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 3aba7540d1..54cc96c0be 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 @@ -1458,7 +1458,8 @@ fun ObserveZapIconState( val hasZapData = zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true || - zapsState?.note?.nutzaps?.isNotEmpty() == true + zapsState?.note?.nutzaps?.isNotEmpty() == true || + zapsState?.note?.onchainZaps?.isNotEmpty() == true val wasZapped = if (hasZapData) { accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds) @@ -1538,7 +1539,14 @@ fun ObserveZapAmountText( inner(zapAmountTxt) } else { - inner(showAmount(zapsState?.note?.zapsAmount)) + // Include the signed-in user's own pending onchain zaps so + // the counter reflects the optimistic value the gallery shows. + val ownPubKey = accountViewModel.account.userProfile().pubkeyHex + val note = zapsState?.note + val total = + (note?.zapsAmount ?: java.math.BigDecimal(0)) + + java.math.BigDecimal(note?.extraOwnPendingOnchainSats(ownPubKey) ?: 0L) + inner(showAmount(total)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 35014b1745..cb40bb677e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -623,15 +623,21 @@ class AccountViewModel( account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds) } - suspend fun calculateZapAmount(zappedNote: Note): String = - if (zappedNote.zapPayments.isNotEmpty()) { + suspend fun calculateZapAmount(zappedNote: Note): String { + // The signed-in user's own outgoing onchain zaps that aren't + // yet CONFIRMED still need to show in the counter — the user + // knows what they sent, so make the counter reflect reality + // immediately instead of waiting for chain confirmation. + val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex) + return if (zappedNote.zapPayments.isNotEmpty()) { withContext(Dispatchers.IO) { - val it = account.calculateZappedAmount(zappedNote) - showAmount(it) + val nwc = account.calculateZappedAmount(zappedNote) + showAmount(nwc + java.math.BigDecimal(ownPendingOnchain)) } } else { - showAmount(zappedNote.zapsAmount) + showAmount(zappedNote.zapsAmount + java.math.BigDecimal(ownPendingOnchain)) } + } suspend fun calculateZapraiser(zappedNote: Note): ZapraiserStatus { val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 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 3a33ab8476..ff6d04c121 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 @@ -769,12 +769,14 @@ open class Note( afterTimeInSeconds: Long, account: IAccount, ): Boolean { - // NIP-61 nutzaps: the sender is the source event's pubkey, so - // the check is direct — no private-zap decryption needed (cashu - // doesn't have a private-recipient variant the way NIP-57 does). - // Run this first; it's an in-memory scan and a hit short-circuits - // the more expensive lightning path. + // NIP-61 nutzaps and NIP-BC onchain zaps: the sender is the + // source event's pubkey, so the check is direct — no + // private-zap decryption needed. Run these first; they're + // in-memory scans and a hit short-circuits the more expensive + // lightning path (which may have to decrypt NIP-44-private + // zap requests). if (isNutzappedBy(user, afterTimeInSeconds)) return true + if (isOnchainZappedBy(user, afterTimeInSeconds)) return true val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps) if (first) return true @@ -793,6 +795,40 @@ open class Note( entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds } + private fun isOnchainZappedBy( + user: User, + afterTimeInSeconds: Long, + ): Boolean = + onchainZaps.values.any { entry -> + val sourceEvent = entry.source.event ?: return@any false + entry.source.author == user && sourceEvent.createdAt > afterTimeInSeconds + } + + /** + * Extra sats to add on top of [zapsAmount] for the reaction-row + * counter when the signed-in user has outgoing onchain zaps on + * this note that aren't yet CONFIRMED. [updateZapTotal] only + * counts CONFIRMED onchain entries (verifiedSats) because incoming + * sender-claimed amounts are spoofable. The signed-in user's OWN + * outgoing zap is trusted at face value though — they know what + * they sent — so the counter should reflect it immediately, the + * same way the gallery shows their own UNVERIFIED entry with the + * claimed amount. Other senders' non-confirmed entries still + * contribute 0 here. + */ + fun extraOwnPendingOnchainSats(loggedInPubKey: HexKey?): Long { + if (loggedInPubKey == null) return 0L + var sum = 0L + onchainZaps.values.forEach { entry -> + if (entry.status != OnchainZapStatus.CONFIRMED && + entry.source.author?.pubkeyHex == loggedInPubKey + ) { + sum += entry.claimedSats + } + } + return sum + } + suspend fun isZappedBy( option: Int?, user: User,