feat(nip29): visual polish pass on the relay-group UI

Replace the plain, glyph-based rows with real iconography and avatars (all icons
reuse the existing MaterialSymbols subset — no font regen):

- Relay rows (grouped view): the relay's NIP-11 avatar + name, host subtitle, and
  a ChevronRight icon instead of a "›" character.
- Channel-browse rows: a channel avatar, a private-lock glyph, member count, and a
  "joined" check; the FAB uses the Add icon instead of a "+" character.
- Chat top bar: a real MoreVert overflow icon; the header now shows a colored role
  pill (Admin/Moderator/Requested), a members count with a Group icon, and a lock
  for private groups — replacing the flat "host · N members · role" text.
- Inline row's relay chip gains a small Dns icon.

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-07 22:45:50 +00:00
parent 2dca306914
commit 7eefdd2fb5
4 changed files with 238 additions and 66 deletions
@@ -21,11 +21,16 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -37,16 +42,25 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupDirectorySubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.delay
@@ -97,14 +111,19 @@ fun RelayGroupChannelListScreen(
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreate = true }) {
Text("+", style = MaterialTheme.typography.headlineMedium)
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.relay_group_create_title),
modifier = Modifier.size(24.dp),
)
}
},
) { padding ->
val myPubkey = accountViewModel.userProfile().pubkeyHex
LazyColumn(modifier = Modifier.padding(padding)) {
items(channels, key = { it.groupId.id }) { channel ->
RelayGroupChannelRow(channel) { nav.nav(routeFor(channel)) }
HorizontalDivider(thickness = 0.25.dp)
RelayGroupChannelRow(channel, myPubkey, accountViewModel) { nav.nav(routeFor(channel)) }
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@@ -117,29 +136,77 @@ fun RelayGroupChannelListScreen(
@Composable
private fun RelayGroupChannelRow(
channel: RelayGroupChannel,
myPubkey: String,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
Column(
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val joined = channel.membershipOf(myPubkey).isMember()
val memberCount = channel.memberCount()
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "# " + channel.toBestDisplayName(),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
RobohashFallbackAsyncImage(
robot = channel.groupId.id,
model = channel.profilePicture(),
contentDescription = channel.toBestDisplayName(),
modifier = Modifier.size(40.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
channel.summary()?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (channel.isPrivate()) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
}
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val subtitle =
channel.summary()?.takeIf { it.isNotBlank() }
?: if (memberCount > 0) {
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)
} else {
null
}
subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (joined) {
Box(Modifier.size(20.dp).clip(CircleShape)) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = stringRes(R.string.relay_group_role_member),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
@@ -26,6 +26,7 @@ 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.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -37,16 +38,21 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* The "grouped" Messages view: one row per host relay of the user's joined NIP-29
* groups. Tapping a relay opens its channel list. Rendered above the DM feed only
* in [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED].
* groups, each with the relay's NIP-11 avatar and name. Tapping a relay opens its
* channel list. Rendered above the DM feed only in
* [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED].
*/
@Composable
fun RelayGroupServerList(
@@ -60,8 +66,8 @@ fun RelayGroupServerList(
Column(Modifier.fillMaxWidth()) {
servers.sorted().forEach { server ->
RelayServerRow(server) { nav.nav(Route.RelayGroupServer(server)) }
HorizontalDivider(thickness = 0.25.dp)
RelayServerRow(server, accountViewModel) { nav.nav(Route.RelayGroupServer(server)) }
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@@ -69,29 +75,52 @@ fun RelayGroupServerList(
@Composable
private fun RelayServerRow(
relayUrl: String,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val label = RelayUrlNormalizer.normalizeOrNull(relayUrl)?.displayUrl() ?: relayUrl
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl)
val host = relay?.displayUrl() ?: relayUrl
val info = relay?.let { loadRelayInfo(it) }
val name = info?.value?.name?.takeIf { it.isNotBlank() } ?: host
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = label,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
RenderRelayIcon(
displayUrl = host,
iconUrl = info?.value?.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
pingInMs = 0,
)
Text(
text = "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
Column(Modifier.weight(1f)) {
Text(
text = name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (name != host) {
Text(
text = host,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
@@ -20,12 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -33,10 +39,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
@@ -75,19 +85,53 @@ fun RelayGroupTopBar(
TopBarExtensibleWithBackButton(
title = {
Column {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle(channel.groupId.relayUrl.displayUrl(), memberCount, displayMembership),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = channel.toBestDisplayName(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
RoleBadge(displayMembership)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (channel.isPrivate()) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(12.dp),
)
}
Text(
text = channel.groupId.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (memberCount > 0) {
Icon(
symbol = MaterialSymbols.Group,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(12.dp),
)
Text(
text = "$memberCount",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
actions = {
@@ -116,7 +160,11 @@ fun RelayGroupTopBar(
else -> {
IconButton(onClick = { menuOpen = true }) {
Text("", style = MaterialTheme.typography.titleLarge)
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
modifier = Modifier.size(22.dp),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (displayMembership.canModerate()) {
@@ -156,22 +204,37 @@ fun RelayGroupTopBar(
}
}
/** A small colored pill naming the user's role/status in the group. */
@Composable
private fun subtitle(
relayHost: String,
memberCount: Int,
membership: RelayGroupMembership,
): String {
val parts = mutableListOf(relayHost)
if (memberCount > 0) parts.add(pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount))
val role =
private fun RoleBadge(membership: RelayGroupMembership) {
val label =
when (membership) {
RelayGroupMembership.ADMIN -> stringRes(R.string.relay_group_role_admin)
RelayGroupMembership.MODERATOR -> stringRes(R.string.relay_group_role_moderator)
RelayGroupMembership.MEMBER -> stringRes(R.string.relay_group_role_member)
RelayGroupMembership.PENDING -> stringRes(R.string.relay_group_pending)
RelayGroupMembership.NONE -> null
// A plain member needs no badge; the lack of a Join button already says it.
RelayGroupMembership.MEMBER, RelayGroupMembership.NONE -> return
}
role?.let { parts.add(it) }
return parts.joinToString(" · ")
val container =
if (membership == RelayGroupMembership.ADMIN || membership == RelayGroupMembership.MODERATOR) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.secondaryContainer
}
val content =
if (membership == RelayGroupMembership.ADMIN || membership == RelayGroupMembership.MODERATOR) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSecondaryContainer
}
Surface(shape = RoundedCornerShape(6.dp), color = container) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = content,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
@@ -21,10 +21,12 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
@@ -378,14 +380,25 @@ private fun RelayNameChip(
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(11.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}