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),
|
.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(
|
||||||
|
|||||||
+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.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user