Show on-chain zappers in expanded reactions gallery

- Add OnchainZappedIcon and PendingClockBadge
- move PendingClockBadge to TopStart to avoid follow-dot clash
This commit is contained in:
davotoula
2026-05-18 23:52:30 +02:00
parent d41cf6d75b
commit 91ded74636
8 changed files with 359 additions and 16 deletions
@@ -1772,13 +1772,13 @@ object LocalCache : ILocalCache, ICacheProvider {
when (val result = verifier.verify(event)) { when (val result = verifier.verify(event)) {
is VerifiedOnchainZap.Confirmed -> { is VerifiedOnchainZap.Confirmed -> {
repliesTo.forEach { repliesTo.forEach {
it.addOnchainZap(result.txid, result.verifiedSats, confirmed = true) it.addOnchainZap(note, result.txid, result.verifiedSats, confirmed = true)
} }
} }
is VerifiedOnchainZap.Pending -> { is VerifiedOnchainZap.Pending -> {
repliesTo.forEach { 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, 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,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<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
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))
}
}
}
}
}
@@ -597,6 +597,7 @@ private fun ReactionDetailGallery(
) { ) {
Column { Column {
WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel) WatchZapAndRenderGallery(baseNote, backgroundColor, nav, accountViewModel)
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel) WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
WatchReactionsAndRenderGallery(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="boost_or_quote_description">Boost Or Quote</string>
<string name="like_description">Like</string> <string name="like_description">Like</string>
<string name="zap_description">Zap</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> <string name="change_reaction">Change Quick Reactions</string>
@@ -158,11 +158,13 @@ open class Note(
/** /**
* NIP-BC verified onchain zaps targeting this note. * NIP-BC verified onchain zaps targeting this note.
* Key: Bitcoin txid (lowercase 64-char hex). Value: verified satoshis paid to the recipient * Key: Bitcoin txid (lowercase 64-char hex). Value: verified entry with the source
* (NOT the sender-claimed amount). Confirmed and pending entries live here together; * OnchainZapEvent note (so `source.author` identifies the sender), the verified
* `updateZapTotal` only counts confirmed amounts. * 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 private set
var zapPayments = mapOf<Note, Note?>() var zapPayments = mapOf<Note, Note?>()
@@ -430,27 +432,35 @@ open class Note(
@Synchronized @Synchronized
private fun innerAddOnchainZap( private fun innerAddOnchainZap(
txid: String, txid: String,
amount: OnchainZapAmount, entry: OnchainZapEntry,
): Boolean { ): Boolean {
val existing = onchainZaps[txid] val existing = onchainZaps[txid]
// Allow upgrading pending → confirmed but never reduce already-stored confirmations. if (existing == null) {
if (existing != null && existing.confirmed && !amount.confirmed) return false onchainZaps = onchainZaps + Pair(txid, entry)
if (existing == amount) return false return true
onchainZaps = onchainZaps + Pair(txid, amount) }
// 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 return true
} }
/** /**
* Register a NIP-BC verified onchain zap targeting this note. `verifiedSats` MUST come * 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 * 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( fun addOnchainZap(
source: Note,
txid: String, txid: String,
verifiedSats: Long, verifiedSats: Long,
confirmed: Boolean, confirmed: Boolean,
) { ) {
val inserted = innerAddOnchainZap(txid, OnchainZapAmount(verifiedSats, confirmed)) val inserted = innerAddOnchainZap(txid, OnchainZapEntry(source, verifiedSats, confirmed))
if (inserted) { if (inserted) {
updateZapTotal() updateZapTotal()
flowSet?.zaps?.invalidateData() flowSet?.zaps?.invalidateData()
@@ -20,11 +20,14 @@
*/ */
package com.vitorpamplona.amethyst.commons.model 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. * 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 * @property verifiedSats Satoshis verified to have paid the recipient's derived Taproot
* address on chain. NEVER the sender-claimed `amount` tag. * address on chain. NEVER the sender-claimed `amount` tag.
* @property confirmed True when the transaction has at least one confirmation. Unconfirmed * @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 * SHOULD either exclude them from aggregate totals or clearly label
* them as pending"). * them as pending").
*/ */
@Immutable @Stable
data class OnchainZapAmount( data class OnchainZapEntry(
val source: Note,
val verifiedSats: Long, val verifiedSats: Long,
val confirmed: Boolean, val confirmed: Boolean,
) )
@@ -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"))
}
}