From 76d7753a34c45279eed33d0a52044ebf00b549ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 17:32:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(nests):=20richer=20NestJoinCard=20?= =?UTF-8?q?=E2=80=94=20cover,=20theme,=20host,=20speakers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../nip53LiveActivities/ChannelView.kt | 2 +- .../loggedIn/nests/room/NestJoinCard.kt | 267 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 223 insertions(+), 47 deletions(-) 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.