From 238c9ea626bd4643be396f75ba547f7cac426c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:47:12 +0000 Subject: [PATCH] feat(live-chat): display zap receipts inline in live stream chat Subscribe to kind 9735 alongside kind 1311 against the stream's #a tag, route host-directed zaps into the LiveActivitiesChannel cache, and render them as a centered, lightning-accented row with the zapper, amount, and optional zap message. Matches zap.stream's chat behavior for NIP-53 + NIP-57 interop. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 20 +++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 34 ++-- .../chats/feed/types/RenderChatZap.kt | 146 ++++++++++++++++++ .../FilterMessagesToLiveStream.kt | 3 +- amethyst/src/main/res/values/strings.xml | 2 + 5 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 8512b8d51..305da3fcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1541,6 +1541,8 @@ object LocalCache : ILocalCache, ICacheProvider { repliesTo.forEach { it.addZap(zapRequest, note) } + attachZapToLiveActivityChannel(event, note, relay) + refreshNewNoteObservers(note) return true @@ -1549,6 +1551,24 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + private fun attachZapToLiveActivityChannel( + event: LnZapEvent, + note: Note, + relay: NormalizedRelayUrl?, + ) { + val address = + event.tags + .asSequence() + .mapNotNull(ATag::parseAddress) + .firstOrNull { it.kind == LiveActivitiesEvent.KIND } + ?: return + + // Match zap.stream: only show zaps whose receiver is the live activity host + if (event.zappedAuthor().none { it == address.pubKeyHex }) return + + getOrCreateLiveChannel(address).addNote(note, relay) + } + fun consume( event: LnZapRequestEvent, relay: NormalizedRelayUrl?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index e2d5fac54..753f5cf90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile @@ -84,6 +85,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -109,20 +111,24 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> - NormalChatNote( - baseNote, - routeForLastRead, - innerQuote, - canPreview, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply, - onWantsToEditDraft, - onScrollToNote, - shouldHighlight, - onHighlightFinished, - ) + if (baseNote.event is LnZapEvent) { + RenderChatZap(baseNote, accountViewModel, nav) + } else { + NormalChatNote( + baseNote, + routeForLastRead, + innerQuote, + canPreview, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + onWantsToEditDraft, + onScrollToNote, + shouldHighlight, + onHighlightFinished, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt new file mode 100644 index 000000000..449d500bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -0,0 +1,146 @@ +/* + * 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.screen.loggedIn.chats.feed.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +private const val BIG_ZAP_THRESHOLD_SATS = 50_000L + +@Composable +fun RenderChatZap( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val zapEvent = baseNote.event as? LnZapEvent ?: return + + val card by produceState( + ZapAmountCommentNotification(user = null, comment = null, amount = null), + baseNote, + ) { + value = accountViewModel.innerDecryptAmountMessage(baseNote) ?: value + } + + val isBigZap = + remember(zapEvent) { + (zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS + } + + val containerColor = + if (isBigZap) { + BitcoinOrange.copy(alpha = 0.18f) + } else { + BitcoinOrange.copy(alpha = 0.08f) + } + + val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) } + + val amountText = card.amount ?: showAmountInteger(zapEvent.amount) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(containerColor) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ZapIcon( + if (isBigZap) Size24Modifier else Size20Modifier, + BitcoinOrange, + ) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val sender = card.user + if (sender != null) { + UserPicture(sender, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + UsernameDisplay( + baseUser = sender, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } else { + Text( + text = stringRes(R.string.chat_zap_anonymous), + fontWeight = FontWeight.Bold, + ) + } + + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.chat_zap_amount_suffix, amountText), + color = BitcoinOrange, + fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal, + ) + } + + card.comment?.takeIf { it.isNotBlank() }?.let { comment -> + Spacer(Modifier.padding(top = 2.dp)) + CrossfadeToDisplayComment( + comment = comment, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 6ce98ef3b..10da3f52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -35,7 +36,7 @@ fun filterMessagesToLiveActivities( relay = it, filter = Filter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LnZapEvent.KIND), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, since = since?.get(it)?.time, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b537cbcf6..63b4ad22c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -53,6 +53,8 @@ You are using a public key and public keys are read-only. Login with a Private key to be able to boost posts You are using a public key and public keys are read-only. Login with a Private key to like posts No Zap Amount Setup. Long Press to change + zapped %1$s sats + Anonymous You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps You are using a public key and public keys are read-only. Login with a Private key to be able to follow You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow