feat(nests): richer NestJoinCard — cover, theme, host, speakers

Replaces the title-and-summary stub with a proper lobby card.

Render order top-to-bottom:
  1. Cover image (16:9, ContentScale.Crop) when the kind:30312 ships
     an `image` tag. Skipped silently when absent.
  2. Title + status flag row — reuses the existing
     MeetingSpaceOpenFlag / MeetingSpacePrivateFlag /
     MeetingSpaceClosedFlag / MeetingSpacePlannedFlag composables, so
     LIVE/PRIVATE/CLOSED/PLANNED badges visually match the in-feed
     RenderMeetingSpaceEvent surface.
  3. Summary, capped at 3 lines with ellipsis.
  4. Host row: 36dp avatar + UsernameDisplay + "Host" label. Avatar is
     tappable → profile route via the `nav` parameter.
  5. Speaker row: up to 5 24dp avatars from the kind:30312 `p`-tags
     (role=speaker | admin), with a "+N" overflow label. Each avatar
     navigates to its profile.
  6. "Join nest" button bottom-right.

Theming applies the room's color tags (EGG-10):
  - `["c", hex, "background"]` → Card containerColor
  - `["c", hex, "text"]` → all body text colors
  - `["c", hex, "primary"]` → JoinNestButton containerColor (passed
    via new optional `primaryColorOverride` parameter so the in-feed
    JoinNestButton call from RenderMeetingSpaceEvent stays unchanged)

Background image (`bg` tag) is intentionally NOT applied at the lobby
card level — it's reserved for the in-room screen via NestThemedScope
(which does need the full-bleed shader-based tile mode). Painting it
on a card would compete with the cover.

API change: NestJoinCard now takes `nav: INav`. The single call site
in ChannelView already has nav in scope.

New string: nest_lobby_host_label = "Host".

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
This commit is contained in:
Claude
2026-04-27 17:32:51 +00:00
parent 76e9a146f9
commit 76d7753a34
3 changed files with 223 additions and 47 deletions
@@ -130,7 +130,7 @@ fun LiveActivityChannelView(
.weight(1f, true), .weight(1f, true),
) { ) {
ShowVideoStreaming(channel, accountViewModel) ShowVideoStreaming(channel, accountViewModel)
NestJoinCard(channel, accountViewModel) NestJoinCard(channel, accountViewModel, nav)
LiveStreamTopZappers(channel, accountViewModel, nav) LiveStreamTopZappers(channel, accountViewModel, nav)
LiveStreamGoalHeader(channel, accountViewModel, nav) LiveStreamGoalHeader(channel, accountViewModel, nav)
RefreshingChatroomFeedView( RefreshingChatroomFeedView(
@@ -23,8 +23,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -35,36 +39,55 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R 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.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.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.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes 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.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] * 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 + * for NIP-53 kind 30312 meeting-space events. Shows the room cover,
* summary + a "Join audio room" button that launches [NestActivity]. * status, summary, host, speaker avatars, and a "Join nest" button.
* *
* The lobby is intentionally thin: no audio session, no presence event, no * The lobby is intentionally thin: no audio session, no presence
* hand-raise. All of that lives behind the Join button so the on-the-wire * event, no hand-raise. All of that lives behind the Join button so
* traffic only fires when the user actually wants to be in the room. * 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 * Hidden when the event has no `service` tag — those rooms are hosted
* non-nests servers we can't connect to. * on non-nests servers we can't connect to.
*/ */
@Composable @Composable
fun NestJoinCard( fun NestJoinCard(
baseChannel: LiveActivitiesChannel, baseChannel: LiveActivitiesChannel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav,
) { ) {
LoadAddressableNote(baseChannel.address, accountViewModel) { addressableNote -> LoadAddressableNote(baseChannel.address, accountViewModel) { addressableNote ->
addressableNote ?: return@LoadAddressableNote addressableNote ?: return@LoadAddressableNote
val event = addressableNote.event as? MeetingSpaceEvent ?: 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( private fun NestJoinCardContent(
event: MeetingSpaceEvent, event: MeetingSpaceEvent,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav,
) { ) {
val serviceBase = event.service() val serviceBase = event.service()
val endpoint = event.endpoint() val endpoint = event.endpoint()
@@ -81,51 +105,190 @@ private fun NestJoinCardContent(
// are missing — those rooms aren't joinable on the audio plane. // are missing — those rooms aren't joinable on the audio plane.
if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return if (serviceBase.isNullOrBlank() || endpoint.isNullOrBlank() || roomId.isBlank()) return
val context = LocalContext.current val theme = remember(event) { RoomTheme.from(event) }
val addressValue = remember(event) { event.address().toValue() } 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 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( Card(
modifier = Modifier.fillMaxWidth().padding(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), colors = CardDefaults.cardColors(containerColor = containerColor),
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.fillMaxWidth()) {
event.room()?.let { if (!image.isNullOrBlank()) {
Text(text = it, style = MaterialTheme.typography.titleMedium) AsyncImage(
} model = image,
event.summary()?.let { contentDescription = null,
Text( modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
text = it, contentScale = ContentScale.Crop,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp), Column(modifier = Modifier.padding(12.dp)) {
horizontalArrangement = Arrangement.End, Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(),
) { verticalAlignment = Alignment.CenterVertically,
JoinNestButton(event = event, accountViewModel = accountViewModel) ) {
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 * Standalone "Join nest" button. Reusable from any composable that has
* that has a [MeetingSpaceEvent] in hand — the lobby card, the * a [MeetingSpaceEvent] in hand — the lobby card, the in-feed note
* in-feed note renderer (so a `nostr:naddr1...` deep-link to a * renderer (so a `nostr:naddr1...` deep-link to a kind-30312 lands one
* kind-30312 lands one tap away from the room), and any future * tap away from the room), and any future room-list surface. Renders
* room-list surface. Renders nothing for events without a * nothing for events without a service / endpoint / d-tag — those
* service / endpoint / d-tag — those rooms can't be joined on * rooms can't be joined on the audio plane.
* 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 @Composable
fun JoinNestButton( fun JoinNestButton(
event: MeetingSpaceEvent, event: MeetingSpaceEvent,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
primaryColorOverride: Color? = null,
) { ) {
val serviceBase = event.service() val serviceBase = event.service()
val endpoint = event.endpoint() val endpoint = event.endpoint()
@@ -137,18 +300,30 @@ fun JoinNestButton(
val hostPubkey = event.pubKey val hostPubkey = event.pubKey
val kind = event.kind val kind = event.kind
Button(onClick = { val colors =
NestBridge.set(accountViewModel) if (primaryColorOverride != null) {
NestActivity.launch( androidx.compose.material3.ButtonDefaults
context = context, .buttonColors(containerColor = primaryColorOverride)
addressValue = addressValue, } else {
authBaseUrl = serviceBase, androidx.compose.material3.ButtonDefaults
endpoint = endpoint, .buttonColors()
hostPubkey = hostPubkey, }
roomId = roomId,
kind = kind, 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)) Text(stringRes(R.string.nest_join))
} }
} }
+1
View File
@@ -529,6 +529,7 @@
<string name="nest_notification_text">Tap to return.</string> <string name="nest_notification_text">Tap to return.</string>
<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_leave">Leave</string> <string name="nest_leave">Leave</string>
<string name="nest_leave_host_title">End this nest?</string> <string name="nest_leave_host_title">End this nest?</string>
<string name="nest_leave_host_body">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.</string> <string name="nest_leave_host_body">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.</string>