From 44533b218706d0003ebd8967c6db88cebc6f660e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 03:28:01 +0000 Subject: [PATCH] feat(audio-rooms): surface in home live-bubble row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a follow chats in a kind-30312 audio room, the room now appears in the live-bubble row at the top of home — same place streaming kind-30311 + ephemeral chats already show up. The plumbing already exists below the surface: LocalCache.consume(LiveActivitiesChatMessageEvent) calls getOrCreateLiveChannel(activityAddress) regardless of whether the address is a kind-30311 or kind-30312, so liveChatChannels already grows entries for audio rooms whenever a follow chats in one. The home filter just rejected them at the acceptableChatEvent guard because it required `info.status() == LIVE` — and `info` is hard-typed to LiveActivitiesEvent so it's null for audio rooms. Three changes: * HomeLiveFilter.acceptableChatEvent — branch on the chat's activityAddress.kind. For kind-30311 keep the existing info.status() == LIVE path; for kind-30312 read the addressable's MeetingSpaceEvent and accept OPEN/PRIVATE. "Closed" rooms drop out of the bubble even if a follow is chatting in the (now-archived) chat. * RenderLiveActivityBubble — when channel.address.kind == 30312, pull room title from the addressable so the bubble label reads "Lounge" instead of "naddr1abc…", and tap-launch AudioRoomActivity directly (one-tap into the room) instead of routing to ChannelView. Falls back to the channel route if the address is malformed. * LiveStatusIndicator.checkChannelIsOnline — the red live-dot surfaces for kind-30312 channels whenever their addressable is OPEN/PRIVATE, mirroring what status==LIVE means for streaming. Audio rooms with no chat yet still don't surface (would require populating channel.info, which would mean widening the channel model — deferred to a later cycle per the architectural discussion). The chat-driven case covers nostrnests' typical UX: a host opens a room, audience members start chatting, the bubble pulls in their followers. --- .../loggedIn/home/dal/HomeLiveFilter.kt | 39 ++++++++++++-- .../loggedIn/home/live/LiveStatusIndicator.kt | 32 ++++++++---- .../home/live/RenderLiveActivityBubble.kt | 52 ++++++++++++++++++- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 2938ad1a6..dd3bbe3e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -38,8 +38,11 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag as MeetingSpaceStatusTag class HomeLiveFilter( val account: Account, @@ -160,10 +163,40 @@ class HomeLiveFilter( val noteEvent = note.event if (noteEvent is LiveActivitiesChatMessageEvent) { - val stream = noteEvent.activityAddress() ?: return false - val streamChannel = LocalCache.getLiveActivityChannelIfExists(stream) ?: return false + val activity = noteEvent.activityAddress() ?: return false - if (streamChannel.info?.status() != StatusTag.STATUS.LIVE) return false + // Two flavors of "live" share the same kind-1311 chat + // channel: streaming (kind-30311, status=LIVE) and audio + // rooms (kind-30312, status=OPEN/PRIVATE). The chat + // surfaces in liveChatChannels for both because + // consume(LiveActivitiesChatMessageEvent) just keys on + // the a-tag — but the channel.info field only ever + // gets populated for streaming. Read the audio-room + // status straight off the addressable instead so the + // bubble surfaces while a follow is chatting in a + // currently-open kind-30312. + when (activity.kind) { + LiveActivitiesEvent.KIND -> { + val streamChannel = LocalCache.getLiveActivityChannelIfExists(activity) ?: return false + if (streamChannel.info?.status() != StatusTag.STATUS.LIVE) return false + } + + MeetingSpaceEvent.KIND -> { + val room = + LocalCache.getAddressableNoteIfExists(activity)?.event as? MeetingSpaceEvent + ?: return false + val status = room.status() + if (status != MeetingSpaceStatusTag.STATUS.OPEN && + status != MeetingSpaceStatusTag.STATUS.PRIVATE + ) { + return false + } + } + + else -> { + return false + } + } } return (noteEvent is EphemeralChatEvent || noteEvent is LiveActivitiesChatMessageEvent) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt index 923835b1f..0361bb99c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/LiveStatusIndicator.kt @@ -31,12 +31,15 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag as MeetingSpaceStatusTag @Composable fun LiveStatusIndicator( @@ -80,17 +83,28 @@ private suspend fun checkChannelIsOnline( try { when (channel) { is LiveActivitiesChannel -> { - // Check if streaming URL is online, fall back to relay check - val streamingUrl = channel.info?.streaming() - if (!streamingUrl.isNullOrBlank()) { - accountViewModel.checkVideoIsOnline(streamingUrl) + // Audio rooms (kind-30312) ride the same channel + // model but channel.info is null (typed to + // LiveActivitiesEvent). Their "is live" signal is + // the addressable kind-30312's status — surface + // the red dot whenever the room is OPEN/PRIVATE. + if (channel.address.kind == MeetingSpaceEvent.KIND) { + val room = LocalCache.getAddressableNoteIfExists(channel.address)?.event as? MeetingSpaceEvent + room?.status() == MeetingSpaceStatusTag.STATUS.OPEN || + room?.status() == MeetingSpaceStatusTag.STATUS.PRIVATE } else { - // Check relay connection - val relayUrl = channel.relayHintUrl() - if (relayUrl != null) { - OnlineChecker.isOnline(relayUrl.url, accountViewModel.httpClientBuilder::okHttpClientForVideo) + // Check if streaming URL is online, fall back to relay check + val streamingUrl = channel.info?.streaming() + if (!streamingUrl.isNullOrBlank()) { + accountViewModel.checkVideoIsOnline(streamingUrl) } else { - false + // Check relay connection + val relayUrl = channel.relayHintUrl() + if (relayUrl != null) { + OnlineChecker.isOnline(relayUrl.url, accountViewModel.httpClientBuilder::okHttpClientForVideo) + } else { + false + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt index 245ba9e28..0945e8d47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/live/RenderLiveActivityBubble.kt @@ -26,16 +26,22 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelNoteAuthors import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomActivity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomBridge import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @Composable fun RenderLiveActivityBubble( @@ -43,10 +49,49 @@ fun RenderLiveActivityBubble( accountViewModel: AccountViewModel, nav: INav, ) { + // Audio rooms (kind-30312) share the kind-1311 chat infra with + // streams (kind-30311), so they ride the same `LiveActivitiesChannel` + // pump. But channel.info is null for audio rooms (typed to + // LiveActivitiesEvent), so toBestDisplayName() would fall back + // to the truncated bech32. Read the kind-30312 addressable + // directly when the channel's address points to one and use the + // room name + a launch path that goes straight to AudioRoomActivity. + val meetingEvent = + remember(channel.address) { + if (channel.address.kind == MeetingSpaceEvent.KIND) { + LocalCache.getAddressableNoteIfExists(channel.address)?.event as? MeetingSpaceEvent + } else { + null + } + } + val context = LocalContext.current FilledTonalButton( contentPadding = PaddingValues(start = 8.dp, end = 10.dp, bottom = 0.dp, top = 0.dp), onClick = { - nav.nav { routeFor(channel) } + if (meetingEvent != null) { + val service = meetingEvent.service() + val endpoint = meetingEvent.endpoint() + val dTag = meetingEvent.address().dTag + if (!service.isNullOrBlank() && !endpoint.isNullOrBlank() && dTag.isNotBlank()) { + AudioRoomBridge.set(accountViewModel) + AudioRoomActivity.launch( + context = context, + addressValue = meetingEvent.address().toValue(), + authBaseUrl = service, + endpoint = endpoint, + hostPubkey = meetingEvent.pubKey, + roomId = dTag, + kind = meetingEvent.kind, + ) + } else { + // Fall back to the channel route so the user + // still lands somewhere — same as a malformed + // streaming kind-30311. + nav.nav { routeFor(channel) } + } + } else { + nav.nav { routeFor(channel) } + } }, ) { LiveStatusIndicatorForChannel( @@ -58,7 +103,10 @@ fun RenderLiveActivityBubble( RenderUsers(channel, accountViewModel, nav) Spacer(StdHorzSpacer) Text( - channel.toBestDisplayName(), + // Audio rooms have a real `room()` name on the addressable; + // pick that up so the bubble reads "Lounge" instead of + // "naddr1abc…". + meetingEvent?.room() ?: channel.toBestDisplayName(), ) } }