Code review:

- invalidate zaps flow when removeAllChildNotes clears onchainZaps
- simplify on-chain zap gallery after review
This commit is contained in:
davotoula
2026-05-19 12:58:22 +02:00
parent 91ded74636
commit cf44c092dd
3 changed files with 41 additions and 75 deletions
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.note package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -29,20 +28,13 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.model.OnchainZapEntry import com.vitorpamplona.amethyst.commons.model.OnchainZapEntry
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
@@ -51,11 +43,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier 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.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -76,14 +65,17 @@ internal fun WatchOnchainZapsAndRenderGallery(
) { ) {
// Reuse the same flow the lightning gallery subscribes to. Note.addOnchainZap // Reuse the same flow the lightning gallery subscribes to. Note.addOnchainZap
// invalidates flowSet.zaps, so this composable refreshes when on-chain zaps // invalidates flowSet.zaps, so this composable refreshes when on-chain zaps
// arrive or upgrade pending → confirmed. // 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 zapsState by observeNoteZaps(baseNote, accountViewModel)
val entries = val entries =
zapsState remember(zapsState) {
?.note zapsState
?.onchainZaps ?.note
?.values ?.onchainZaps
?.toImmutableList() ?: persistentListOf() ?.values
?.toImmutableList() ?: persistentListOf()
}
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
RenderOnchainZapGallery(entries, nav, accountViewModel) RenderOnchainZapGallery(entries, nav, accountViewModel)
@@ -136,56 +128,27 @@ private fun OnchainZapEntryRow(
} }
val avatarAlpha = if (entry.confirmed) 1f else 0.6f val avatarAlpha = if (entry.confirmed) 1f else 0.6f
Row( Box(
modifier = modifier = Size35Modifier.clickable { onOnchainZapEntryClick(entry, nav) },
Modifier.clickable { onOnchainZapEntryClick(entry, nav) }, contentAlignment = Alignment.BottomCenter,
verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( // Only the avatar dims for pending entries. The amount overlay and clock
modifier = Size35Modifier, // badge stay at full opacity so they remain readable.
contentAlignment = Alignment.BottomCenter, Box(modifier = Modifier.alpha(avatarAlpha)) {
) { WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
// Only the avatar dims for pending entries. The amount overlay and clock user,
// badge stay at full opacity so they remain readable. accountViewModel,
Box(modifier = Modifier.alpha(avatarAlpha)) { )
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( }
user,
accountViewModel,
)
}
// Amount overlay — same look as the lightning row's CrossfadeToDisplayAmount. CrossfadeToDisplayAmount(amountText)
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) { if (!entry.confirmed) {
// TopStart so the badge doesn't collide with the FollowingIcon // TopStart so the badge doesn't collide with the FollowingIcon
// that WatchUserMetadataAndFollowsAndRenderUserProfilePicture // that WatchUserMetadataAndFollowsAndRenderUserProfilePicture
// paints at TopEnd for followed users. // paints at TopEnd for followed users.
Box( Box(modifier = Modifier.align(Alignment.TopStart)) {
modifier = Modifier.align(Alignment.TopStart), PendingClockBadge(modifier = Modifier.size(14.dp))
) {
PendingClockBadge(modifier = Modifier.size(14.dp))
}
} }
} }
} }
@@ -310,7 +310,7 @@ open class Note(
fun removeAllChildNotes(): List<Note> { fun removeAllChildNotes(): List<Note> {
val repliesChanged = replies.isNotEmpty() val repliesChanged = replies.isNotEmpty()
val reactionsChanged = reactions.isNotEmpty() val reactionsChanged = reactions.isNotEmpty()
val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty()
val boostsChanged = boosts.isNotEmpty() val boostsChanged = boosts.isNotEmpty()
val reportsChanged = reports.isNotEmpty() val reportsChanged = reports.isNotEmpty()
@@ -435,15 +435,14 @@ open class Note(
entry: OnchainZapEntry, entry: OnchainZapEntry,
): Boolean { ): Boolean {
val existing = onchainZaps[txid] val existing = onchainZaps[txid]
if (existing == null) { if (existing != null) {
onchainZaps = onchainZaps + Pair(txid, entry) // Same-state duplicate: keep the first source and amount we got.
return true 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.
} }
// 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) onchainZaps = onchainZaps + Pair(txid, entry)
return true return true
} }
@@ -94,9 +94,13 @@ class NoteOnchainZapTest {
val secondSrc = sourceNote("2".repeat(64)) val secondSrc = sourceNote("2".repeat(64))
target.addOnchainZap(firstSrc, "tx1", 100L, confirmed = true) target.addOnchainZap(firstSrc, "tx1", 100L, confirmed = true)
target.addOnchainZap(secondSrc, "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)
assertSame(firstSrc, target.onchainZaps["tx1"]?.source) val entry = target.onchainZaps["tx1"]
assertSame(firstSrc, entry?.source)
assertEquals(100L, entry?.verifiedSats)
assertEquals(BigDecimal.valueOf(100L), target.zapsAmount) assertEquals(BigDecimal.valueOf(100L), target.zapsAmount)
} }