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)