From 7e680ddea0fa2d75dbde24ece6916d52e2741d26 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 19:06:18 +0000 Subject: [PATCH] feat(nip29): richer group card + threads/browse polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card glow-up (from the UI review): ElevatedCard with a subtle avatar ring, the name promoted to titleMedium, a tonal "Private"/"Invite-only" status pill, and a member-count chip with a people icon + primary-tinted chevron — so the inline card reads as a living community, not a link row. Still fixed-layout / no reflow. Threads: gate the compose FAB on membership (a non-member's kind-11 is rejected by the relay), make per-thread reply counts reactive via observeNoteReplyCount (so a new kind-1111 comment bumps the count live), and use leading dividers. Browse: show an inline error when the pasted relay URL doesn't normalize, and drop a redundant Row wrapper around the text field. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../amethyst/ui/components/RelayGroupCard.kt | 103 +++++++++++++----- .../relayGroup/RelayGroupBrowseScreen.kt | 40 ++++--- .../relayGroup/RelayGroupThreadsScreen.kt | 31 ++++-- amethyst/src/main/res/values/strings.xml | 3 + 4 files changed, 123 insertions(+), 54 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt index b0c6a6db30..371939fece 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -27,8 +28,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -100,27 +104,28 @@ private fun RelayGroupCardContent( val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() val memberCount = channel.memberCount() - - // Relay host is known immediately; the member count fills in when the roster loads. - // Both render on the same single line so nothing reflows as the count arrives. val relayLabel = channel.groupId.relayUrl.displayUrl() - val subtitle = - if (memberCount > 0) { - "$relayLabel · ${pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)}" - } else { - relayLabel + + // Closed (invite-only) is the more actionable signal to a prospective joiner than + // private, so it wins when both are set. Null for a plain open group. + val statusBadge = + when { + channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only) + channel.isPrivate() -> stringRes(R.string.relay_group_badge_private) + else -> null } // Shown only when the group actually has an about, so description-less groups stay // compact. It appears (a small one-time grow) once the relay-signed metadata loads. val description = channel.summary()?.takeIf { it.isNotBlank() } - OutlinedCard( + ElevatedCard( onClick = { nav.nav( Route.RelayGroup(channel.groupId.id, channel.groupId.relayUrl.url, inviteCode = inviteCode), ) }, + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), ) { Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { @@ -133,7 +138,11 @@ private fun RelayGroupCardContent( robot = channel.groupId.id, model = channel.profilePicture(), contentDescription = channel.toBestDisplayName(), - modifier = Modifier.size(48.dp).clip(CircleShape), + modifier = + Modifier + .size(52.dp) + .clip(CircleShape) + .border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape), loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = autoPlayGif, @@ -142,38 +151,58 @@ private fun RelayGroupCardContent( Column(Modifier.weight(1f)) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - if (channel.isPrivate()) { - Icon( - symbol = MaterialSymbols.Lock, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), - ) - } Text( text = channel.toBestDisplayName(), + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) + if (statusBadge != null) { + StatusPill(statusBadge) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (memberCount > 0) { + Icon( + symbol = MaterialSymbols.Group, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + Text( + text = pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "·", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = relayLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) } - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) } Icon( symbol = MaterialSymbols.ChevronRight, contentDescription = stringRes(R.string.relay_group_open), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), ) } @@ -190,3 +219,19 @@ private fun RelayGroupCardContent( } } } + +/** A small tonal pill flagging group visibility/join policy (e.g. Private / Invite-only). */ +@Composable +private fun StatusPill(text: String) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt index 0c4d8707ba..2d87e85c63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupBrowseScreen.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -77,12 +76,17 @@ fun RelayGroupBrowseScreen( nav: INav, ) { var relayUrl by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } val joined by accountViewModel.account.relayGroupList.liveRelayGroupServers .collectAsStateWithLifecycle() fun open(url: String) { - val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) ?: return + val normalized = RelayUrlNormalizer.normalizeOrNull(url.trim()) + if (normalized == null) { + showError = true + return + } nav.nav(Route.RelayGroupServer(normalized.url)) } @@ -114,20 +118,26 @@ fun RelayGroupBrowseScreen( modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) - Row( + OutlinedTextField( + value = relayUrl, + onValueChange = { + relayUrl = it + showError = false + }, + singleLine = true, + isError = showError, + supportingText = + if (showError) { + { Text(stringRes(R.string.relay_group_browse_invalid_url)) } + } else { + null + }, + label = { Text(stringRes(R.string.relay_group_browse_relay_label)) }, + placeholder = { Text("wss://…") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { open(relayUrl) }), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - OutlinedTextField( - value = relayUrl, - onValueChange = { relayUrl = it }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_browse_relay_label)) }, - placeholder = { Text("wss://…") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go), - keyboardActions = KeyboardActions(onGo = { open(relayUrl) }), - modifier = Modifier.weight(1f), - ) - } + ) Button( onClick = { open(relayUrl) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt index 419eaac887..a176698ffe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -30,7 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton @@ -98,6 +99,10 @@ private fun RelayGroupThreads( val threads by channel.threads.collectAsStateWithLifecycle() var showCompose by remember { mutableStateOf(false) } + // Only members can post a thread (the relay rejects a non-member's kind-11), so the + // compose FAB is hidden for everyone else. + val canPost = channel.membershipOf(accountViewModel.userProfile().pubkeyHex).isMember() + Scaffold( topBar = { TopBarExtensibleWithBackButton( @@ -122,12 +127,14 @@ private fun RelayGroupThreads( ) }, floatingActionButton = { - FloatingActionButton(onClick = { showCompose = true }) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = stringRes(R.string.relay_group_thread_new), - modifier = Modifier.size(24.dp), - ) + if (canPost) { + FloatingActionButton(onClick = { showCompose = true }) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.relay_group_thread_new), + modifier = Modifier.size(24.dp), + ) + } } }, ) { padding -> @@ -142,9 +149,11 @@ private fun RelayGroupThreads( } } else { LazyColumn(modifier = Modifier.padding(padding)) { - items(threads, key = { it.idHex }) { thread -> + itemsIndexed(threads, key = { _, thread -> thread.idHex }) { index, thread -> + if (index > 0) { + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) } - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } } @@ -162,6 +171,9 @@ private fun ThreadRow( nav: INav, onClick: () -> Unit, ) { + // Observe the reply count so a kind-1111 comment arriving on an already-listed thread + // bumps it live (channel.threads only re-emits on add/remove of a thread). + val replyCount by observeNoteReplyCount(thread, accountViewModel) val event = thread.event as? ThreadEvent val title = event?.title()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.relay_group_thread_untitled) val preview = @@ -170,7 +182,6 @@ private fun ThreadRow( ?.replace('\n', ' ') ?.trim() .orEmpty() - val replyCount = thread.replies.size val author = thread.author Row( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 700854f53e..db1144d0e4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1969,6 +1969,9 @@ Open group No groups on this relay yet. Preparing invite… + Private + Invite-only + Enter a valid relay URL (wss://…). No threads yet. Start one with the + button. New thread Untitled