diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt
index 630cb78aa..d4d41a78d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt
@@ -130,7 +130,7 @@ fun LiveActivityChannelView(
.weight(1f, true),
) {
ShowVideoStreaming(channel, accountViewModel)
- NestJoinCard(channel, accountViewModel)
+ NestJoinCard(channel, accountViewModel, nav)
LiveStreamTopZappers(channel, accountViewModel, nav)
LiveStreamGoalHeader(channel, accountViewModel, nav)
RefreshingChatroomFeedView(
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestJoinCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestJoinCard.kt
index e2610da9a..e5afd5b9f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestJoinCard.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/NestJoinCard.kt
@@ -23,8 +23,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -35,36 +39,55 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+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 coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
+import com.vitorpamplona.amethyst.commons.viewmodels.RoomTheme
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.ui.navigation.navs.INav
+import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
+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.stringRes
+import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
+import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
+import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
/**
* Lobby card rendered in [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ChannelView]
- * for NIP-53 kind 30312 meeting-space events. Shows the room title +
- * summary + a "Join audio room" button that launches [NestActivity].
+ * for NIP-53 kind 30312 meeting-space events. Shows the room cover,
+ * status, summary, host, speaker avatars, and a "Join nest" button.
*
- * The lobby is intentionally thin: no audio session, no presence event, no
- * hand-raise. All of that lives behind the Join button so the on-the-wire
- * traffic only fires when the user actually wants to be in the room.
+ * The lobby is intentionally thin: no audio session, no presence
+ * event, no hand-raise. All of that lives behind the Join button so
+ * the on-the-wire traffic only fires when the user actually wants to
+ * be in the room.
*
- * Hidden when the event has no `service` tag — those rooms are hosted on
- * non-nests servers we can't connect to.
+ * Hidden when the event has no `service` tag — those rooms are hosted
+ * on non-nests servers we can't connect to.
*/
@Composable
fun NestJoinCard(
baseChannel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
+ nav: INav,
) {
LoadAddressableNote(baseChannel.address, accountViewModel) { addressableNote ->
addressableNote ?: return@LoadAddressableNote
val event = addressableNote.event as? MeetingSpaceEvent ?: return@LoadAddressableNote
- NestJoinCardContent(event, accountViewModel)
+ NestJoinCardContent(event, accountViewModel, nav)
}
}
@@ -72,6 +95,7 @@ fun NestJoinCard(
private fun NestJoinCardContent(
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
+ nav: INav,
) {
val serviceBase = event.service()
val endpoint = event.endpoint()
@@ -81,51 +105,190 @@ private fun NestJoinCardContent(
// are missing — those rooms aren't joinable on the audio plane.
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
- val context = LocalContext.current
- val addressValue = remember(event) { event.address().toValue() }
+ val theme = remember(event) { RoomTheme.from(event) }
+ val containerColor =
+ theme.backgroundArgb?.let { Color(it) }
+ ?: MaterialTheme.colorScheme.surfaceVariant
+ val textColor =
+ theme.textArgb?.let { Color(it) }
+ ?: MaterialTheme.colorScheme.onSurfaceVariant
+ val mutedTextColor = textColor.copy(alpha = 0.7f)
+
+ val image = remember(event) { event.image() }
+ val roomName = remember(event) { event.room() }
+ val summary = remember(event) { event.summary() }
+ val status = remember(event) { event.status() }
+ val starts = remember(event) { event.starts() }
+ val participants = remember(event) { event.participants() }
+ // Host is whoever the kind:30312's `p`-tag explicitly marks `host`,
+ // OR — when the host is implicit (no `p`-tag at all for them) — the
+ // event author. Per EGG-07, the author is the host regardless of
+ // whether they self-tag.
val hostPubkey = event.pubKey
- val kind = event.kind
+ 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) }
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(12.dp),
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
) {
- Column(modifier = Modifier.padding(12.dp)) {
- event.room()?.let {
- Text(text = it, style = MaterialTheme.typography.titleMedium)
- }
- event.summary()?.let {
- Text(
- text = it,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ Column(modifier = Modifier.fillMaxWidth()) {
+ if (!image.isNullOrBlank()) {
+ AsyncImage(
+ model = image,
+ contentDescription = null,
+ modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
+ contentScale = ContentScale.Crop,
)
}
- Row(
- modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
- horizontalArrangement = Arrangement.End,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- JoinNestButton(event = event, accountViewModel = accountViewModel)
+
+ Column(modifier = Modifier.padding(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ roomName?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleMedium,
+ color = textColor,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ Spacer(StdHorzSpacer)
+ when (status) {
+ StatusTag.STATUS.OPEN -> {
+ MeetingSpaceOpenFlag()
+ }
+
+ StatusTag.STATUS.PRIVATE -> {
+ MeetingSpacePrivateFlag()
+ }
+
+ StatusTag.STATUS.CLOSED -> {
+ MeetingSpaceClosedFlag()
+ }
+
+ StatusTag.STATUS.PLANNED -> {
+ MeetingSpacePlannedFlag(starts)
+ }
+
+ null -> {}
+ }
+ }
+
+ summary?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = mutedTextColor,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+
+ Spacer(Modifier.height(12.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ ClickableUserPicture(
+ baseUser = hostUser,
+ size = 36.dp,
+ accountViewModel = accountViewModel,
+ onClick = { user -> nav.nav(routeFor(user)) },
+ )
+ Spacer(StdHorzSpacer)
+ UsernameDisplay(
+ baseUser = hostUser,
+ weight = Modifier.weight(1f),
+ textColor = textColor,
+ accountViewModel = accountViewModel,
+ )
+ Spacer(StdHorzSpacer)
+ Text(
+ text = stringRes(R.string.nest_lobby_host_label),
+ style = MaterialTheme.typography.labelSmall,
+ color = mutedTextColor,
+ )
+ }
+
+ if (speakers.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Cap visible speaker avatars so the row doesn't
+ // overflow on rooms with many speakers; the rest
+ // collapse into a "+N" label.
+ val cap = 5
+ val visible = remember(speakers, cap) { speakers.take(cap) }
+ visible.forEach { tag ->
+ ClickableUserPicture(
+ baseUserHex = tag.pubKey,
+ size = 24.dp,
+ accountViewModel = accountViewModel,
+ modifier = Modifier.padding(end = 4.dp),
+ 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.labelSmall,
+ color = textColor,
+ modifier = Modifier.padding(start = 4.dp),
+ )
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ JoinNestButton(
+ event = event,
+ accountViewModel = accountViewModel,
+ primaryColorOverride = theme.primaryArgb?.let { Color(it) },
+ )
+ }
}
}
}
}
/**
- * Standalone "Join audio room" 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.
+ * 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.
+ *
+ * [primaryColorOverride] lets the lobby card paint the button with
+ * the room's themed primary color (`["c", hex, "primary"]`); other
+ * call sites pass null and get the platform default.
*/
@Composable
fun JoinNestButton(
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
+ primaryColorOverride: Color? = null,
) {
val serviceBase = event.service()
val endpoint = event.endpoint()
@@ -137,18 +300,30 @@ fun JoinNestButton(
val hostPubkey = event.pubKey
val kind = event.kind
- Button(onClick = {
- NestBridge.set(accountViewModel)
- NestActivity.launch(
- context = context,
- addressValue = addressValue,
- authBaseUrl = serviceBase,
- endpoint = endpoint,
- hostPubkey = hostPubkey,
- roomId = roomId,
- kind = kind,
- )
- }) {
+ val colors =
+ if (primaryColorOverride != null) {
+ androidx.compose.material3.ButtonDefaults
+ .buttonColors(containerColor = primaryColorOverride)
+ } else {
+ androidx.compose.material3.ButtonDefaults
+ .buttonColors()
+ }
+
+ Button(
+ onClick = {
+ NestBridge.set(accountViewModel)
+ NestActivity.launch(
+ context = context,
+ addressValue = addressValue,
+ authBaseUrl = serviceBase,
+ endpoint = endpoint,
+ hostPubkey = hostPubkey,
+ roomId = roomId,
+ kind = kind,
+ )
+ },
+ colors = colors,
+ ) {
Text(stringRes(R.string.nest_join))
}
}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 5906489c6..c3324cac1 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -529,6 +529,7 @@
Tap to return.
Stop
Join nest
+ Host
Leave
End this nest?
You\'re the host. Closing the room will disconnect everyone. Choose \"Just leave\" if you want to come back later — the room will auto-close after 8 hours of inactivity.