From 91ded746363dbb0ab271be4da3c629cf094dded1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 18 May 2026 23:52:30 +0200 Subject: [PATCH] Show on-chain zappers in expanded reactions gallery - Add OnchainZappedIcon and PendingClockBadge - move PendingClockBadge to TopStart to avoid follow-dot clash --- .../amethyst/model/LocalCache.kt | 4 +- .../vitorpamplona/amethyst/ui/note/Icons.kt | 20 ++ .../amethyst/ui/note/OnchainZapGallery.kt | 192 ++++++++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 1 + amethyst/src/main/res/values/strings.xml | 2 + .../amethyst/commons/model/Note.kt | 32 ++- ...OnchainZapAmount.kt => OnchainZapEntry.kt} | 10 +- .../commons/model/NoteOnchainZapTest.kt | 114 +++++++++++ 8 files changed, 359 insertions(+), 16 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/OnchainZapGallery.kt rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/{OnchainZapAmount.kt => OnchainZapEntry.kt} (83%) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d60787d01..c0caa44f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index d56ee0b80..6f7f00405 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/OnchainZapGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/OnchainZapGallery.kt new file mode 100644 index 000000000..6a42024ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/OnchainZapGallery.kt @@ -0,0 +1,192 @@ +/* + * 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.background +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.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.Size35dp +import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.amethyst.ui.theme.overPictureBackground +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. + val zapsState by observeNoteZaps(baseNote, accountViewModel) + val entries = + zapsState + ?.note + ?.onchainZaps + ?.values + ?.toImmutableList() ?: persistentListOf() + + if (entries.isNotEmpty()) { + RenderOnchainZapGallery(entries, nav, accountViewModel) + } +} + +@Composable +private fun RenderOnchainZapGallery( + entries: ImmutableList, + 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, + 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 + + Row( + modifier = + Modifier.clickable { onOnchainZapEntryClick(entry, nav) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Size35Modifier, + 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, + ) + } + + // Amount overlay — same look as the lightning row's CrossfadeToDisplayAmount. + Box( + modifier = + Modifier + .size(Size35dp) + .clip(CircleShape), + contentAlignment = Alignment.BottomCenter, + ) { + val overlayBg = MaterialTheme.colorScheme.overPictureBackground + Box( + modifier = remember(overlayBg) { Modifier.width(Size35dp).background(overlayBg) }, + contentAlignment = Alignment.BottomCenter, + ) { + Text( + text = amountText, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.bitcoinColor, + fontSize = 12.sp, + modifier = bottomPadding1dp, + ) + } + } + + 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)) + } + } + } + } +} 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 bed452a13..4fc82beaf 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 @@ -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) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cbc2ed5db..082392e1d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1887,6 +1887,8 @@ Boost Or Quote Like Zap + On-chain Bitcoin zap + Pending confirmation Change Quick Reactions diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index d4bf2a061..3ee570c57 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -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() + var onchainZaps = mapOf() private set var zapPayments = mapOf() @@ -430,27 +432,35 @@ 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) { + onchainZaps = onchainZaps + Pair(txid, entry) + return true + } + // Same-state duplicate: keep the first source and amount we got. + if (existing.confirmed == entry.confirmed) return false + // Downgrade confirmed → pending: never accept. + if (existing.confirmed && !entry.confirmed) return false + // Upgrade pending → confirmed: replace, so the upgrading event's source wins. + 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() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapEntry.kt similarity index 83% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapEntry.kt index d7c4bcb65..5f0d8e3a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapEntry.kt @@ -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, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt new file mode 100644 index 000000000..2c57eedc6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteOnchainZapTest.kt @@ -0,0 +1,114 @@ +/* + * 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) + target.addOnchainZap(secondSrc, "tx1", 100L, confirmed = true) + + assertSame(firstSrc, target.onchainZaps["tx1"]?.source) + 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")) + } +}