From 7eefdd2fb5d67e36d3de5d01ba2fc30435e47445 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:45:50 +0000 Subject: [PATCH] feat(nip29): visual polish pass on the relay-group UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../relayGroup/RelayGroupChannelListScreen.kt | 101 ++++++++++++--- .../relayGroup/RelayGroupServerList.kt | 59 ++++++--- .../relayGroup/RelayGroupTopBar.kt | 117 ++++++++++++++---- .../chats/rooms/ChatroomHeaderCompose.kt | 27 ++-- 4 files changed, 238 insertions(+), 66 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index b75db2434f..7f353934bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -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), + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index f716456b7c..1cae9ff857 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 1bd4b24aeb..d0d1fd455d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -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), + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 9258f10f84..5c4c86b7e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -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, + ) + } } }