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.livestreams.LiveStreamsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen
|
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.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.ImportFollowListPickFollowsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
|
||||||
@@ -247,6 +248,7 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
|
||||||
|
composableFromEndArgs<Route.NestLobby> { NestLobbyScreen(it.addressValue, accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
|
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Nests : Route()
|
@Serializable object Nests : Route()
|
||||||
|
|
||||||
|
@Serializable data class NestLobby(
|
||||||
|
val addressValue: String,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
@Serializable object Longs : Route()
|
@Serializable object Longs : Route()
|
||||||
|
|
||||||
@Serializable object Articles : Route()
|
@Serializable object Articles : Route()
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ fun RenderMeetingSpaceEventInner(
|
|||||||
} else {
|
} else {
|
||||||
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.JoinNestButton(
|
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.JoinNestButton(
|
||||||
event = noteEvent,
|
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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.components.SensitivityWarning
|
||||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.navigation.routes.routeFor
|
||||||
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
|
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
|
||||||
import com.vitorpamplona.amethyst.ui.note.Gallery
|
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.chats.publicChannels.nip53LiveActivities.ScheduledFlag
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.LoadParticipants
|
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.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.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||||
@@ -137,9 +135,10 @@ fun NestsFeedLoaded(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio-rooms list card. Mirrors [ObserveAndRenderSpace] visually but
|
* Audio-rooms list card. Mirrors [ObserveAndRenderSpace] visually but
|
||||||
* routes a tap straight into [NestActivity] when the underlying event
|
* routes a tap into the in-app [NestLobbyScreen] when the underlying
|
||||||
* is a [MeetingSpaceEvent], instead of the thread view that the generic
|
* event is a joinable [MeetingSpaceEvent]. The lobby is read-only —
|
||||||
* `ChannelCardCompose` → `ClickableNote` chain would otherwise open.
|
* launching the audio activity (and any host-side kind-30312 refresh)
|
||||||
|
* still requires an explicit tap on the lobby's Open Room button.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun NestFeedCard(
|
private fun NestFeedCard(
|
||||||
@@ -149,7 +148,6 @@ private fun NestFeedCard(
|
|||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val meetingEvent = baseNote.event as? MeetingSpaceEvent ?: return
|
val meetingEvent = baseNote.event as? MeetingSpaceEvent ?: return
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val onClick =
|
val onClick =
|
||||||
remember(meetingEvent) {
|
remember(meetingEvent) {
|
||||||
@@ -158,11 +156,7 @@ private fun NestFeedCard(
|
|||||||
val endpoint = meetingEvent.endpoint()
|
val endpoint = meetingEvent.endpoint()
|
||||||
val dTag = meetingEvent.address().dTag
|
val dTag = meetingEvent.address().dTag
|
||||||
if (!service.isNullOrBlank() && !endpoint.isNullOrBlank() && dTag.isNotBlank()) {
|
if (!service.isNullOrBlank() && !endpoint.isNullOrBlank() && dTag.isNotBlank()) {
|
||||||
NestBridge.set(accountViewModel)
|
nav.nav(Route.NestLobby(meetingEvent.address().toValue()))
|
||||||
NestActivity.launch(
|
|
||||||
context = context,
|
|
||||||
addressValue = meetingEvent.address().toValue(),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
nav.nav { routeFor(baseNote, accountViewModel.account) }
|
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.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.navigation.routes.routeFor
|
||||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||||
@@ -260,7 +261,7 @@ private fun NestJoinCardContent(
|
|||||||
) {
|
) {
|
||||||
JoinNestButton(
|
JoinNestButton(
|
||||||
event = event,
|
event = event,
|
||||||
accountViewModel = accountViewModel,
|
nav = nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -269,12 +270,15 @@ private fun NestJoinCardContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standalone "Join nest" button. Reusable from any composable that has
|
* "Join nest" entry button — navigates to [NestLobbyScreen] rather
|
||||||
* a [MeetingSpaceEvent] in hand — the lobby card, the in-feed note
|
* than launching the audio activity directly. The lobby is read-only
|
||||||
* renderer (so a `nostr:naddr1...` deep-link to a kind-30312 lands one
|
* (cached chat, host, status), so a user who's just coming back to
|
||||||
* tap away from the room), and any future room-list surface. Renders
|
* check on an old room doesn't trigger a MoQ handshake or the host's
|
||||||
* nothing for events without a service / endpoint / d-tag — those
|
* kind-30312 republish path. The actual room launch lives behind
|
||||||
* rooms can't be joined on the audio plane.
|
* [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
|
* [primaryColorOverride] lets the lobby card paint the button with
|
||||||
* the room's themed primary color (`["c", hex, "primary"]`); other
|
* the room's themed primary color (`["c", hex, "primary"]`); other
|
||||||
@@ -283,7 +287,7 @@ private fun NestJoinCardContent(
|
|||||||
@Composable
|
@Composable
|
||||||
fun JoinNestButton(
|
fun JoinNestButton(
|
||||||
event: MeetingSpaceEvent,
|
event: MeetingSpaceEvent,
|
||||||
accountViewModel: AccountViewModel,
|
nav: INav,
|
||||||
primaryColorOverride: Color? = null,
|
primaryColorOverride: Color? = null,
|
||||||
) {
|
) {
|
||||||
val serviceBase = event.service()
|
val serviceBase = event.service()
|
||||||
@@ -291,7 +295,6 @@ fun JoinNestButton(
|
|||||||
val roomId = event.address().dTag
|
val roomId = event.address().dTag
|
||||||
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
|
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val colors =
|
val colors =
|
||||||
if (primaryColorOverride != null) {
|
if (primaryColorOverride != null) {
|
||||||
androidx.compose.material3.ButtonDefaults
|
androidx.compose.material3.ButtonDefaults
|
||||||
@@ -301,6 +304,33 @@ fun JoinNestButton(
|
|||||||
.buttonColors()
|
.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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
NestBridge.set(accountViewModel)
|
NestBridge.set(accountViewModel)
|
||||||
@@ -309,8 +339,8 @@ fun JoinNestButton(
|
|||||||
addressValue = event.address().toValue(),
|
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_notification_stop">Stop</string>
|
||||||
<string name="nest_join">Join nest</string>
|
<string name="nest_join">Join nest</string>
|
||||||
<string name="nest_lobby_host_label">Host</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_host">Host</string>
|
||||||
<string name="nest_role_moderator">Moderator</string>
|
<string name="nest_role_moderator">Moderator</string>
|
||||||
<string name="nest_state_muted">Microphone muted</string>
|
<string name="nest_state_muted">Microphone muted</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user