From ad423ead2913dc69fc82ffb54fd6c4c609b80563 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 18:05:02 +0000 Subject: [PATCH] fix(nests): keep presence out of channel chat + render kind:10312 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/ui/note/NoteCompose.kt | 5 ++ .../amethyst/ui/note/types/MeetingSpace.kt | 66 +++++++++++++++++++ .../publicChannels/dal/ChannelFeedFilter.kt | 18 ++++- amethyst/src/main/res/values/strings.xml | 5 ++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 9b48d1d766..b2cf50fbaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index cfc1b3ca50..3d2bd891a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 32ac5f3f4f..1115ce7caf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -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 = sort(channel.notes.filterIntoSet { key, it -> account.isAcceptable(it) }) + override fun feed(): List = sort(channel.notes.filterIntoSet { key, it -> isChatEvent(it) && account.isAcceptable(it) }) override fun applyFilter(newItems: Set): Set = 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): List = 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 } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c3324cac1d..e18e41a0c2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -530,6 +530,11 @@ Stop Join nest Host + raised their hand + stepped on stage + is speaking + joined as audience + left the nest Leave End this nest? 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.