feat(nip29): richer group card + threads/browse polish

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 19:06:18 +00:00
parent 15c336e623
commit 7e680ddea0
4 changed files with 123 additions and 54 deletions
@@ -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),
)
}
}
@@ -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) },
@@ -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(
+3
View File
@@ -1969,6 +1969,9 @@
<string name="relay_group_open">Open group</string>
<string name="relay_group_channels_empty">No groups on this relay yet.</string>
<string name="relay_group_invite_preparing">Preparing invite…</string>
<string name="relay_group_badge_private">Private</string>
<string name="relay_group_badge_invite_only">Invite-only</string>
<string name="relay_group_browse_invalid_url">Enter a valid relay URL (wss://…).</string>
<string name="relay_group_threads_empty">No threads yet. Start one with the + button.</string>
<string name="relay_group_thread_new">New thread</string>
<string name="relay_group_thread_untitled">Untitled</string>