Code review:
- invalidate zaps flow when removeAllChildNotes clears onchainZaps - simplify on-chain zap gallery after review
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user