Merge pull request #2666 from vitorpamplona/claude/fix-nest-room-status-3Mmq9
Add NestLobbyScreen for read-only room preview before joining
This commit is contained in:
@@ -128,6 +128,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.LiveStreamsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
|
||||
@@ -247,6 +248,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
|
||||
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -91,6 +91,10 @@ sealed class Route {
|
||||
|
||||
@Serializable object Nests : Route()
|
||||
|
||||
@Serializable data class NestLobby(
|
||||
val addressValue: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable object Longs : Route()
|
||||
|
||||
@Serializable object Articles : Route()
|
||||
|
||||
@@ -177,7 +177,7 @@ fun RenderMeetingSpaceEventInner(
|
||||
} else {
|
||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.JoinNestButton(
|
||||
event = noteEvent,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-12
@@ -49,7 +49,6 @@ import androidx.compose.ui.Alignment.Companion.TopEnd
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -63,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
|
||||
import com.vitorpamplona.amethyst.ui.note.Gallery
|
||||
@@ -77,8 +77,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53L
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.LoadParticipants
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
@@ -137,9 +135,10 @@ fun NestsFeedLoaded(
|
||||
|
||||
/**
|
||||
* Audio-rooms list card. Mirrors [ObserveAndRenderSpace] visually but
|
||||
* routes a tap straight into [NestActivity] when the underlying event
|
||||
* is a [MeetingSpaceEvent], instead of the thread view that the generic
|
||||
* `ChannelCardCompose` → `ClickableNote` chain would otherwise open.
|
||||
* routes a tap into the in-app [NestLobbyScreen] when the underlying
|
||||
* event is a joinable [MeetingSpaceEvent]. The lobby is read-only —
|
||||
* launching the audio activity (and any host-side kind-30312 refresh)
|
||||
* still requires an explicit tap on the lobby's Open Room button.
|
||||
*/
|
||||
@Composable
|
||||
private fun NestFeedCard(
|
||||
@@ -149,7 +148,6 @@ private fun NestFeedCard(
|
||||
nav: INav,
|
||||
) {
|
||||
val meetingEvent = baseNote.event as? MeetingSpaceEvent ?: return
|
||||
val context = LocalContext.current
|
||||
|
||||
val onClick =
|
||||
remember(meetingEvent) {
|
||||
@@ -158,11 +156,7 @@ private fun NestFeedCard(
|
||||
val endpoint = meetingEvent.endpoint()
|
||||
val dTag = meetingEvent.address().dTag
|
||||
if (!service.isNullOrBlank() && !endpoint.isNullOrBlank() && dTag.isNotBlank()) {
|
||||
NestBridge.set(accountViewModel)
|
||||
NestActivity.launch(
|
||||
context = context,
|
||||
addressValue = meetingEvent.address().toValue(),
|
||||
)
|
||||
nav.nav(Route.NestLobby(meetingEvent.address().toValue()))
|
||||
} else {
|
||||
nav.nav { routeFor(baseNote, accountViewModel.account) }
|
||||
}
|
||||
|
||||
+41
-11
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
@@ -260,7 +261,7 @@ private fun NestJoinCardContent(
|
||||
) {
|
||||
JoinNestButton(
|
||||
event = event,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -269,12 +270,15 @@ private fun NestJoinCardContent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone "Join nest" button. Reusable from any composable that has
|
||||
* a [MeetingSpaceEvent] in hand — the lobby card, the in-feed note
|
||||
* renderer (so a `nostr:naddr1...` deep-link to a kind-30312 lands one
|
||||
* tap away from the room), and any future room-list surface. Renders
|
||||
* nothing for events without a service / endpoint / d-tag — those
|
||||
* rooms can't be joined on the audio plane.
|
||||
* "Join nest" entry button — navigates to [NestLobbyScreen] rather
|
||||
* than launching the audio activity directly. The lobby is read-only
|
||||
* (cached chat, host, status), so a user who's just coming back to
|
||||
* check on an old room doesn't trigger a MoQ handshake or the host's
|
||||
* kind-30312 republish path. The actual room launch lives behind
|
||||
* [OpenNestRoomButton] inside the lobby.
|
||||
*
|
||||
* Renders nothing for events without a service / endpoint / d-tag —
|
||||
* those rooms can't be joined on the audio plane.
|
||||
*
|
||||
* [primaryColorOverride] lets the lobby card paint the button with
|
||||
* the room's themed primary color (`["c", hex, "primary"]`); other
|
||||
@@ -283,7 +287,7 @@ private fun NestJoinCardContent(
|
||||
@Composable
|
||||
fun JoinNestButton(
|
||||
event: MeetingSpaceEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
primaryColorOverride: Color? = null,
|
||||
) {
|
||||
val serviceBase = event.service()
|
||||
@@ -291,7 +295,6 @@ fun JoinNestButton(
|
||||
val roomId = event.address().dTag
|
||||
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val colors =
|
||||
if (primaryColorOverride != null) {
|
||||
androidx.compose.material3.ButtonDefaults
|
||||
@@ -301,6 +304,33 @@ fun JoinNestButton(
|
||||
.buttonColors()
|
||||
}
|
||||
|
||||
val addressValue = event.address().toValue()
|
||||
Button(
|
||||
onClick = { nav.nav(Route.NestLobby(addressValue)) },
|
||||
colors = colors,
|
||||
) {
|
||||
Text(stringRes(R.string.nest_join))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lobby's primary CTA. This is the only place that actually launches
|
||||
* [NestActivity] — meaning a host re-entering an old room only
|
||||
* triggers the audio session (and any rejoin-time event refresh)
|
||||
* when they explicitly opt in here.
|
||||
*/
|
||||
@Composable
|
||||
fun OpenNestRoomButton(
|
||||
event: MeetingSpaceEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val serviceBase = event.service()
|
||||
val endpoint = event.endpoint()
|
||||
val roomId = event.address().dTag
|
||||
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
|
||||
|
||||
val context = LocalContext.current
|
||||
Button(
|
||||
onClick = {
|
||||
NestBridge.set(accountViewModel)
|
||||
@@ -309,8 +339,8 @@ fun JoinNestButton(
|
||||
addressValue = event.address().toValue(),
|
||||
)
|
||||
},
|
||||
colors = colors,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(stringRes(R.string.nest_join))
|
||||
Text(stringRes(R.string.nest_lobby_open_room))
|
||||
}
|
||||
}
|
||||
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.MeetingSpaceClosedFlag
|
||||
import com.vitorpamplona.amethyst.ui.note.types.MeetingSpaceOpenFlag
|
||||
import com.vitorpamplona.amethyst.ui.note.types.MeetingSpacePlannedFlag
|
||||
import com.vitorpamplona.amethyst.ui.note.types.MeetingSpacePrivateFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* In-app navigation lobby for a NIP-53 audio room (kind-30312). Sits
|
||||
* between the feed / share-link entry points and [com.vitorpamplona.amethyst
|
||||
* .ui.screen.loggedIn.nests.room.activity.NestActivity], so a user who's
|
||||
* just coming back to read the chat or check who's hosting doesn't
|
||||
* trigger a MoQ handshake or a host-side kind-30312 republish. Only the
|
||||
* "Open room" button on this screen launches the audio activity.
|
||||
*
|
||||
* The lobby is read-only: it mounts the room's relay subscription
|
||||
* ([NestRoomFilterAssemblerSubscription]) so cached chat / presence
|
||||
* stay fresh, but it does NOT open a [com.vitorpamplona.amethyst.commons
|
||||
* .viewmodels.NestViewModel], does NOT publish kind-10312 presence,
|
||||
* and does NOT touch the audio pipeline.
|
||||
*/
|
||||
@Composable
|
||||
fun NestLobbyScreen(
|
||||
addressValue: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val address = remember(addressValue) { Address.parse(addressValue) } ?: return
|
||||
LoadAddressableNote(address, accountViewModel) { addressableNote ->
|
||||
addressableNote ?: return@LoadAddressableNote
|
||||
val event = addressableNote.event as? MeetingSpaceEvent ?: return@LoadAddressableNote
|
||||
NestLobbyContent(event, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NestLobbyContent(
|
||||
event: MeetingSpaceEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val roomATag = remember(event) { event.address().toValue() }
|
||||
|
||||
// Keep cached chat / presence warm while the user is on the lobby.
|
||||
// No MoQ session is opened — that's gated behind the Open Room button.
|
||||
NestRoomFilterAssemblerSubscription(roomATag, accountViewModel)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(
|
||||
caption = event.room().orEmpty(),
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
OpenNestRoomButton(
|
||||
event = event,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
state = rememberLazyListState(),
|
||||
) {
|
||||
item("header") {
|
||||
RoomHeader(event, accountViewModel, nav)
|
||||
}
|
||||
item("listeners") {
|
||||
CachedListenerCount(roomATag)
|
||||
}
|
||||
item("chat-header") {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_lobby_recent_chat),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
cachedChatItems(roomATag, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomHeader(
|
||||
event: MeetingSpaceEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val image = remember(event) { event.image() }
|
||||
val summary = remember(event) { event.summary() }
|
||||
val status = remember(event) { event.status() }
|
||||
val starts = remember(event) { event.starts() }
|
||||
val hostPubkey = event.pubKey
|
||||
val participants = remember(event) { event.participants() }
|
||||
val speakers =
|
||||
remember(participants, hostPubkey) {
|
||||
participants.filter { p ->
|
||||
p.pubKey != hostPubkey &&
|
||||
(p.role.equals(ROLE.SPEAKER.code, true) || p.role.equals(ROLE.MODERATOR.code, true))
|
||||
}
|
||||
}
|
||||
val hostUser = remember(hostPubkey) { LocalCache.getOrCreateUser(hostPubkey) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
if (!image.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = image,
|
||||
contentDescription = null,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (status) {
|
||||
StatusTag.STATUS.LIVE -> {
|
||||
MeetingSpaceOpenFlag()
|
||||
}
|
||||
|
||||
StatusTag.STATUS.PRIVATE -> {
|
||||
MeetingSpacePrivateFlag()
|
||||
}
|
||||
|
||||
StatusTag.STATUS.ENDED -> {
|
||||
MeetingSpaceClosedFlag()
|
||||
}
|
||||
|
||||
StatusTag.STATUS.PLANNED -> {
|
||||
MeetingSpacePlannedFlag(starts)
|
||||
}
|
||||
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!summary.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ClickableUserPicture(
|
||||
baseUser = hostUser,
|
||||
size = 40.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { user -> nav.nav(routeFor(user)) },
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
UsernameDisplay(
|
||||
baseUser = hostUser,
|
||||
weight = Modifier.weight(1f),
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.nest_lobby_host_label),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (speakers.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val cap = 8
|
||||
val visible = remember(speakers) { speakers.take(cap) }
|
||||
visible.forEach { tag ->
|
||||
ClickableUserPicture(
|
||||
baseUserHex = tag.pubKey,
|
||||
size = 28.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { hex ->
|
||||
nav.nav(
|
||||
com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
.Profile(hex),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (speakers.size > visible.size) {
|
||||
Text(
|
||||
text = "+${speakers.size - visible.size}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Live listener count from cached kind-10312 presence events. Mirrors
|
||||
* the staleness window used inside the room (PRESENCE_STALE_THRESHOLD_SEC
|
||||
* in NestRoomEventCollectors): a peer that's missed one heartbeat plus a
|
||||
* 5-min "still here" window stops counting.
|
||||
*/
|
||||
@Composable
|
||||
private fun CachedListenerCount(roomATag: String) {
|
||||
var count by remember(roomATag) { mutableStateOf(0) }
|
||||
LaunchedEffect(roomATag) {
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(MeetingRoomPresenceEvent.KIND),
|
||||
tags = mapOf("a" to listOf(roomATag)),
|
||||
)
|
||||
LocalCache.observeEvents<MeetingRoomPresenceEvent>(filter).collect { events ->
|
||||
// Latest event per pubkey within the staleness window.
|
||||
val cutoff = TimeUtils.now() - LISTENER_FRESHNESS_SEC
|
||||
count =
|
||||
events
|
||||
.groupBy { it.pubKey }
|
||||
.values
|
||||
.count { perAuthor -> perAuthor.maxOf { it.createdAt } >= cutoff }
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
if (count == 0) {
|
||||
stringRes(R.string.nest_lobby_no_listeners)
|
||||
} else {
|
||||
stringRes(R.string.nest_lobby_listeners_count, count)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun LazyListScope.cachedChatItems(
|
||||
roomATag: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
item("chat-list") {
|
||||
CachedChatList(roomATag, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CachedChatList(
|
||||
roomATag: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val messages by produceState(initialValue = emptyList<Note>(), roomATag) {
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
|
||||
tags = mapOf("a" to listOf(roomATag)),
|
||||
)
|
||||
LocalCache.observeNotes(filter).collect { notes ->
|
||||
value = notes.sortedByDescending { it.createdAt() ?: 0L }.take(LOBBY_CHAT_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
val routeForLastRead = remember(roomATag) { "NestLobbyChat/$roomATag" }
|
||||
|
||||
if (messages.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.nest_chat_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
messages.forEach { note ->
|
||||
ChatroomMessageCompose(
|
||||
baseNote = note,
|
||||
routeForLastRead = routeForLastRead,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToReply = {},
|
||||
onWantsToEditDraft = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val LOBBY_CHAT_LIMIT = 50
|
||||
|
||||
// 6 minutes — matches PRESENCE_STALE_THRESHOLD_SEC in NestRoomEventCollectors.
|
||||
private const val LISTENER_FRESHNESS_SEC: Long = 6L * 60L
|
||||
@@ -533,6 +533,10 @@
|
||||
<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_lobby_open_room">Open room</string>
|
||||
<string name="nest_lobby_recent_chat">Recent chat</string>
|
||||
<string name="nest_lobby_no_listeners">No one is in the room right now.</string>
|
||||
<string name="nest_lobby_listeners_count">%1$d listening</string>
|
||||
<string name="nest_role_host">Host</string>
|
||||
<string name="nest_role_moderator">Moderator</string>
|
||||
<string name="nest_state_muted">Microphone muted</string>
|
||||
|
||||
Reference in New Issue
Block a user