Merge pull request #2977 from davotoula/feat/onchain-zaps-reactions-gallery

Show on-chain Bitcoin zappers as a dedicated ₿ row in the expanded reactions gallery
This commit is contained in:
Vitor Pamplona
2026-05-19 10:34:19 -04:00
committed by GitHub
8 changed files with 326 additions and 17 deletions
@@ -1772,13 +1772,13 @@ object LocalCache : ILocalCache, ICacheProvider {
when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach {
it.addOnchainZap(result.txid, result.verifiedSats, confirmed = true)
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = true)
}
}
is VerifiedOnchainZap.Pending -> {
repliesTo.forEach {
it.addOnchainZap(result.txid, result.verifiedSats, confirmed = false)
it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = false)
}
}
@@ -420,3 +420,23 @@ fun VerticalDotsIcon() {
tint = MaterialTheme.colorScheme.placeholderText,
)
}
@Composable
fun OnchainZappedIcon(modifier: Modifier) {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = stringRes(R.string.onchain_zap_description),
tint = BitcoinOrange,
modifier = modifier,
)
}
@Composable
fun PendingClockBadge(modifier: Modifier) {
Icon(
symbol = MaterialSymbols.Schedule,
contentDescription = stringRes(R.string.onchain_zap_pending),
tint = BitcoinOrange,
modifier = modifier,
)
}
@@ -0,0 +1,155 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.OnchainZapEntry
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
private fun onOnchainZapEntryClick(
entry: OnchainZapEntry,
nav: INav,
) {
entry.source.author?.let { nav.nav(routeFor(it)) }
}
@Composable
internal fun WatchOnchainZapsAndRenderGallery(
baseNote: Note,
nav: INav,
accountViewModel: AccountViewModel,
) {
// Reuse the same flow the lightning gallery subscribes to. Note.addOnchainZap
// invalidates flowSet.zaps, so this composable refreshes when on-chain zaps
// arrive or upgrade pending → confirmed. The flow also fires for lightning
// zap arrivals on the same note, so memoize the list snapshot.
val zapsState by observeNoteZaps(baseNote, accountViewModel)
val entries =
remember(zapsState) {
zapsState
?.note
?.onchainZaps
?.values
?.toImmutableList() ?: persistentListOf()
}
if (entries.isNotEmpty()) {
RenderOnchainZapGallery(entries, nav, accountViewModel)
}
}
@Composable
private fun RenderOnchainZapGallery(
entries: ImmutableList<OnchainZapEntry>,
nav: INav,
accountViewModel: AccountViewModel,
) {
Row(Modifier.fillMaxWidth()) {
Box(modifier = WidthAuthorPictureModifier) {
OnchainZappedIcon(
modifier = Modifier.size(Size25dp).align(Alignment.TopEnd),
)
}
OnchainZapAuthorGallery(entries, nav, accountViewModel)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun OnchainZapAuthorGallery(
entries: ImmutableList<OnchainZapEntry>,
nav: INav,
accountViewModel: AccountViewModel,
) {
Column(modifier = StdStartPadding) {
FlowRow {
entries.forEach { entry ->
OnchainZapEntryRow(entry, nav, accountViewModel)
}
}
}
}
@Composable
private fun OnchainZapEntryRow(
entry: OnchainZapEntry,
nav: INav,
accountViewModel: AccountViewModel,
) {
val user = entry.source.author
val amountText =
remember(entry.verifiedSats) {
showAmount(BigDecimal.valueOf(entry.verifiedSats))
}
val avatarAlpha = if (entry.confirmed) 1f else 0.6f
Box(
modifier = Size35Modifier.clickable { onOnchainZapEntryClick(entry, nav) },
contentAlignment = Alignment.BottomCenter,
) {
// Only the avatar dims for pending entries. The amount overlay and clock
// badge stay at full opacity so they remain readable.
Box(modifier = Modifier.alpha(avatarAlpha)) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
user,
accountViewModel,
)
}
CrossfadeToDisplayAmount(amountText)
if (!entry.confirmed) {
// TopStart so the badge doesn't collide with the FollowingIcon
// that WatchUserMetadataAndFollowsAndRenderUserProfilePicture
// paints at TopEnd for followed users.
Box(modifier = Modifier.align(Alignment.TopStart)) {
PendingClockBadge(modifier = Modifier.size(14.dp))
}
}
}
}
@@ -597,6 +597,7 @@ private fun ReactionDetailGallery(
) {
Column {
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
}
+2
View File
@@ -1887,6 +1887,8 @@
<string name="boost_or_quote_description">Boost Or Quote</string>
<string name="like_description">Like</string>
<string name="zap_description">Zap</string>
<string name="onchain_zap_description">On-chain Bitcoin zap</string>
<string name="onchain_zap_pending">Pending confirmation</string>
<string name="change_reaction">Change Quick Reactions</string>
@@ -158,11 +158,13 @@ open class Note(
/**
* NIP-BC verified onchain zaps targeting this note.
* Key: Bitcoin txid (lowercase 64-char hex). Value: verified satoshis paid to the recipient
* (NOT the sender-claimed amount). Confirmed and pending entries live here together;
* `updateZapTotal` only counts confirmed amounts.
* Key: Bitcoin txid (lowercase 64-char hex). Value: verified entry with the source
* OnchainZapEvent note (so `source.author` identifies the sender), the verified
* satoshis paid to the recipient (NOT the sender-claimed amount), and a confirmed
* flag. Confirmed and pending entries live here together; `updateZapTotal` only
* counts confirmed amounts.
*/
var onchainZaps = mapOf<String, OnchainZapAmount>()
var onchainZaps = mapOf<String, OnchainZapEntry>()
private set
var zapPayments = mapOf<Note, Note?>()
@@ -308,7 +310,7 @@ open class Note(
fun removeAllChildNotes(): List<Note> {
val repliesChanged = replies.isNotEmpty()
val reactionsChanged = reactions.isNotEmpty()
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty()
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty()
val boostsChanged = boosts.isNotEmpty()
val reportsChanged = reports.isNotEmpty()
@@ -430,27 +432,34 @@ open class Note(
@Synchronized
private fun innerAddOnchainZap(
txid: String,
amount: OnchainZapAmount,
entry: OnchainZapEntry,
): Boolean {
val existing = onchainZaps[txid]
// Allow upgrading pending → confirmed but never reduce already-stored confirmations.
if (existing != null && existing.confirmed && !amount.confirmed) return false
if (existing == amount) return false
onchainZaps = onchainZaps + Pair(txid, amount)
if (existing != null) {
// Same-state duplicate: keep the first source and amount we got.
if (existing.confirmed == entry.confirmed) return false
// Downgrade confirmed → pending: never accept. States differ here,
// so existing.confirmed alone is sufficient to identify the downgrade.
if (existing.confirmed) return false
// Else: existing pending + incoming confirmed — fall through to upgrade.
}
onchainZaps = onchainZaps + Pair(txid, entry)
return true
}
/**
* Register a NIP-BC verified onchain zap targeting this note. `verifiedSats` MUST come
* from on-chain verification (sum of outputs paying the recipient's derived Taproot
* address), not the sender-claimed `amount` tag.
* address), not the sender-claimed `amount` tag. `source` is the OnchainZapEvent's own
* note — `source.author` is the sender shown in the reactions gallery.
*/
fun addOnchainZap(
source: Note,
txid: String,
verifiedSats: Long,
confirmed: Boolean,
) {
val inserted = innerAddOnchainZap(txid, OnchainZapAmount(verifiedSats, confirmed))
val inserted = innerAddOnchainZap(txid, OnchainZapEntry(source, verifiedSats, confirmed))
if (inserted) {
updateZapTotal()
flowSet?.zaps?.invalidateData()
@@ -20,11 +20,14 @@
*/
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
/**
* Per-(note, txid) verified NIP-BC onchain zap state.
*
* @property source The OnchainZapEvent note that contributed this entry. `source.author` is the
* sender shown in the reactions gallery. When pending confirmed upgrades
* happen, the upgrading event's note replaces the existing `source`.
* @property verifiedSats Satoshis verified to have paid the recipient's derived Taproot
* address on chain. NEVER the sender-claimed `amount` tag.
* @property confirmed True when the transaction has at least one confirmation. Unconfirmed
@@ -33,8 +36,9 @@ import androidx.compose.runtime.Immutable
* SHOULD either exclude them from aggregate totals or clearly label
* them as pending").
*/
@Immutable
data class OnchainZapAmount(
@Stable
data class OnchainZapEntry(
val source: Note,
val verifiedSats: Long,
val confirmed: Boolean,
)
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model
import java.math.BigDecimal
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
import kotlin.test.assertTrue
class NoteOnchainZapTest {
private fun freshNote(idHex: String = "a".repeat(64)) = Note(idHex)
private fun sourceNote(idHex: String) = Note(idHex)
@Test
fun pendingEntryIsStoredWithSourceAndDoesNotAffectTotal() {
val target = freshNote()
val src = sourceNote("b".repeat(64))
target.addOnchainZap(source = src, txid = "tx1", verifiedSats = 1000L, confirmed = false)
val entry = target.onchainZaps["tx1"]
assertEquals(1, target.onchainZaps.size)
assertSame(src, entry?.source)
assertEquals(1000L, entry?.verifiedSats)
assertEquals(false, entry?.confirmed)
assertEquals(BigDecimal.ZERO, target.zapsAmount)
}
@Test
fun confirmedEntryIsStoredAndAddsToTotal() {
val target = freshNote()
val src = sourceNote("c".repeat(64))
target.addOnchainZap(source = src, txid = "tx1", verifiedSats = 5000L, confirmed = true)
assertEquals(true, target.onchainZaps["tx1"]?.confirmed)
assertEquals(BigDecimal.valueOf(5000L), target.zapsAmount)
}
@Test
fun pendingThenConfirmedReplacesSourceAndUpdatesTotal() {
val target = freshNote()
val firstSrc = sourceNote("d".repeat(64))
val secondSrc = sourceNote("e".repeat(64))
target.addOnchainZap(firstSrc, "tx1", 2500L, confirmed = false)
target.addOnchainZap(secondSrc, "tx1", 2500L, confirmed = true)
val entry = target.onchainZaps["tx1"]
assertSame(secondSrc, entry?.source)
assertEquals(true, entry?.confirmed)
assertEquals(BigDecimal.valueOf(2500L), target.zapsAmount)
}
@Test
fun confirmedThenPendingIsIgnored() {
val target = freshNote()
val firstSrc = sourceNote("f".repeat(64))
val secondSrc = sourceNote("0".repeat(64))
target.addOnchainZap(firstSrc, "tx1", 7500L, confirmed = true)
target.addOnchainZap(secondSrc, "tx1", 7500L, confirmed = false)
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(true, entry?.confirmed)
assertEquals(BigDecimal.valueOf(7500L), target.zapsAmount)
}
@Test
fun sameStateDuplicateIsIgnored() {
val target = freshNote()
val firstSrc = sourceNote("1".repeat(64))
val secondSrc = sourceNote("2".repeat(64))
target.addOnchainZap(firstSrc, "tx1", 100L, confirmed = true)
// Mismatched verifiedSats so we can detect a regression that silently
// overwrites the first entry with the second.
target.addOnchainZap(secondSrc, "tx1", 999L, confirmed = true)
val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(100L, entry?.verifiedSats)
assertEquals(BigDecimal.valueOf(100L), target.zapsAmount)
}
@Test
fun updateZapTotalSumsConfirmedAcrossMultipleTxids() {
val target = freshNote()
target.addOnchainZap(sourceNote("3".repeat(64)), "tx1", 1000L, confirmed = true)
target.addOnchainZap(sourceNote("4".repeat(64)), "tx2", 2000L, confirmed = true)
target.addOnchainZap(sourceNote("5".repeat(64)), "tx3", 9999L, confirmed = false)
assertEquals(BigDecimal.valueOf(3000L), target.zapsAmount)
assertEquals(3, target.onchainZaps.size)
assertTrue(target.onchainZaps.containsKey("tx3"))
}
}