feat(live-chat): show NIP-75 zap goal at top of live stream chat

Add a goalEventId() accessor on LiveActivitiesEvent that reads the
zap.stream `["goal", "<hex>"]` tag. When a stream channel's 30311 event
references a goal, fetch the kind 9041 event and its zap receipts
(#e=<goalId>) so the existing goal-progress aggregation works.

Render a compact LiveStreamGoalHeader above the chat feed showing the
goal title, the existing GoalProgressBar, and a one-tap ZapReaction
that targets the goal event so viewers can contribute directly to the
fundraiser without leaving the stream.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
This commit is contained in:
Claude
2026-04-20 22:10:54 +00:00
parent 8368c9d661
commit 43435427b2
5 changed files with 222 additions and 4 deletions
@@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler(
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? = ): List<RelayBasedFilter>? =
when (val channel = key.channel) { when (val channel = key.channel) {
is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since) is EphemeralChatChannel -> {
is PublicChatChannel -> filterMessagesToPublicChat(channel, since) filterMessagesToEphemeralChat(channel, since)
is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since) }
else -> null
is PublicChatChannel -> {
filterMessagesToPublicChat(channel, since)
}
is LiveActivitiesChannel -> {
filterMessagesToLiveActivities(channel, since) +
filterGoalForLiveActivities(channel, since)
}
else -> {
null
}
} }
override fun id(key: ChannelQueryState) = key.channel override fun id(key: ChannelQueryState) = key.channel
@@ -0,0 +1,66 @@
/*
* 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.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
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.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
/**
* Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts
* that count toward it.
*
* zap.stream attaches a goal to a 30311 event via `["goal", "<hex event id>"]`.
* We fetch the 9041 goal event by id and the 9735 zap receipts that `#e`-tag it.
*/
fun filterGoalForLiveActivities(
channel: LiveActivitiesChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val goalId = channel.info?.goalEventId() ?: return emptyList()
return channel.relays().toSet().flatMap { relay ->
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GoalEvent.KIND),
ids = listOf(goalId),
limit = 1,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(LnZapEvent.KIND),
tags = mapOf("e" to listOf(goalId)),
limit = 200,
since = since?.get(relay)?.time,
),
),
)
}
}
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -127,6 +128,7 @@ fun LiveActivityChannelView(
.weight(1f, true), .weight(1f, true),
) { ) {
ShowVideoStreaming(channel, accountViewModel) ShowVideoStreaming(channel, accountViewModel)
LiveStreamGoalHeader(channel, accountViewModel, nav)
RefreshingChatroomFeedView( RefreshingChatroomFeedView(
feedContentState = feedViewModel.feedState, feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -0,0 +1,131 @@
/*
* 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.publicChannels.nip53LiveActivities.header
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.types.GoalProgressBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
/**
* Compact header rendered above the live-activity chat feed when the stream
* has a NIP-75 zap goal attached. Shows the goal title, a progress bar, and
* a one-tap zap button targeting the goal event.
*/
@Composable
fun LiveStreamGoalHeader(
channel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val goalId = channel.info?.goalEventId() ?: return
LoadNote(baseNoteHex = goalId, accountViewModel = accountViewModel) { goalNote ->
if (goalNote != null) {
GoalHeaderContent(goalNote, accountViewModel, nav)
}
}
}
@Composable
private fun GoalHeaderContent(
goalNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val goal = goalNote.event as? GoalEvent ?: return
val title =
remember(goal) {
goal.summary()?.ifBlank { null } ?: goal.content.take(120).ifBlank { null }
}
val goalAmountSats = remember(goal) { (goal.amount() ?: 0L) / 1000 }
if (goalAmountSats <= 0) return
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
Spacer(StdHorzSpacer)
ZapReaction(
baseNote = goalNote,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
iconSize = Size20dp,
iconSizeModifier = Size20Modifier,
showCounter = false,
nav = nav,
)
}
GoalProgressBar(
note = goalNote,
goalAmountSats = goalAmountSats,
accountViewModel = accountViewModel,
)
}
HorizontalDivider(thickness = 0.5.dp)
Spacer(Modifier.height(2.dp))
}
@@ -117,6 +117,12 @@ class LiveActivitiesEvent(
fun pinned() = tags.mapNotNull(PinnedEventTag::parse) fun pinned() = tags.mapNotNull(PinnedEventTag::parse)
/**
* zap.stream convention: a NIP-75 zap goal (kind 9041) is attached to a live stream
* via a flat tag `["goal", "<hex event id>"]` on the 30311 event.
*/
fun goalEventId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == GOAL_TAG && it[1].isNotEmpty() }?.get(1)
fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) { if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
StatusTag.STATUS.ENDED StatusTag.STATUS.ENDED
@@ -139,6 +145,7 @@ class LiveActivitiesEvent(
companion object { companion object {
const val KIND = 30311 const val KIND = 30311
const val ALT = "Live activity event" const val ALT = "Live activity event"
const val GOAL_TAG = "goal"
suspend fun create( suspend fun create(
signer: NostrSigner, signer: NostrSigner,