From 4ab73789a647f2638b07d76831959426dd1e794e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Jul 2026 10:04:17 +0300 Subject: [PATCH] feat(desktop): profile Zaps received tab - Wire real PrivateZapCache(signer) into DesktopIAccount (was a null stub) - Subscribe to kind 9735 receipts p-tagging the profile; aggregate sats per zapper (private zaps decrypt only on your own profile, else fall back to the public zap-request pubkey) - New ProfileZapRow; tab header shows the total sats Co-Authored-By: Claude Opus 4.8 --- .../amethyst/desktop/model/DesktopIAccount.kt | 13 ++- .../amethyst/desktop/ui/UserProfileScreen.kt | 81 ++++++++++++++++ .../desktop/ui/profile/ProfileZapRow.kt | 94 +++++++++++++++++++ 3 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileZapRow.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 5c83476af8..3d9b28bbb2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag @@ -193,12 +193,11 @@ class DesktopIAccount( override fun isNIP47Author(pubKey: String?): Boolean = false } - override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = - object : IPrivateZapsDecryptionCache { - override fun cachedPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null - - override suspend fun decryptPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null - } + // Real private-zap decryption backed by the account signer. Only decrypts + // zaps addressed to (or sent by) this account — i.e. useful on your own + // profile; on a third party's profile the zapper falls back to the public + // zap-request pubkey. LRU(1000), lazy per event. + override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = PrivateZapCache(signer) override fun userProfile(): User = localCache.getOrCreateUser(pubKey) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 1ad755466d..0935d6c666 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab +import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileZapRow import com.vitorpamplona.amethyst.desktop.ui.profile.RelayRowCard import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -123,6 +124,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent @@ -133,6 +135,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.Toolkit import java.awt.datatransfer.StringSelection +import java.math.BigDecimal /** * User profile screen showing user info, follow button, and their posts. @@ -353,6 +356,11 @@ fun UserProfileScreen( // Each relay entry: (url, canRead, canWrite) var relayList by remember(pubKeyHex) { mutableStateOf>>(emptyList()) } + // Zaps received: raw zap receipts, aggregated per zapper (pubkey → total sats). + val zapEvents = remember(pubKeyHex) { mutableStateListOf() } + var zapAmounts by remember(pubKeyHex) { mutableStateOf>>(emptyList()) } + var zapTotalSats by remember(pubKeyHex) { mutableStateOf(BigDecimal.ZERO) } + // Follow state val followState = remember(account) { @@ -688,6 +696,54 @@ fun UserProfileScreen( } } + // Subscribe to zap receipts (kind 9735) that tag this profile (Zaps tab) + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + zapEvents.clear() + SubscriptionConfig( + subId = generateSubId("zaps-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byPTags( + pubKeys = listOf(pubKeyHex), + kinds = listOf(LnZapEvent.KIND), + limit = 500, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent && zapEvents.none { it.id == event.id }) { + zapEvents.add(event) + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Aggregate zaps per zapper. Private zaps only decrypt on the viewer's own + // profile (needs the account signer); otherwise the public zap-request + // pubkey is used. + LaunchedEffect(zapEvents.size, pubKeyHex, account) { + val byUser = HashMap() + val isOwn = account != null && pubKeyHex == account.pubKeyHex + zapEvents.toList().forEach { z -> + val req = z.zapRequest + val zapper = + when { + req == null -> z.pubKey + req.isPrivateZap() && isOwn -> + iAccount?.privateZapsDecryptionCache?.decryptPrivateZap(req)?.pubKey ?: req.pubKey + else -> req.pubKey + } + byUser[zapper] = (byUser[zapper] ?: BigDecimal.ZERO) + (z.amount ?: BigDecimal.ZERO) + } + zapAmounts = byUser.entries.sortedByDescending { it.value }.map { it.key to it.value } + zapTotalSats = byUser.values.fold(BigDecimal.ZERO) { a, b -> a + b } + } + // Scroll state for detecting scroll direction val listState = rememberLazyListState() var showFloatingHeader by remember { mutableStateOf(false) } @@ -1218,6 +1274,12 @@ fun UserProfileScreen( Tab(selected = selectedTab == 9, onClick = { selectedTab = 9 }) { Text("Mutual", modifier = Modifier.padding(12.dp)) } + Tab(selected = selectedTab == 10, onClick = { selectedTab = 10 }) { + Text( + "Zaps${if (zapTotalSats.signum() > 0) " (${zapTotalSats.toBigInteger()})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } } } @@ -1564,6 +1626,25 @@ fun UserProfileScreen( } } } + + 10 -> { + if (zapAmounts.isEmpty()) { + item(key = "no-zaps") { ProfileTabMessage("No zaps received yet") } + } else { + items(zapAmounts, key = { "zap-${it.first}" }) { (pk, sats) -> + val u = remember(pk) { localCache.getUserIfExists(pk) } + ProfileZapRow( + userHex = pk, + displayName = + u?.metadataOrNull()?.bestName() + ?: (pk.hexToByteArrayOrNull()?.toNpub()?.take(16) ?: pk.take(16)), + pictureUrl = u?.metadataOrNull()?.profilePicture(), + sats = sats.toBigInteger().toString(), + onClick = { onNavigateToProfile(pk) }, + ) + } + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileZapRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileZapRow.kt new file mode 100644 index 0000000000..ac96149f86 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileZapRow.kt @@ -0,0 +1,94 @@ +/* + * 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.desktop.ui.profile + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar + +/** + * A single zapper row for the profile Zaps tab: avatar + name on the left, the + * total sats they zapped (Bolt icon + amount) on the right. + */ +@Composable +fun ProfileZapRow( + userHex: String, + displayName: String, + pictureUrl: String?, + sats: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + WoTBadgedAvatar( + userHex = userHex, + pictureUrl = pictureUrl, + size = 36.dp, + contentDescription = null, + ) + Spacer(Modifier.width(10.dp)) + Text( + displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.width(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(2.dp)) + Text( + sats, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + } + } +}