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.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) &&
@@ -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
}
}
}
}
@@ -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(),
)
}
}