diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 4d6aaa501..7872e6cbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, nav) } + composableFromEndArgs { NestLobbyScreen(it.addressValue, accountViewModel, nav) } composableFromEnd { LongsScreen(accountViewModel, nav) } composableFromEnd { ArticlesScreen(accountViewModel, nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 62649f251..d414e8ad5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt index 9b7f181e9..24a5afa9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MeetingSpace.kt @@ -177,7 +177,7 @@ fun RenderMeetingSpaceEventInner( } else { com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.JoinNestButton( event = noteEvent, - accountViewModel = accountViewModel, + nav = nav, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt index 0bf6c241d..5107568f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestJoinCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestJoinCard.kt index b9ed966c2..8a6bf0bad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestJoinCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestJoinCard.kt @@ -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)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt new file mode 100644 index 000000000..b24902caa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lobby/NestLobbyScreen.kt @@ -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(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(), 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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 999c75b4f..53213060a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -533,6 +533,10 @@ Stop Join nest Host + Open room + Recent chat + No one is in the room right now. + %1$d listening Host Moderator Microphone muted