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),
) {
ShowVideoStreaming(channel, accountViewModel)
NestJoinCard(channel, accountViewModel)
NestJoinCard(channel, accountViewModel, nav)
LiveStreamTopZappers(channel, accountViewModel, nav)
LiveStreamGoalHeader(channel, accountViewModel, nav)
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.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))
}
}
+1
View File
@@ -529,6 +529,7 @@
<string name="nest_notification_text">Tap to return.</string>
<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_leave">Leave</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>