fix(nests): keep presence out of channel chat + render kind:10312

Two-part fix for kind-10312 [MeetingRoomPresenceEvent] showing up as
empty cards in the channel chat panel.

(1) Filter at the chat-feed level. ChannelFeedFilter now rejects
MeetingRoomPresenceEvent — these only land in `channel.notes` so the
home live-bubble (HomeLiveFilter) can detect a follow broadcasting
in a Nest by walking channel.notes. They aren't chat content; the
chat panel rendering them as empty rows was a leak from that
home-bubble plumbing.

The filter is in ChannelFeedFilter rather than upstream in
LocalCache.consume because removing presence from channel.notes
would silently break the home-bubble's broadcasting-now detection.
This way the channel keeps the events for non-chat consumers and
the chat just refuses to surface them.

(2) Renderer fallback. NoteCompose now dispatches kind-10312 to
RenderMeetingRoomPresence — a one-line italic status row ("@user ·
raised their hand" / "stepped on stage" / "is speaking" / "joined
as audience" / "left the nest"). After (1) this only shows up on
non-chat surfaces (search results, thread view of a quoted
presence, profile timeline, …), but renders informatively instead
of as the empty card it was before.

Five new strings under nest_presence_*.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
This commit is contained in:
Claude
2026-04-27 18:05:02 +00:00
parent aadf43d5ae
commit ad423ead29
4 changed files with 92 additions and 2 deletions
@@ -1039,6 +1039,11 @@ private fun RenderNoteRow(
RenderMeetingRoomEvent(baseNote, accountViewModel, nav)
}
is com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent -> {
com.vitorpamplona.amethyst.ui.note.types
.RenderMeetingRoomPresence(baseNote, accountViewModel, nav)
}
is GitRepositoryEvent -> {
RenderGitRepositoryEvent(baseNote, accountViewModel, nav)
}
@@ -293,6 +293,72 @@ fun RenderMeetingRoomEventInner(
RenderParticipants(participants, accountViewModel, nav)
}
/**
* Inline renderer for kind-10312 [MeetingRoomPresenceEvent] events.
* The event's `content` is empty; the interesting bits are the
* presence flags. Render a one-line italic status "@user · raised
* their hand" — so a presence event that leaks into a feed surface
* outside the room (search results, thread view of a quoted
* presence, ) renders as a status note instead of an empty card.
*
* The active room's chat panel does NOT render presence events at
* all `ChannelFeedFilter` filters them out so the chat stays
* just chat. This composable is the fallback for everything else.
*/
@Composable
fun RenderMeetingRoomPresence(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = baseNote.event as? com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent ?: return
val handRaised = remember(event) { event.handRaised() == true }
val publishing = remember(event) { event.publishing() == true }
val onstage = remember(event) { event.onstage() == true }
val stateRes =
when {
!publishing && !onstage && !handRaised -> R.string.nest_presence_left
handRaised -> R.string.nest_presence_raised_hand
onstage && publishing -> R.string.nest_presence_speaking
onstage -> R.string.nest_presence_on_stage
else -> R.string.nest_presence_listening
}
val user =
remember(event.pubKey) {
com.vitorpamplona.amethyst.model.LocalCache
.getOrCreateUser(event.pubKey)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { nav.nav(routeFor(user)) },
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(user, 20.dp, accountViewModel)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
UsernameDisplay(
baseUser = user,
fontWeight = androidx.compose.ui.text.font.FontWeight.Normal,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(
text = stringRes(stateRes),
style =
MaterialTheme.typography.bodySmall.copy(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
),
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
@Composable
private fun RenderParticipants(
participants: List<ParticipantTag>,
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
class ChannelFeedFilter(
val channel: Channel,
@@ -37,12 +38,25 @@ class ChannelFeedFilter(
override fun changesFlow() = channel.changesFlow()
// returns the last Note of each user.
override fun feed(): List<Note> = sort(channel.notes.filterIntoSet { key, it -> account.isAcceptable(it) })
override fun feed(): List<Note> = sort(channel.notes.filterIntoSet { key, it -> isChatEvent(it) && account.isAcceptable(it) })
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems
.filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) }
.filter { channel.notes.containsKey(it.idHex) && isChatEvent(it) && account.isAcceptable(it) }
.toSet()
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
/**
* Reject non-chat events that get attached to a [Channel] for other
* surfaces. The current case: kind-10312
* [MeetingRoomPresenceEvent]s land in `channel.notes` so the home
* live-bubble can detect a follow broadcasting in a Nest
* (HomeLiveFilter scans channel.notes); they have no chat content
* and would otherwise render as an empty card in the chat panel.
*
* Anything else is passed through chat messages, zaps, raids,
* clips, channel-create / metadata events all belong here.
*/
private fun isChatEvent(note: Note): Boolean = note.event !is MeetingRoomPresenceEvent
}
+5
View File
@@ -530,6 +530,11 @@
<string name="nest_notification_stop">Stop</string>
<string name="nest_join">Join nest</string>
<string name="nest_lobby_host_label">Host</string>
<string name="nest_presence_raised_hand">raised their hand</string>
<string name="nest_presence_on_stage">stepped on stage</string>
<string name="nest_presence_speaking">is speaking</string>
<string name="nest_presence_listening">joined as audience</string>
<string name="nest_presence_left">left the nest</string>
<string name="nest_leave">Leave</string>
<string name="nest_leave_host_title">End this nest?</string>
<string name="nest_leave_host_body">You\'re the host. Closing the room will disconnect everyone. Choose \"Just leave\" if you want to come back later — the room will auto-close after 8 hours of inactivity.</string>