feat(audio-rooms): surface in home live-bubble row

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.
This commit is contained in:
Claude
2026-04-27 03:28:01 +00:00
parent e3bf46377b
commit 44533b2187
3 changed files with 109 additions and 14 deletions
@@ -38,8 +38,11 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent 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.nip53LiveActivities.streaming.tags.StatusTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag as MeetingSpaceStatusTag
class HomeLiveFilter( class HomeLiveFilter(
val account: Account, val account: Account,
@@ -160,10 +163,40 @@ class HomeLiveFilter(
val noteEvent = note.event val noteEvent = note.event
if (noteEvent is LiveActivitiesChatMessageEvent) { if (noteEvent is LiveActivitiesChatMessageEvent) {
val stream = noteEvent.activityAddress() ?: return false val activity = noteEvent.activityAddress() ?: return false
val streamChannel = LocalCache.getLiveActivityChannelIfExists(stream) ?: 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) && return (noteEvent is EphemeralChatEvent || noteEvent is LiveActivitiesChatMessageEvent) &&
@@ -31,12 +31,15 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag as MeetingSpaceStatusTag
@Composable @Composable
fun LiveStatusIndicator( fun LiveStatusIndicator(
@@ -80,17 +83,28 @@ private suspend fun checkChannelIsOnline(
try { try {
when (channel) { when (channel) {
is LiveActivitiesChannel -> { is LiveActivitiesChannel -> {
// Check if streaming URL is online, fall back to relay check // Audio rooms (kind-30312) ride the same channel
val streamingUrl = channel.info?.streaming() // model but channel.info is null (typed to
if (!streamingUrl.isNullOrBlank()) { // LiveActivitiesEvent). Their "is live" signal is
accountViewModel.checkVideoIsOnline(streamingUrl) // 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 { } else {
// Check relay connection // Check if streaming URL is online, fall back to relay check
val relayUrl = channel.relayHintUrl() val streamingUrl = channel.info?.streaming()
if (relayUrl != null) { if (!streamingUrl.isNullOrBlank()) {
OnlineChecker.isOnline(relayUrl.url, accountViewModel.httpClientBuilder::okHttpClientForVideo) accountViewModel.checkVideoIsOnline(streamingUrl)
} else { } else {
false // Check relay connection
val relayUrl = channel.relayHintUrl()
if (relayUrl != null) {
OnlineChecker.isOnline(relayUrl.url, accountViewModel.httpClientBuilder::okHttpClientForVideo)
} else {
false
}
} }
} }
} }
@@ -26,16 +26,22 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel 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.service.relayClient.reqCommand.channel.observeChannelNoteAuthors
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.Gallery import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
@Composable @Composable
fun RenderLiveActivityBubble( fun RenderLiveActivityBubble(
@@ -43,10 +49,49 @@ fun RenderLiveActivityBubble(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, 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( FilledTonalButton(
contentPadding = PaddingValues(start = 8.dp, end = 10.dp, bottom = 0.dp, top = 0.dp), contentPadding = PaddingValues(start = 8.dp, end = 10.dp, bottom = 0.dp, top = 0.dp),
onClick = { 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( LiveStatusIndicatorForChannel(
@@ -58,7 +103,10 @@ fun RenderLiveActivityBubble(
RenderUsers(channel, accountViewModel, nav) RenderUsers(channel, accountViewModel, nav)
Spacer(StdHorzSpacer) Spacer(StdHorzSpacer)
Text( 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(),
) )
} }
} }