feat(live-chat): display live stream clips (kind 1313) inline

Add a Quartz LiveActivitiesClipEvent for zap.stream's kind 1313 clip
convention, parsing the `a` tag (stream address), `p` tag (host), `r`
tag (video URL), and `title` tag. Register it in EventFactory, subscribe
in the live-activity chat filter, and route clips into the source
LiveActivitiesChannel cache. Render clips as an accent-colored card
showing "X created a clip" with the title and an inline video player
for the clip's playable URL.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
This commit is contained in:
Claude
2026-04-20 21:53:02 +00:00
parent 4786647762
commit 8368c9d661
7 changed files with 262 additions and 0 deletions
@@ -169,6 +169,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
@@ -1516,6 +1517,24 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
fun consume(
event: LiveActivitiesClipEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val activityAddress = event.activityAddress() ?: return false
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
val channel = getOrCreateLiveChannel(activityAddress)
val note = getOrCreateNote(event.id)
channel.addNote(note, relay)
}
return new
}
@Suppress("UNUSED_PARAMETER")
fun consume(
event: ChannelHideMessageEvent,
@@ -2689,6 +2708,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified)
is LiveActivitiesClipEvent -> consume(event, relay, wasVerified)
is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified)
@@ -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.RenderChatClip
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
@@ -86,6 +87,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.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
@@ -118,6 +120,8 @@ fun ChatroomMessageCompose(
RenderChatZap(baseNote, accountViewModel, nav)
} else if (event is LiveActivitiesRaidEvent) {
RenderChatRaid(baseNote, accountViewModel, nav)
} else if (event is LiveActivitiesClipEvent) {
RenderChatClip(baseNote, accountViewModel, nav)
} else {
NormalChatNote(
baseNote,
@@ -0,0 +1,119 @@
/*
* 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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.layout.ContentScale
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.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
@Composable
fun RenderChatClip(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val clip = baseNote.event as? LiveActivitiesClipEvent ?: return
val videoUrl = remember(clip) { clip.videoUrl() }
val title = remember(clip) { clip.title() }
val authorHex = remember(clip) { clip.pubKey }
val accent = MaterialTheme.colorScheme.primary
val containerColor = remember(accent) { accent.copy(alpha = 0.12f) }
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(RoundedCornerShape(8.dp))
.background(containerColor)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer)
LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
}
}
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.chat_clip_created_a_clip),
color = accent,
fontWeight = FontWeight.Bold,
)
}
if (!title.isNullOrBlank()) {
Text(text = title)
}
val caption = clip.content
if (caption.isNotBlank()) {
Text(text = caption)
}
if (!videoUrl.isNullOrBlank()) {
VideoView(
videoUri = videoUrl,
mimeType = null,
title = title,
roundedCorner = true,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
}
}
@@ -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.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -41,6 +42,7 @@ fun filterMessagesToLiveActivities(
listOf(
LiveActivitiesChatMessageEvent.KIND,
LiveActivitiesRaidEvent.KIND,
LiveActivitiesClipEvent.KIND,
LnZapEvent.KIND,
),
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
+1
View File
@@ -56,6 +56,7 @@
<string name="chat_zap_amount_suffix">zapped %1$s sats</string>
<string name="chat_zap_anonymous">Anonymous</string>
<string name="chat_raid_is_raiding">is raiding</string>
<string name="chat_clip_created_a_clip">created a clip</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_unfollow">You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow</string>
@@ -0,0 +1,114 @@
/*
* 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.clip
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.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-53 live stream clip (zap.stream convention, kind 1313).
*
* A clip is a standalone highlight produced from an ongoing or past live stream.
* The event carries:
* - `a` -> the source stream address (kind 30311)
* - `p` -> the stream host's pubkey
* - `r` -> direct playable video URL (MP4/HLS)
* - `title` -> clip title
* - `alt` -> NIP-31 fallback text
*
* `content` is an optional free-text caption.
*/
@Immutable
class LiveActivitiesClipEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
PubKeyHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints(): List<PubKeyHint> = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys(): List<HexKey> = tags.mapNotNull(PTag::parseKey)
fun activity(): ATag? =
tags
.asSequence()
.mapNotNull(ATag::parse)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
fun activityAddress(): Address? = activity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
fun host(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey)
fun videoUrl(): String? = tags.firstNotNullOfOrNull(ReferenceTag::parse)
fun title(): String? = tags.firstNotNullOfOrNull(TitleTag::parse)
companion object {
const val KIND = 1313
const val ALT = "Live activity clip"
/**
* Builds an event template for a clip. Typically published by the clip-authoring backend
* on behalf of a viewer, but can also be published directly by a client.
*/
fun build(
activity: EventHintBundle<LiveActivitiesEvent>,
videoUrl: String,
title: String,
host: HexKey = activity.event.pubKey,
caption: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesClipEvent>.() -> Unit = {},
) = eventTemplate(KIND, caption, createdAt) {
aTag(ATag(activity.event.kind, activity.event.pubKey, activity.event.dTag(), activity.relay))
add(PTag.assemble(host, null))
add(ReferenceTag.assemble(videoUrl))
add(TitleTag.assemble(title))
add(AltTag.assemble(ALT))
initializer()
}
}
}
@@ -165,6 +165,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
@@ -437,6 +438,7 @@ class EventFactory {
KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig)
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(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)