feat: redesign Relay Groups discovery as a relay-grouped rail

Rework the NIP-29 Relay Groups discovery feed from a flat list of tall
ElevatedCards into the "Relay Rail" layout: groups are clustered under a
slim per-relay header (NIP-11 icon + name, group count, favorite star)
and rendered as thin inbox-style rows inside a rounded block.

Each row now previews what's actually happening in the group instead of
a static blurb: the newest message as "author: text", a green LIVE pill
when the group has an active audio room (hasLivekit), the people you
follow who are already inside, or a compact message-activity badge. The
old `about` description and the standalone Join button are dropped to
keep rows thin — tapping a row opens the group where you can join.

The feed is bucketed by host relay in the composable (a cache lookup, no
extra subscription); per-row metadata and the message preview still
stream lazily as rows scroll on, via the existing warm-up subscription.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
This commit is contained in:
Claude
2026-07-25 00:14:53 +00:00
parent 09e389373c
commit 31a0137924
2 changed files with 369 additions and 282 deletions
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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
@@ -32,10 +34,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -48,6 +48,7 @@ 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.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -61,6 +62,8 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.model.nip11RelayInfo.WarmNip11
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
@@ -75,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor
@@ -83,7 +87,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -154,21 +160,32 @@ fun RelayGroupDiscoveryScreen(
routeForLastRead = null,
onLoaded = { loaded ->
val items by loaded.feed.collectAsStateWithLifecycle()
// Collect the kind-3 follow set once for the whole list; each card highlights
// Collect the kind-3 follow set once for the whole list; each row highlights
// the members it contains ("people you follow who are in here").
val follows by accountViewModel.account.kind3FollowList.flow
.collectAsStateWithLifecycle()
// Relay Rail: instead of a flat list of tall cards, cluster the groups by their
// host relay. Each relay becomes a slim header + a block of thin inbox-style
// rows. Resolving a note to its channel is a cache lookup (no subscription), so
// bucketing the whole feed here is cheap; the per-row live state (metadata,
// message preview) still streams lazily inside each row as it scrolls on.
val entries = remember(items.list) { toRelayRailEntries(items.list) }
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
modifier = Modifier.fillMaxWidth(),
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
RelayGroupDiscoveryRow(item, Modifier.animateItem(), follows.authors, accountViewModel, nav)
entries,
key = { _, entry -> entry.key },
contentType = { _, entry -> entry::class },
) { _, entry ->
when (entry) {
is RelayRailEntry.Header ->
RelayRailHeader(entry.relay, entry.groupCount, Modifier.animateItem(), accountViewModel, nav)
is RelayRailEntry.GroupRow ->
RelayGroupRailRow(entry.note, entry.isFirst, entry.isLast, Modifier.animateItem(), follows.authors, accountViewModel, nav)
}
}
}
},
@@ -256,27 +273,154 @@ private fun WatchAccountForRelayGroupDiscovery(
}
/**
* Resolves the 39000 metadata note to its live [RelayGroupChannel] (keyed by host relay + group
* id) and recomposes in place as the relay-signed metadata / roster changes, so member counts and
* membership stay current without moving the row. While the row is on screen it also warms the
* group — the newest handful of chat messages / threads — so tapping it opens an already-populated
* screen. The warm-up is lifecycle-aware and tears down as the row scrolls off.
* One flattened entry in the Relay Rail list: either a relay [Header] that opens a cluster, or a
* [GroupRow] for one group inside the cluster just above it. [isFirst]/[isLast] mark a row's
* position in its relay block so it can round the right corners and skip the top divider.
*/
private sealed interface RelayRailEntry {
val key: String
data class Header(
val relay: NormalizedRelayUrl,
val groupCount: Int,
) : RelayRailEntry {
override val key get() = "relay:" + relay.url
}
data class GroupRow(
val note: Note,
val isFirst: Boolean,
val isLast: Boolean,
) : RelayRailEntry {
override val key get() = note.idHex
}
}
/**
* Bucket the flat discovery feed by each group's host relay, preserving the relay's first-seen
* order and the feed order within it, then flatten into header + row entries. Resolving a note to
* its channel is a cache lookup ([relayGroupDiscoveryChannelFor]) with no subscription, so doing it
* for the whole list here is cheap; the live per-row state still streams lazily as rows scroll on.
*/
private fun toRelayRailEntries(notes: List<Note>): List<RelayRailEntry> {
val buckets = LinkedHashMap<NormalizedRelayUrl, MutableList<Note>>()
notes.forEach { note ->
val relay = relayGroupDiscoveryChannelFor(note)?.groupId?.relayUrl ?: return@forEach
buckets.getOrPut(relay) { ArrayList() }.add(note)
}
val entries = ArrayList<RelayRailEntry>(buckets.size + notes.size)
buckets.forEach { (relay, groupNotes) ->
entries.add(RelayRailEntry.Header(relay, groupNotes.size))
groupNotes.forEachIndexed { i, note ->
entries.add(RelayRailEntry.GroupRow(note, isFirst = i == 0, isLast = i == groupNotes.lastIndex))
}
}
return entries
}
/**
* The slim header that owns a relay's cluster: the relay's NIP-11 icon + name, how many groups it
* hosts, and the favorite star. Tapping the name area opens that relay's full group list
* ([Route.RelayGroupServer]); the star is a separate tap target that toggles the relay in the
* kind-10012 relay-feeds list (favoriting it, and every group it hosts, under the relay filter).
*/
@Composable
private fun RelayGroupDiscoveryRow(
private fun RelayRailHeader(
relay: NormalizedRelayUrl,
groupCount: Int,
modifier: Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
val info = loadRelayInfo(relay)
val host = relay.displayUrl()
val name = info.value.name?.takeIf { it.isNotBlank() } ?: host
val favoriteRelays by accountViewModel.account.relayFeedsList.flow
.collectAsStateWithLifecycle()
val isFavorite = relay in favoriteRelays
Row(
modifier = modifier.fillMaxWidth().padding(start = 6.dp, end = 6.dp, top = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier =
Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.clickable { nav.nav(Route.RelayGroupServer(relay.url)) }
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
RenderRelayIcon(
displayUrl = host,
iconUrl = info.value.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
pingInMs = 0,
iconModifier = Modifier.size(Size25dp).clip(CircleShape),
)
Column(Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
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,
)
}
}
Text(
text = pluralStringResource(R.plurals.relay_group_relay_group_count, groupCount, groupCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
Icon(
symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder,
contentDescription = stringRes(R.string.relay_group_favorite_relay),
tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.clip(CircleShape)
.clickable {
if (isFavorite) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay)
}.padding(6.dp)
.size(18.dp),
)
}
}
/**
* Resolves the 39000 metadata note to its live [RelayGroupChannel] and renders it as a thin
* inbox-style row inside its relay's block. Recomposes in place as the relay-signed metadata /
* roster / message preview streams in. While on screen it warms the group's recent content
* (contentOnly) so tapping it opens an already-populated screen; the warm-up tears down as the row
* scrolls off. The card's old `about` description is intentionally dropped — the last message and
* activity signals below say what's happening far better than a static blurb.
*/
@Composable
private fun RelayGroupRailRow(
note: Note,
isFirst: Boolean,
isLast: Boolean,
modifier: Modifier,
follows: Set<HexKey>,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Resolve the note to its channel the SAME way the feed filter matched/sorted it, so the row
// never binds a different relay's (empty) channel than the one the feed qualified.
val baseChannel = remember(note) { relayGroupDiscoveryChannelFor(note) } ?: return
// Prefetch the group's recent content so opening the card lands on a populated screen. The
// metadata/rosters are already streaming from the directory subscription, so only ask for
// content here (contentOnly) instead of re-requesting 39000-39003 per visible row.
RelayGroupCardWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupCardWarmup, accountViewModel, contentOnly = true)
val channelState by baseChannel
@@ -285,312 +429,249 @@ private fun RelayGroupDiscoveryRow(
.collectAsStateWithLifecycle()
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
// Reactive loaded-message count: the preview subscription streams kind-9 chats into the
// channel's note cache, and each arrival re-emits this flow, so the "50+ messages" activity
// signal grows live while the row is on screen.
// The preview subscription streams kind-9 chats into the channel's note cache; each arrival
// re-emits this flow, so the last-message line and the "50+" activity badge grow live on screen.
val notesState by baseChannel
.flow()
.notes.stateFlow
.collectAsStateWithLifecycle()
val messageCount = notesState.channel.notes.size()
val lastNote = notesState.channel.lastNote
// People I follow who are in this group (relay-signed roster ∩ my kind-3 follows). Recomputed
// when the roster changes (channelState) or my follow list does.
val participatingFollows = remember(channelState, follows) { channel.participatingFollows(follows) }
val favoriteRelays by accountViewModel.account.relayFeedsList.flow
.collectAsStateWithLifecycle()
val blockColor = MaterialTheme.colorScheme.surfaceContainerLow
val shape =
RoundedCornerShape(
topStart = if (isFirst) 14.dp else 0.dp,
topEnd = if (isFirst) 14.dp else 0.dp,
bottomStart = if (isLast) 14.dp else 0.dp,
bottomEnd = if (isLast) 14.dp else 0.dp,
)
RelayGroupDiscoveryCard(
channel = channel,
modifier = modifier,
myPubkey = accountViewModel.userProfile().pubkeyHex,
isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays,
messageCount = messageCount,
participatingFollows = participatingFollows,
accountViewModel = accountViewModel,
nav = nav,
)
Column(modifier.fillMaxWidth().padding(horizontal = 10.dp)) {
Surface(
onClick = { nav.nav(routeFor(channel)) },
shape = shape,
color = blockColor,
modifier = Modifier.fillMaxWidth(),
) {
Column {
if (!isFirst) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.padding(start = 63.dp),
)
}
RelayGroupRailRowContent(
channel = channel,
lastNote = lastNote,
messageCount = messageCount,
participatingFollows = participatingFollows,
blockColor = blockColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
private fun RelayGroupDiscoveryCard(
private fun RelayGroupRailRowContent(
channel: RelayGroupChannel,
modifier: Modifier,
myPubkey: String,
isFavoriteRelay: Boolean,
lastNote: Note?,
messageCount: Int,
participatingFollows: List<HexKey>,
blockColor: Color,
accountViewModel: AccountViewModel,
nav: INav,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
val isMember = channel.membershipOf(myPubkey).isMember()
val memberCount = channel.memberCount()
val relay = channel.groupId.relayUrl
ElevatedCard(
onClick = { nav.nav(routeFor(channel)) },
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 5.dp),
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = channel.groupId.id,
model = channel.profilePicture(),
contentDescription = channel.toBestDisplayName(),
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,
)
RobohashFallbackAsyncImage(
robot = channel.groupId.id,
model = channel.profilePicture(),
contentDescription = channel.toBestDisplayName(),
modifier =
Modifier
.size(40.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,
)
Column(Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = channel.toBestDisplayName(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
DiscoveryStatusPill(channel)
}
if (memberCount > 0 || messageCount > 0) {
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,
)
}
if (memberCount > 0 && messageCount > 0) {
Text(
text = "·",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (messageCount > 0) {
// Loaded from the preview page; a full page reads as "50+", fewer as
// the exact count. DISCOVERY_MESSAGE_CAP tracks the preview limit.
Text(
text =
if (messageCount >= DISCOVERY_MESSAGE_CAP) {
pluralStringResource(R.plurals.relay_group_message_count_capped, DISCOVERY_MESSAGE_CAP, DISCOVERY_MESSAGE_CAP)
} else {
pluralStringResource(R.plurals.relay_group_message_count, messageCount, messageCount)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (!isMember && channel.requiresMembershipToPost()) {
FilledTonalButton(onClick = {
// Open groups join directly; closed ones open the group where the invite
// code can be entered.
if (channel.isClosed()) {
nav.nav(routeFor(channel))
} else {
accountViewModel.joinRelayGroup(channel)
}
}) {
Text(stringRes(R.string.join))
}
}
}
// Prominent social proof: the people I follow who are already in this group.
if (participatingFollows.isNotEmpty()) {
FollowsInGroupRow(
follows = participatingFollows,
accountViewModel = accountViewModel,
nav = nav,
modifier = Modifier.padding(top = 10.dp),
)
}
// The host relay as its own chip. Tapping the chip opens that relay's full group list;
// the star INSIDE it is a separate tap target that only favorites the relay (surfacing
// its groups under the relay filter). Joining the group is the separate button above.
RelayChip(
relay = relay,
isFavorite = isFavoriteRelay,
onToggleFavorite = {
if (isFavoriteRelay) accountViewModel.unfollowRelayFeed(relay) else accountViewModel.followRelayFeed(relay)
},
onOpenRelay = { nav.nav(Route.RelayGroupServer(relay.url)) },
modifier = Modifier.padding(top = 8.dp),
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = channel.toBestDisplayName(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
channel.summary()?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
RelayGroupPreviewLine(channel, lastNote, accountViewModel)
}
}
}
/**
* The group's host relay as a tappable chip. The chip body opens that relay's full group list
* ([Route.RelayGroupServer]); the star inside it is an independent tap target that only toggles the
* relay in the kind-10012 relay-feeds list (a favorited relay, and every group it hosts, then
* surfaces under the relay chip in the top-nav filter). Filled/primary when favorited, tonal outline
* otherwise — visually distinct from the group's Join button so the scopes don't read as one action.
*/
@Composable
private fun RelayChip(
relay: NormalizedRelayUrl,
isFavorite: Boolean,
onToggleFavorite: () -> Unit,
onOpenRelay: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier = modifier.fillMaxWidth()) {
Surface(
onClick = onOpenRelay,
shape = RoundedCornerShape(8.dp),
color =
if (isFavorite) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
modifier = Modifier.weight(1f, fill = false),
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(start = 2.dp, end = 8.dp, top = 2.dp, bottom = 2.dp),
) {
Icon(
symbol = if (isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder,
contentDescription = stringRes(R.string.relay_group_favorite_relay),
tint =
if (isFavorite) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier =
Modifier
.clip(CircleShape)
.clickable(onClick = onToggleFavorite)
.padding(4.dp)
.size(16.dp),
)
Text(
text = relay.displayUrl(),
style = MaterialTheme.typography.labelMedium,
color =
if (isFavorite) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
RelayGroupStatusPill(channel)
if (participatingFollows.isNotEmpty()) {
CompactFollowFaces(participatingFollows, blockColor, accountViewModel, nav)
} else if (messageCount > 0) {
MessageActivityBadge(messageCount)
}
}
}
}
/**
* Overlapping avatars of the people I follow who are in the group, with a "%d people you follow"
* caption — social proof that a group is worth joining. Shows up to five faces; the caption always
* carries the true total.
* The line under a group name: the newest message as "author: text" — the live "what's happening"
* signal that replaces the old static description. Author names resolve reactively (hex → profile
* name). Before any message has streamed in, falls back to the member count so the row isn't bare.
*/
@Composable
private fun FollowsInGroupRow(
private fun RelayGroupPreviewLine(
channel: RelayGroupChannel,
lastNote: Note?,
accountViewModel: AccountViewModel,
) {
val event = lastNote?.event
val author = lastNote?.author
val preview: String =
if (event != null && author != null) {
val authorName by observeUserName(author, accountViewModel)
val body = event.content.take(80)
if (body.isBlank()) authorName else "$authorName: $body"
} else if (event != null) {
event.content.take(80)
} else {
val memberCount = channel.memberCount()
if (memberCount > 0) {
pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)
} else {
return
}
}
Text(
text = preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/**
* The top badge on a row's right edge. A live audio room (NIP-53 / LiveKit) wins with a green
* pulsing "LIVE"; otherwise invite-only, then private. Open groups show nothing here so the row
* stays quiet.
*/
@Composable
private fun RelayGroupStatusPill(channel: RelayGroupChannel) {
when {
channel.hasLivekit() ->
Surface(shape = RoundedCornerShape(6.dp), color = RelayGroupLiveColor.copy(alpha = 0.16f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Box(Modifier.size(6.dp).clip(CircleShape).background(RelayGroupLiveColor))
Text(
text = stringRes(R.string.relay_group_badge_live),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = RelayGroupLiveColor,
)
}
}
// `closed` alone doesn't mean invite-only on Buzz (it stamps every channel); only badge it
// where membership is actually required to participate.
channel.requiresMembershipToPost() && channel.isClosed() ->
TonalTextPill(stringRes(R.string.relay_group_badge_invite_only))
channel.isPrivate() ->
TonalTextPill(stringRes(R.string.relay_group_badge_private))
else -> {}
}
}
/** A small amber tonal status pill (invite-only / private). */
@Composable
private fun TonalTextPill(label: String) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.warningColor.copy(alpha = 0.16f)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.warningColor,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
/**
* The loaded-message counter as a compact violet pill ("50+" when the preview page is full,
* [DISCOVERY_MESSAGE_CAP] tracking that limit; the exact count otherwise).
*/
@Composable
private fun MessageActivityBadge(messageCount: Int) {
val label =
if (messageCount >= DISCOVERY_MESSAGE_CAP) {
stringRes(R.string.relay_group_message_count_short_capped, DISCOVERY_MESSAGE_CAP)
} else {
messageCount.toString()
}
Surface(shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp),
)
}
}
/**
* Overlapping avatars (up to three) of the people I follow who are already in this group — the
* strongest join signal, so it takes the row's right edge over the message counter when present.
* Faces border in the row's own background so they cut cleanly out of the block.
*/
@Composable
private fun CompactFollowFaces(
follows: List<HexKey>,
blockColor: Color,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy((-9).dp)) {
follows.take(5).forEach { pubkey ->
UserPicture(
userHex = pubkey,
size = Size25dp,
pictureModifier = Modifier.border(1.5.dp, MaterialTheme.colorScheme.surface, CircleShape),
accountViewModel = accountViewModel,
nav = nav,
)
}
Row(horizontalArrangement = Arrangement.spacedBy((-7).dp)) {
follows.take(3).forEach { pubkey ->
UserPicture(
userHex = pubkey,
size = Size20dp,
pictureModifier = Modifier.border(1.5.dp, blockColor, CircleShape),
accountViewModel = accountViewModel,
nav = nav,
)
}
Text(
text = pluralStringResource(R.plurals.relay_group_follows_participating, follows.size, follows.size),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* Display cap for the loaded-message counter — kept in step with the discovery preview's fetch
* limit (RELAY_GROUP_WARMUP_LIMIT), so a chat that returns the full page reads as "50+ messages".
* limit (RELAY_GROUP_WARMUP_LIMIT), so a chat that returns the full page reads as "50+".
*/
private const val DISCOVERY_MESSAGE_CAP = 50
/** Small tonal pill: Invite-only wins over Private, matching the inline group card. */
@Composable
private fun DiscoveryStatusPill(channel: RelayGroupChannel) {
val label =
when {
// `closed` alone doesn't mean invite-only on Buzz (it stamps every channel); only badge
// it where membership is actually required to participate. See [RelayGroupChannel.requiresMembershipToPost].
channel.requiresMembershipToPost() && channel.isClosed() -> stringRes(R.string.relay_group_badge_invite_only)
channel.isPrivate() -> stringRes(R.string.relay_group_badge_private)
else -> return
}
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.secondaryContainer) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
/** Live-audio accent (green) for the row's LIVE pill — reads on both light and dark grounds. */
private val RelayGroupLiveColor = Color(0xFF17B978)
+6
View File
@@ -2563,6 +2563,8 @@
<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_badge_live">LIVE</string>
<string name="relay_group_message_count_short_capped">%1$d+</string>
<string name="relay_group_browse_invalid_url">Enter a valid relay URL (wss://…).</string>
<string name="relay_group_field_name">Name</string>
<string name="relay_group_field_about">Description</string>
@@ -2645,6 +2647,10 @@
<item quantity="one">%1$d person you follow</item>
<item quantity="other">%1$d people you follow</item>
</plurals>
<plurals name="relay_group_relay_group_count">
<item quantity="one">%1$d group</item>
<item quantity="other">%1$d groups</item>
</plurals>
<string name="messages_new_message">Private</string>
<string name="messages_new_message_to">To</string>
<string name="messages_new_message_subject">Subject</string>