From 4786647762b89f6afae42fd6928a70119b99f877 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:29:50 +0000 Subject: [PATCH] feat(live-chat): display live stream raids (kind 1312) inline Add a Quartz LiveActivitiesRaidEvent for zap.stream's kind 1312 raid convention with root/mention a-tag markers. Wire it into the event factory, subscribe to it in the live-activity chat filter, and route received raids into both source and target LiveActivitiesChannel caches so viewers on either side see the notification. Render raids as an accent-colored pill showing "X is raiding Y" that navigates to the target stream when tapped. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 22 +++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 7 +- .../chats/feed/types/RenderChatRaid.kt | 147 ++++++++++++++++++ .../FilterMessagesToLiveStream.kt | 8 +- amethyst/src/main/res/values/strings.xml | 1 + .../raid/LiveActivitiesRaidEvent.kt | 115 ++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 7 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.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 305da3fcf..124d7db39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -172,6 +172,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -1495,6 +1496,26 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + fun consume( + event: LiveActivitiesRaidEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val fromAddress = event.fromAddress() + val toAddress = event.toAddress() + if (fromAddress == null && toAddress == null) return false + + val new = consumeRegularEvent(event, relay, wasVerified) + + if (new) { + val note = getOrCreateNote(event.id) + fromAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) } + toAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) } + } + + return new + } + @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelHideMessageEvent, @@ -2667,6 +2688,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) + is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified) 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 753f5cf90..fa5e645ce 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.RenderChatRaid 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 @@ -85,6 +86,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.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -111,8 +113,11 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> - if (baseNote.event is LnZapEvent) { + val event = baseNote.event + if (event is LnZapEvent) { RenderChatZap(baseNote, accountViewModel, nav) + } else if (event is LiveActivitiesRaidEvent) { + RenderChatRaid(baseNote, accountViewModel, nav) } else { NormalChatNote( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt new file mode 100644 index 000000000..00b0b6f31 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -0,0 +1,147 @@ +/* + * 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.clickable +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.navigation.routes.Route +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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent + +@Composable +fun RenderChatRaid( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val raid = baseNote.event as? LiveActivitiesRaidEvent ?: return + val from = remember(raid) { raid.fromAddress() } + val to = remember(raid) { raid.toAddress() } + + // Without both endpoints we can't render a meaningful raid card. + if (from == null || to == null) return + + val accent = MaterialTheme.colorScheme.primary + val containerColor = remember(accent) { accent.copy(alpha = 0.14f) } + val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(containerColor) + .clickable { nav.nav(to.toRoute()) } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } + } + + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.chat_raid_is_raiding), + color = accent, + fontWeight = FontWeight.Bold, + ) + Spacer(StdHorzSpacer) + + UserPicture(to.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + LoadUser(baseUserHex = to.pubKeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } + } + } + + val message = raid.content + if (message.isNotBlank()) { + Spacer(Modifier.padding(top = 2.dp)) + CrossfadeToDisplayComment( + comment = message, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } + } + + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = null, + tint = accent, + modifier = Size20Modifier, + ) + } +} + +private fun Address.toRoute(): Route = Route.LiveActivityChannel(kind, pubKeyHex, dTag) 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 10da3f52c..15fd2035c 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.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent fun filterMessagesToLiveActivities( @@ -36,7 +37,12 @@ fun filterMessagesToLiveActivities( relay = it, filter = Filter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LnZapEvent.KIND), + kinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesRaidEvent.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 63b4ad22c..2a2eb75d0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ No Zap Amount Setup. Long Press to change zapped %1$s sats Anonymous + is raiding 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt new file mode 100644 index 000000000..cbf9f605e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt @@ -0,0 +1,115 @@ +/* + * 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.quartz.nip53LiveActivities.raid + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-53 live stream raid (zap.stream convention, kind 1312). + * + * A raid is authored by the source streamer to redirect viewers to another live + * stream. The event carries two `a` tags referencing NIP-53 Live Activities + * (kind 30311) differentiated by NIP-10-style markers at position 3: + * - "root" -> the source stream (the raid is being sent FROM) + * - "mention" -> the target stream (the raid is being sent TO) + * + * The `content` is a free-text raid message from the source streamer. + */ +@Immutable +class LiveActivitiesRaidEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + fun fromActivity(): ATag? = findActivity(MARKER_ROOT) + + fun toActivity(): ATag? = findActivity(MARKER_MENTION) + + fun fromAddress(): Address? = fromActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) } + + fun toAddress(): Address? = toActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) } + + private fun findActivity(marker: String): ATag? = + tags + .asSequence() + .filter { it.size > 3 && it[0] == ATag.TAG_NAME && it[3] == marker } + .mapNotNull(ATag::parse) + .firstOrNull { it.kind == LiveActivitiesEvent.KIND } + + companion object { + const val KIND = 1312 + const val ALT = "Live activity raid" + const val MARKER_ROOT = "root" + const val MARKER_MENTION = "mention" + + /** + * Builds an event template for a raid. Sender must be the host of the `from` stream. + * + * @param from source stream (the one currently live that is being ended/redirected) + * @param to target stream (where viewers should be redirected) + * @param message raid announcement text + */ + fun build( + from: EventHintBundle, + to: EventHintBundle, + message: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + addMarkedATag(from, MARKER_ROOT) + addMarkedATag(to, MARKER_MENTION) + initializer() + } + + private fun TagArrayBuilder.addMarkedATag( + bundle: EventHintBundle, + marker: String, + ) { + val relayUrl = bundle.relay?.url.orEmpty() + val addressId = + Address.assemble( + LiveActivitiesEvent.KIND, + bundle.event.pubKey, + bundle.event.dTag(), + ) + add(arrayOf(ATag.TAG_NAME, addressId, relayUrl, marker)) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 8c46307f2..c4d721624 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -168,6 +168,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -437,6 +438,7 @@ class EventFactory { LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)