feat(live-chat): surface stream clips in the profile gallery tab

Clips are authored by viewers but carry a `p` tag identifying the
stream host, so they naturally belong on the host's profile. Extend
the profile media subscription with `{kinds:[1313], #p:[pubkey]}`,
accept LiveActivitiesClipEvent in UserProfileGalleryFeedFilter when
the clip references a stream hosted by this user, and render the clip's
`r`-tag URL as a MediaUrlVideo tile in GalleryThumbnail.

Also plug LiveActivitiesClipEvent into NoteCompose via the existing
RenderChatClip so tapping a clip tile opens a working thread view
instead of an empty one.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
This commit is contained in:
Claude
2026-04-20 23:33:55 +00:00
parent 766274d0b2
commit e12b6b4172
4 changed files with 65 additions and 21 deletions
@@ -172,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -250,6 +251,7 @@ import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
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.streaming.LiveActivitiesEvent
@@ -938,6 +940,10 @@ private fun RenderNoteRow(
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
}
is LiveActivitiesClipEvent -> {
RenderChatClip(baseNote, accountViewModel, nav)
}
is FhirResourceEvent -> {
RenderFhirResource(baseNote, accountViewModel, nav)
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
@@ -51,16 +52,30 @@ fun filterUserProfileMedia(
?: user.allUsedRelaysOrNull()
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = UserProfileMediaKinds,
authors = listOf(user.pubkeyHex),
limit = 200,
since = since?.get(relay)?.time,
),
return relays.flatMap { relay ->
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = UserProfileMediaKinds,
authors = listOf(user.pubkeyHex),
limit = 200,
since = since?.get(relay)?.time,
),
),
// Clips are authored by viewers but carry a `p` tag identifying the stream host.
// Fetch clips OF this profile's streams so they surface in their gallery.
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(LiveActivitiesClipEvent.KIND),
tags = mapOf("p" to listOf(user.pubkeyHex)),
limit = 100,
since = since?.get(relay)?.time,
),
),
)
}
}
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
@@ -130,6 +131,21 @@ fun GalleryThumbnail(
thumbhash = imeta.thumbhash,
)
}
} else if (noteEvent is LiveActivitiesClipEvent) {
noteEvent.videoUrl()?.let { url ->
listOf(
MediaUrlVideo(
url = url,
description = noteEvent.title() ?: noteEvent.content,
hash = null,
blurhash = null,
dim = null,
uri = null,
mimeType = null,
thumbhash = null,
),
)
} ?: emptyList()
} else {
emptyList()
}
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent
@@ -75,17 +76,23 @@ class UserProfileGalleryFeedFilter(
user: User,
): Boolean {
val noteEvent = it.event
return (
(
it.event?.pubKey == user.pubkeyHex &&
(
noteEvent is PictureEvent ||
noteEvent is RegularVideoEvent ||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
)
) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) &&
val authoredByUser =
it.event?.pubKey == user.pubkeyHex &&
(
noteEvent is PictureEvent ||
noteEvent is RegularVideoEvent ||
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
)
// Clips are authored by viewers, not the host — accept them when they reference
// a stream hosted by this user AND carry a playable URL.
val clipOfUsersStream =
noteEvent is LiveActivitiesClipEvent &&
noteEvent.host() == user.pubkeyHex &&
!noteEvent.videoUrl().isNullOrBlank()
return (authoredByUser || clipOfUsersStream) &&
params.match(noteEvent, it.relays) &&
account.isAcceptable(it)
}