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
This commit is contained in:
@@ -1541,6 +1541,8 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
|
|
||||||
repliesTo.forEach { it.addZap(zapRequest, note) }
|
repliesTo.forEach { it.addZap(zapRequest, note) }
|
||||||
|
|
||||||
|
attachZapToLiveActivityChannel(event, note, relay)
|
||||||
|
|
||||||
refreshNewNoteObservers(note)
|
refreshNewNoteObservers(note)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -1549,6 +1551,24 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
return false
|
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(
|
fun consume(
|
||||||
event: LnZapRequestEvent,
|
event: LnZapRequestEvent,
|
||||||
relay: NormalizedRelayUrl?,
|
relay: NormalizedRelayUrl?,
|
||||||
|
|||||||
+20
-14
@@ -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.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
|
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.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.RenderCreateChannelNote
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
|
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.ChannelCreateEvent
|
||||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
|
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||||
|
|
||||||
@@ -109,20 +111,24 @@ fun ChatroomMessageCompose(
|
|||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
) { canPreview ->
|
) { canPreview ->
|
||||||
NormalChatNote(
|
if (baseNote.event is LnZapEvent) {
|
||||||
baseNote,
|
RenderChatZap(baseNote, accountViewModel, nav)
|
||||||
routeForLastRead,
|
} else {
|
||||||
innerQuote,
|
NormalChatNote(
|
||||||
canPreview,
|
baseNote,
|
||||||
parentBackgroundColor,
|
routeForLastRead,
|
||||||
accountViewModel,
|
innerQuote,
|
||||||
nav,
|
canPreview,
|
||||||
onWantsToReply,
|
parentBackgroundColor,
|
||||||
onWantsToEditDraft,
|
accountViewModel,
|
||||||
onScrollToNote,
|
nav,
|
||||||
shouldHighlight,
|
onWantsToReply,
|
||||||
onHighlightFinished,
|
onWantsToEditDraft,
|
||||||
)
|
onScrollToNote,
|
||||||
|
shouldHighlight,
|
||||||
|
onHighlightFinished,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+146
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
|
|
||||||
fun filterMessagesToLiveActivities(
|
fun filterMessagesToLiveActivities(
|
||||||
channel: LiveActivitiesChannel,
|
channel: LiveActivitiesChannel,
|
||||||
@@ -35,7 +36,7 @@ fun filterMessagesToLiveActivities(
|
|||||||
relay = it,
|
relay = it,
|
||||||
filter =
|
filter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
|
kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LnZapEvent.KIND),
|
||||||
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
|
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
|
||||||
limit = 200,
|
limit = 200,
|
||||||
since = since?.get(it)?.time,
|
since = since?.get(it)?.time,
|
||||||
|
|||||||
@@ -53,6 +53,8 @@
|
|||||||
<string name="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 be able to boost posts</string>
|
<string name="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 be able to boost posts</string>
|
||||||
<string name="login_with_a_private_key_to_like_posts">You are using a public key and public keys are read-only. Login with a Private key to like posts</string>
|
<string name="login_with_a_private_key_to_like_posts">You are using a public key and public keys are read-only. Login with a Private key to like posts</string>
|
||||||
<string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string>
|
<string name="no_zap_amount_setup_long_press_to_change">No Zap Amount Setup. Long Press to change</string>
|
||||||
|
<string name="chat_zap_amount_suffix">zapped %1$s sats</string>
|
||||||
|
<string name="chat_zap_anonymous">Anonymous</string>
|
||||||
<string name="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 send zaps</string>
|
<string name="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 send zaps</string>
|
||||||
<string name="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 follow</string>
|
<string name="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 follow</string>
|
||||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string>
|
<string name="login_with_a_private_key_to_be_able_to_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user