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:
+1
-1
@@ -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(
|
||||
|
||||
+221
-46
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user