feat: enrich Concord channel list rows with last message + unread counts

The community "server" channel list (ConcordChannelListScreen) showed only an
icon and name per channel. It now mirrors the chat-room list feel: each row
carries a preview of the last message (author + snippet), the relative time of
that message, and an unread-message count badge, with the name/icon/time
emphasized while unread.

Adds a shared, reactive unread-message counter (concordChannelUnreadCountFlow)
that combines each channel's persisted last-read marker with its own note store,
plus a shared ConcordUnreadBadge pill (capped at 99+, plural content
description). The Concord Channels hub (ConcordHomeScreen) now reuses both:
per-channel rows show the real new-message count instead of a bare dot, and a
community's badge sums new messages across its channels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e
This commit is contained in:
Claude
2026-07-15 21:24:25 +00:00
parent 2be4929a97
commit 7ecb1af13b
5 changed files with 278 additions and 65 deletions
@@ -60,15 +60,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.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -254,28 +260,18 @@ fun ConcordChannelListScreen(
def.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
}
Row(
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.Concord(communityId, entry.key)) }
.padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.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, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1)
if (canManageChannels) {
ConcordChannelRowMenu(
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
)
}
}
ConcordChannelListRow(
communityId = communityId,
channelKey = entry.key,
channelName = name,
icon = icon,
isVoice = def.voice == true,
canManageChannels = canManageChannels,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.Concord(communityId, entry.key)) },
onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) },
)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
@@ -283,6 +279,116 @@ fun ConcordChannelListScreen(
}
}
/**
* One channel row in the community's server view: its icon, name, a preview of the last message
* (author + snippet), the relative time of that message, and an unread-message count badge — plus
* the manager-only overflow menu. Name/icon come from the folded Control Plane definition; the
* preview, time, and unread count are read reactively from the channel's own message store so they
* fill in the moment messages fold in and clear as soon as the channel is opened (last-read advances).
*/
@Composable
private fun ConcordChannelListRow(
communityId: String,
channelKey: String,
channelName: String,
icon: MaterialSymbol,
isVoice: Boolean,
canManageChannels: Boolean,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
val account = accountViewModel.account
// getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note
// yet; its notes flow then makes the preview/time/unread reactive as the first message arrives.
val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) }
val channelState by channel
.flow()
.notes.stateFlow
.collectAsStateWithLifecycle()
val lastNote = channelState.channel.lastNote
val unread by
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
.collectAsStateWithLifecycle(0)
val hasUnread = unread > 0
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = if (canManageChannels) 4.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
SymbolIcon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (hasUnread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(Modifier.weight(1f)) {
Text(
channelName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ConcordChannelPreviewLine(lastNote, isVoice, accountViewModel)
}
lastNote?.createdAt()?.let { ts ->
Text(
timeAgo(ts, LocalContext.current, prefix = ""),
style = MaterialTheme.typography.labelSmall,
color = if (hasUnread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
ConcordUnreadBadge(unread)
if (canManageChannels) {
ConcordChannelRowMenu(
onRename = onRename,
onDelete = onDelete,
)
}
}
}
/**
* The one-line message preview under a channel name: the last message's author and a snippet of its
* content ("author: hello"), or a muted "No messages yet" placeholder before anything has folded in.
* The author name resolves reactively, so it upgrades from a hex fallback to the profile name.
*/
@Composable
private fun ConcordChannelPreviewLine(
lastNote: Note?,
isVoice: Boolean,
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).ifBlank { "" }
if (body.isBlank()) authorName ?: "" else "${authorName ?: ""}: $body"
} else if (event != null) {
event.content.take(80)
} else {
// Voice channels never carry chat notes, so "No messages yet" would read oddly — leave blank.
if (isVoice) return
stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_no_messages)
}
Text(
preview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/** A pending channel create ([channelIdHex] null) or rename target. */
private data class ConcordChannelEditor(
val channelIdHex: String?,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -258,10 +257,9 @@ private fun communityActivity(
} ?: 0L
/**
* The number of a community's channels with a message newer than this account last read there —
* combines each channel's persisted last-read ([concordChannelLastReadRoute]) against its
* [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel.lastNote]. Recomputed on
* [revision] so a freshly-folded message flips the badge.
* The total number of new messages across a community's channels — the sum, over every channel, of
* messages newer than this account last read that channel ([concordChannelUnreadCountFlow]).
* Recomputed reactively as messages fold in and as channels are opened (last-read advances).
*/
@Composable
private fun communityUnreadCount(
@@ -271,23 +269,14 @@ private fun communityUnreadCount(
): Int {
if (channelKeys.isEmpty()) return 0
// Keyed only on the channel set (not the global revision): each per-channel flow reacts to both
// its last-read marker AND the channel's own notes flow, so a folded message flips the badge
// its last-read marker AND the channel's own notes flow, so a folded message updates the badge
// without tearing down and restarting every flow on every unrelated fold (which reset the badge
// to 0 and made it flicker).
val flow =
remember(communityId, channelKeys) {
combine(
channelKeys.map { key ->
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key))
combine(
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)),
channel.flow().notes.stateFlow,
) { lastRead, state ->
val last = state.channel.lastNote?.createdAt() ?: 0L
if (last > lastRead) 1 else 0
}
},
) { flags -> flags.sum() }
channelKeys.map { key -> concordChannelUnreadCountFlow(account, communityId, key) },
) { counts -> counts.sum() }
}
return flow.collectAsStateWithLifecycle(0).value
}
@@ -357,7 +346,7 @@ private fun CommunityHeader(
)
}
}
if (unread > 0) UnreadBadge(unread)
ConcordUnreadBadge(unread)
SymbolIcon(
// ▲ only when fully open; ▼ for both CLOSED and the UNREAD peek ("more to reveal").
symbol = if (mode == ChannelExpand.OPEN) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
@@ -388,25 +377,6 @@ private fun CommunityBanner(
)
}
/** A small pill showing the unread-channel count next to a community. */
@Composable
private fun UnreadBadge(count: Int) {
Box(
modifier =
Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 7.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
count.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
/**
* One channel row with its last message (author + snippet), relative time, and an unread marker —
* bold + a dot when there's a message newer than this account last read the channel.
@@ -433,8 +403,10 @@ private fun ConcordChannelRow(
.notes.stateFlow
.collectAsStateWithLifecycle()
val lastNote = channelState.channel.lastNote
val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle()
val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime
val unreadCount by
remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) }
.collectAsStateWithLifecycle(0)
val unread = unreadCount > 0
// In the UNREAD peek, a read channel simply isn't shown.
if (hideIfRead && !unread) return
@@ -486,12 +458,10 @@ private fun ConcordChannelRow(
Text(
timeAgo(ts, LocalContext.current, prefix = ""),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = if (unread) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (unread) {
Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary))
}
ConcordUnreadBadge(unreadCount)
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
/**
* A reactive flow of how many messages in [communityId]/[channelKey] are newer than the
* timestamp this account last read that channel. It combines the persisted last-read marker
* ([concordChannelLastReadRoute]) with the channel's own notes flow, so the count updates both
* when a fresh message folds in and when the user opens the channel (which advances last-read).
*
* Uses `getOrCreate` (not `getIfExists`): a channel folded on the Control Plane may have no
* message-buffer note yet, and the flow must still exist so the badge appears the moment its
* first message lands. Counting straight off [ConcordChannel.notes] ignores the synthetic
* placeholder note (it is never added to that cache) and any note with no timestamp.
*/
fun concordChannelUnreadCountFlow(
account: Account,
communityId: String,
channelKey: String,
): Flow<Int> {
val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey))
// The notes flow drives recomputation (each new message re-emits its channel state); the count
// itself reads the channel's own note store, so it survives the base-typed ChannelState.channel.
return combine(
account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)),
channel.flow().notes.stateFlow,
) { lastRead, _ ->
channel.newMessagesSince(lastRead)
}
}
/** The number of this channel's messages created strictly after [sinceSecs] (0 if none). */
private fun ConcordChannel.newMessagesSince(sinceSecs: Long): Int = notes.count { _, note -> (note.createdAt() ?: 0L) > sinceSecs }
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */
private const val CONCORD_UNREAD_CAP = 99
/**
* A small pill showing an unread-message [count] (new messages since this account last read).
* Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at
* [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers.
*/
@Composable
fun ConcordUnreadBadge(
count: Int,
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString()
val description = pluralStringResource(R.plurals.concord_unread_messages, count, count)
Box(
modifier =
modifier
.semantics { contentDescription = description }
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
+5
View File
@@ -341,6 +341,11 @@
<item quantity="one">%1$d member</item>
<item quantity="other">%1$d members</item>
</plurals>
<plurals name="concord_unread_messages">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
<string name="concord_channel_no_messages">No messages yet</string>
<string name="concord_create_action">Create</string>
<string name="concord_invite_action">Invite people</string>
<string name="concord_invite_title">Invite link</string>