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.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
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.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer 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.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent 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.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@@ -938,6 +940,10 @@ private fun RenderNoteRow(
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
} }
is LiveActivitiesClipEvent -> {
RenderChatClip(baseNote, accountViewModel, nav)
}
is FhirResourceEvent -> { is FhirResourceEvent -> {
RenderFhirResource(baseNote, accountViewModel, nav) 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.experimental.profileGallery.ProfileGalleryEntryEvent
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.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
@@ -51,7 +52,8 @@ fun filterUserProfileMedia(
?: user.allUsedRelaysOrNull() ?: user.allUsedRelaysOrNull()
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map { relay -> return relays.flatMap { relay ->
listOf(
RelayBasedFilter( RelayBasedFilter(
relay = relay, relay = relay,
filter = filter =
@@ -61,6 +63,19 @@ fun filterUserProfileMedia(
limit = 200, limit = 200,
since = since?.get(relay)?.time, 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.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent
@@ -130,6 +131,21 @@ fun GalleryThumbnail(
thumbhash = imeta.thumbhash, 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 { } else {
emptyList() 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.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent
@@ -75,8 +76,7 @@ class UserProfileGalleryFeedFilter(
user: User, user: User,
): Boolean { ): Boolean {
val noteEvent = it.event val noteEvent = it.event
return ( val authoredByUser =
(
it.event?.pubKey == user.pubkeyHex && it.event?.pubKey == user.pubkeyHex &&
( (
noteEvent is PictureEvent || noteEvent is PictureEvent ||
@@ -84,8 +84,15 @@ class UserProfileGalleryFeedFilter(
(noteEvent is ReplaceableVideoEvent && it is AddressableNote) || (noteEvent is ReplaceableVideoEvent && it is AddressableNote) ||
(noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent())
) )
) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) && // 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) && params.match(noteEvent, it.relays) &&
account.isAcceptable(it) account.isAcceptable(it)
} }