feat(concord): rich community/channel rows + inbox community icon

- ConcordHomeScreen: each community row now shows a circular avatar
  (RobohashFallbackAsyncImage, community icon or robohash fallback) and a
  pluralized channel count, re-read reactively on each Control-Plane fold.
- ConcordChannelListScreen: channel rows show a type icon (# public / lock
  private / mic voice) and an explicit empty state, styled like the relay-group
  channel list (thin dividers).
- Inbox row (ConcordRoomCompose): passes the folded community icon to ChannelName
  instead of null, so Messages shows the community avatar.
- ConcordChannel exposes communityIcon from the folded MetadataEntity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-10 23:53:49 +00:00
parent f7ee2964db
commit 3f475fd5eb
5 changed files with 125 additions and 32 deletions
@@ -21,10 +21,13 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.material3.AlertDialog
@@ -42,6 +45,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
@@ -116,18 +120,43 @@ fun ConcordChannelListScreen(
?.entries
?.toList()
.orEmpty()
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(channels, key = { it.key }) { entry ->
val name = entry.value.definition?.name ?: entry.key
Column(
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.Concord(communityId, entry.key)) }
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Text("# $name", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (channels.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(channels, key = { it.key }) { entry ->
val def = entry.value.definition
val name = def?.name ?: entry.key
val icon =
when {
def?.voice == true -> MaterialSymbols.Mic
def?.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
}
Row(
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.Concord(communityId, entry.key)) }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
SymbolIcon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1)
}
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
HorizontalDivider()
}
}
}
@@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -38,12 +41,18 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -62,52 +71,97 @@ fun ConcordHomeScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val communities by accountViewModel.account.concordChannelList.liveCommunities
.collectAsStateWithLifecycle()
val account = accountViewModel.account
val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle()
// Re-read folded metadata (icon / channel count) whenever a Control Plane folds.
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_home_title), fontWeight = FontWeight.Bold) },
title = { Text(stringRes(R.string.concord_home_title), fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back))
}
},
)
},
floatingActionButton = {
FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }) {
SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_title))
SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(R.string.concord_create_title))
}
},
) { padding ->
if (communities.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringRes(com.vitorpamplona.amethyst.R.string.concord_home_empty),
stringRes(R.string.concord_home_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(communities, key = { it.id }) { entry ->
Column(
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.ConcordServer(entry.id)) }
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Text(
entry.name.ifBlank { stringRes(com.vitorpamplona.amethyst.R.string.concord_home_title) },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
}
HorizontalDivider()
val state =
remember(entry.id, revision) {
account.concordSessions
.sessionFor(entry.id)
?.state
?.value
}
CommunityRow(
communityId = entry.id,
name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) },
iconUrl = state?.metadata?.icon,
channelCount = state?.channels?.size ?: 0,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.ConcordServer(entry.id)) },
)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
}
@Composable
private fun CommunityRow(
communityId: String,
name: String,
iconUrl: String?,
channelCount: Int,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle()
Row(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement =
androidx.compose.foundation.layout.Arrangement
.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = communityId,
model = iconUrl,
contentDescription = name,
modifier = Modifier.size(40.dp).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (channelCount > 0) {
Text(
pluralStringResource(R.plurals.concord_channel_count, channelCount, channelCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -436,7 +436,7 @@ private fun ConcordRoomCompose(
ChannelName(
channelIdHex = channel.channelId.channelId,
channelPicture = null,
channelPicture = channel.communityIcon,
channelTitle = { modifier ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Text(
+5
View File
@@ -309,11 +309,16 @@
<string name="concord_invite_failed">Could not fetch this invite. The link may be expired or its relays unreachable.</string>
<string name="concord_home_title">Concord Channels</string>
<string name="concord_home_empty">You haven\'t joined any Concord Channels yet. Create one, or open an invite link.</string>
<string name="concord_channels_empty">No channels yet.</string>
<string name="concord_create_title">New Concord Channel</string>
<string name="concord_create_name">Name</string>
<string name="concord_create_about">About (optional)</string>
<string name="concord_create_relays">Relays</string>
<string name="concord_create_icon">Icon URL (optional)</string>
<plurals name="concord_channel_count">
<item quantity="one">%1$d channel</item>
<item quantity="other">%1$d channels</item>
</plurals>
<string name="concord_create_action">Create</string>
<string name="concord_invite_action">Invite people</string>
<string name="concord_invite_title">Invite link</string>
@@ -60,6 +60,10 @@ class ConcordChannel(
var communityName: String? = null
private set
/** The parent community's icon URL, from its folded metadata (null if unset). */
var communityIcon: String? = null
private set
/** The community's bootstrap relays — a channel plane may be mirrored on all of them. */
var communityRelays: Set<NormalizedRelayUrl> = emptySet()
private set
@@ -84,6 +88,7 @@ class ConcordChannel(
isPrivate = it.private
}
communityName = state.metadata?.name
communityIcon = state.metadata?.icon
communityRelays = relays
membership = ConcordMembership.of(state.authority, myPubKey)
}