From 7ecb1af13bcb7d8f30402b3a5b61977bb50ee2ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:57:11 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e --- .../concord/ConcordChannelListScreen.kt | 150 +++++++++++++++--- .../concord/ConcordHomeScreen.kt | 56 ++----- .../publicChannels/concord/ConcordUnread.kt | 58 +++++++ .../concord/ConcordUnreadBadge.kt | 74 +++++++++ amethyst/src/main/res/values/strings.xml | 5 + 5 files changed, 278 insertions(+), 65 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 84121fe740..28992c8e58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index a368f2429c..34def3f473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt new file mode 100644 index 0000000000..deb3d2835b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt @@ -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 { + 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt new file mode 100644 index 0000000000..99bf36ab36 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt @@ -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, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e0a84dd566..d8573277d6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -341,6 +341,11 @@ %1$d member %1$d members + + %1$d new message + %1$d new messages + + No messages yet Create Invite people Invite link