From 7ecb1af13bcb7d8f30402b3a5b61977bb50ee2ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:57:11 +0000 Subject: [PATCH 1/4] 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 From dbb8cc522ed68e15e81477ff1d1d17d05b560577 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 20:32:18 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20make=20Concord=20channel=20rows=20f?= =?UTF-8?q?eel=20alive=20=E2=80=94=20facepile,=20live=20typing,=20animated?= =?UTF-8?q?=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the last commit to push the server channel list beyond a plain messaging list: - Recent-poster facepile: a stack of overlapping avatars (newest on top) for each channel's latest distinct authors, so a busy channel reads as "who's here" at a glance. New ConcordAuthorFacepile reuses the existing avatar primitives; recentAuthorHexes() picks the most-recent distinct posters in one O(notes) pass. - Live "X is typing…": each row consumes the community session's typing heartbeats (kind 23311) and swaps its preview line for an italic typing indicator, driven by one screen-level freshness ticker that only spins while a heartbeat is live. - Rows are now a two-line layout (name + facepile / preview + time + badge), and the unread badge animateContentSizes as its count changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e --- .../concord/ConcordChannelListScreen.kt | 129 ++++++++++++++---- .../publicChannels/concord/ConcordFacepile.kt | 84 ++++++++++++ .../publicChannels/concord/ConcordUnread.kt | 20 +++ .../concord/ConcordUnreadBadge.kt | 4 + 4 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.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 28992c8e58..c68fcb05fb 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 @@ -47,7 +47,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -56,6 +58,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,6 +66,7 @@ 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.commons.model.concord.ConcordCommunitySession import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.util.setText @@ -76,6 +80,9 @@ 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 com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -100,6 +107,21 @@ fun ConcordChannelListScreen( val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) }) .collectAsStateWithLifecycle() + // Live typing heartbeats (kind 23311) per channel, so a channel where someone is composing shows + // "typing…" in place of its last-message preview. A single screen-level ticker re-applies the + // freshness window for every row at once, and only spins while at least one heartbeat is live. + val typingMap by (session?.typing ?: remember { kotlinx.coroutines.flow.MutableStateFlow(emptyMap>()) }) + .collectAsStateWithLifecycle() + var typingNow by remember { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(typingMap) { + if (typingMap.values.all { it.isEmpty() }) return@LaunchedEffect + while (true) { + typingNow = TimeUtils.now() + if (typingMap.values.all { per -> per.values.none { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS } }) break + delay(2000L) + } + } + val scope = rememberCoroutineScope() var inviteLink by remember { mutableStateOf(null) } var minting by remember { mutableStateOf(false) } @@ -260,12 +282,20 @@ fun ConcordChannelListScreen( def.private == true -> MaterialSymbols.Lock else -> MaterialSymbols.Tag } + val typingAuthors = + remember(typingMap, typingNow, entry.key) { + (typingMap[entry.key] ?: emptyMap()) + .filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS } + .keys + .sorted() + } ConcordChannelListRow( communityId = communityId, channelKey = entry.key, channelName = name, icon = icon, isVoice = def.voice == true, + typingAuthors = typingAuthors, canManageChannels = canManageChannels, accountViewModel = accountViewModel, onClick = { nav.nav(Route.Concord(communityId, entry.key)) }, @@ -293,6 +323,7 @@ private fun ConcordChannelListRow( channelName: String, icon: MaterialSymbol, isVoice: Boolean, + typingAuthors: List, canManageChannels: Boolean, accountViewModel: AccountViewModel, onClick: () -> Unit, @@ -301,7 +332,7 @@ private fun ConcordChannelListRow( ) { 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. + // yet; its notes flow then makes the preview/time/unread/facepile reactive as messages arrive. val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) } val channelState by channel .flow() @@ -312,6 +343,8 @@ private fun ConcordChannelListRow( remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } .collectAsStateWithLifecycle(0) val hasUnread = unread > 0 + // The recent posters' faces — recomputed as the channel's notes change (keyed on channelState). + val faceAuthors = remember(channelState) { channel.recentAuthorHexes(FACEPILE_MAX) } Row( Modifier @@ -327,25 +360,35 @@ private fun ConcordChannelListRow( 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) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + // Line 1: channel name + the recent-posters facepile pushed to the right. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + channelName, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (hasUnread) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ConcordAuthorFacepile(faceAuthors, accountViewModel) + } + // Line 2: the last-message preview (or a live "typing…"), then the time + unread badge. + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f)) { + ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, 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) + } } - 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, @@ -355,24 +398,51 @@ private fun ConcordChannelListRow( } } +/** How many recent-poster avatars a channel row's facepile shows at most. */ +private const val FACEPILE_MAX = 4 + /** - * 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. + * The line under a channel name. When someone is composing it shows a live italic "X is typing…"; + * otherwise the last message's author + a snippet ("author: hello"), or a muted "No messages yet" + * placeholder before anything has folded in. Author names resolve reactively (hex → profile name). */ @Composable private fun ConcordChannelPreviewLine( lastNote: Note?, isVoice: Boolean, + typingAuthors: List, accountViewModel: AccountViewModel, ) { + if (typingAuthors.isNotEmpty()) { + val label = + when (typingAuthors.size) { + 1 -> stringRes(com.vitorpamplona.amethyst.R.string.concord_typing_one, rememberConcordDisplayName(typingAuthors[0], accountViewModel)) + 2 -> + stringRes( + com.vitorpamplona.amethyst.R.string.concord_typing_two, + rememberConcordDisplayName(typingAuthors[0], accountViewModel), + rememberConcordDisplayName(typingAuthors[1], accountViewModel), + ) + else -> stringRes(com.vitorpamplona.amethyst.R.string.concord_typing_many) + } + Text( + label, + style = MaterialTheme.typography.bodySmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + return + } + 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" + val body = event.content.take(80) + if (body.isBlank()) authorName else "$authorName: $body" } else if (event != null) { event.content.take(80) } else { @@ -389,6 +459,17 @@ private fun ConcordChannelPreviewLine( ) } +/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */ +@Composable +private fun rememberConcordDisplayName( + hex: HexKey, + accountViewModel: AccountViewModel, +): String { + val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) } + val name by observeUserName(user, accountViewModel) + return name +} + /** 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/ConcordFacepile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt new file mode 100644 index 0000000000..328458adc2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordFacepile.kt @@ -0,0 +1,84 @@ +/* + * 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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor +import com.vitorpamplona.amethyst.ui.note.ObserveAndDrawInnerUserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A horizontal stack of overlapping avatars for the recent posters in a channel — the "who's here" + * cue that makes a busy channel feel alive. Each avatar wears a thin ring in the row's background + * colour so the overlap reads as a deck of cards; the newest poster sits on top (leftmost, highest + * z-index). Renders nothing for an empty [authorHexes], so callers can drop it in unconditionally. + */ +@Composable +fun ConcordAuthorFacepile( + authorHexes: List, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + avatarSize: Dp = 22.dp, + maxShown: Int = 4, +) { + if (authorHexes.isEmpty()) return + val shown = authorHexes.take(maxShown) + // A ring one-and-a-half dp wide, and each avatar pulled left over its neighbour by a third of + // its width — enough overlap to read as a stack without hiding faces. + val ring = 1.5.dp + val overlap = avatarSize * 0.36f + Row(modifier, horizontalArrangement = Arrangement.spacedBy(-overlap)) { + shown.forEachIndexed { index, hex -> + Box( + Modifier + // Newest (index 0) on top so it isn't clipped by the one after it. + .zIndex((shown.size - index).toFloat()) + .size(avatarSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(ring), + ) { + LoadUser(baseUserHex = hex, accountViewModel) { user -> + if (user != null) { + ObserveAndDrawInnerUserPicture(user, avatarSize - ring * 2, accountViewModel) + } else { + DisplayBlankAuthor(avatarSize - ring * 2, Modifier, accountViewModel) + } + } + } + } + } +} 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 index deb3d2835b..a453cc031f 100644 --- 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 @@ -24,6 +24,7 @@ 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 com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -56,3 +57,22 @@ fun concordChannelUnreadCountFlow( /** 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 } + +/** + * The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the + * facepile shown on a channel row. One O(notes) pass keeps each author's latest post time, so a + * chatty author counts once (at their newest message) rather than crowding out quieter voices. + */ +fun ConcordChannel.recentAuthorHexes(limit: Int): List { + val latestByAuthor = HashMap() + for (note in notes.values()) { + val author = note.author?.pubkeyHex ?: continue + val at = note.createdAt() ?: continue + val prev = latestByAuthor[author] + if (prev == null || at > prev) latestByAuthor[author] = at + } + return latestByAuthor.entries + .sortedByDescending { it.value } + .take(limit) + .map { it.key } +} 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 index 99bf36ab36..7b321b32af 100644 --- 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding @@ -58,6 +59,9 @@ fun ConcordUnreadBadge( modifier = modifier .semantics { contentDescription = description } + // Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of + // snapping — a small touch that makes new activity feel noticed. + .animateContentSize() .sizeIn(minWidth = 20.dp, minHeight = 20.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary) From b8966049b82d39411827f708a34df40250779a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:59:25 +0000 Subject: [PATCH 3/4] fix: Concord channel rows stuck on "No messages yet" despite having messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consumeConcordRumor attaches a message row to its ConcordChannel BEFORE justConsume loads the event (so the note carries its gatherer through the Messages filter). That means Channel.addNote ran while createdAt() was still null and never set lastNote — and on reprojects the containsKey guard skips addNote entirely, so lastNote stayed null forever for every Concord channel. The list/hub rows read lastNote, so they always showed "No messages yet" (and activity sorting was a no-op) even after messages had loaded. Adds Channel.refreshAfterEventLoad(note), called right after justConsume once the event is present: it picks lastNote when newer and invalidates the notes flow so previews, unread counts, and ordering recompute. Regression-tested in commons (ConcordChannelLastNoteTest). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P9Uv3h6GhSQVuUTY5Ffm6e --- .../amethyst/model/LocalCache.kt | 10 +- .../amethyst/commons/model/Channel.kt | 18 ++++ .../concord/ConcordChannelLastNoteTest.kt | 93 +++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c7914c418c..40bc4ba53d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -790,7 +790,15 @@ object LocalCache : ILocalCache, ICacheProvider { // permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message) // is already handled by the normal deletion cascade unlinking the note from its gatherers. messageRow?.let { (ch, note) -> - if (note.event == null) ch.removeNote(note) + if (note.event == null) { + ch.removeNote(note) + } else { + // The row was attached (addNote) BEFORE justConsume set the event, so addNote saw a + // null createdAt and could not pick lastNote or order the feed. The event is loaded + // now — refresh so the channel's last-message preview, unread count, and ordering are + // correct (otherwise lastNote stays null forever and every row reads "No messages yet"). + ch.refreshAfterEventLoad(note) + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt index 960d123b87..061991deeb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Channel.kt @@ -116,6 +116,24 @@ abstract class Channel : NotesGatherer { } } + /** + * Refresh derived state after an already-[addNote]'d [note] finishes loading its event. + * + * Most channels attach a note whose event is already set, so [addNote] can pick [lastNote] and + * order the feed right away. Concord is the exception: it attaches a message row to its channel + * *before* the rumor is consumed (so the row already carries its gatherer when it flows through + * the Messages filter), which means [addNote] ran while `createdAt()` was still null and could + * neither set [lastNote] nor sort correctly. Once the event lands, call this so [lastNote] and + * any notes-flow observers (row previews, unread counts, ordering) reflect the real message. + */ + fun refreshAfterEventLoad(note: Note) { + if (!notes.containsKey(note.idHex)) return + if ((note.createdAt() ?: 0L) > (lastNote?.createdAt() ?: 0L)) { + lastNote = note + } + flowSet?.notes?.invalidateData() + } + override fun removeNote(note: Note) { if (notes.containsKey(note.idHex)) { notes.remove(note.idHex) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt new file mode 100644 index 0000000000..3505bc2fa9 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelLastNoteTest.kt @@ -0,0 +1,93 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Regression cover for the Concord attach-before-consume ordering: `LocalCache.consumeConcordRumor` + * attaches a message row to its [ConcordChannel] (so the note carries its gatherer through the + * Messages filter) *before* `justConsume` loads the event, so [com.vitorpamplona.amethyst.commons.model.Channel.addNote] + * runs while `createdAt()` is null and cannot pick [com.vitorpamplona.amethyst.commons.model.Channel.lastNote]. + * Without the [com.vitorpamplona.amethyst.commons.model.Channel.refreshAfterEventLoad] follow-up, + * `lastNote` stays null forever and every channel row renders "No messages yet" even after messages + * have loaded. + */ +class ConcordChannelLastNoteTest { + private val community = "aa".repeat(32) + private val channelId = "cc".repeat(32) + private val author = "bb".repeat(32) + + private fun channel() = ConcordChannel(ConcordChannelId(community, channelId)) + + private fun event(createdAt: Long): Event = EventFactory.create("00".repeat(32), author, createdAt, 1, arrayOf(), "hi", "22".repeat(64)) + + @Test + fun lastNoteStaysNullUntilEventLoads_thenReflectsIt() { + val c = channel() + val note = Note("11".repeat(32)) + + // Attach exactly as Concord does — before the rumor is consumed, so the event is still null. + c.addNote(note) + assertNull(c.lastNote, "addNote can't pick lastNote while createdAt() is null (the bug)") + + // justConsume loads the event; the follow-up refresh must now set lastNote. + note.event = event(1000) + c.refreshAfterEventLoad(note) + assertEquals(note, c.lastNote) + } + + @Test + fun refreshKeepsTheNewestAndIgnoresLaterOlderLoads() { + val c = channel() + val older = Note("11".repeat(32)) + val newer = Note("22".repeat(32)) + c.addNote(older) + c.addNote(newer) + + older.event = event(1000) + c.refreshAfterEventLoad(older) + newer.event = event(2000) + c.refreshAfterEventLoad(newer) + assertEquals(newer, c.lastNote) + + // An out-of-order older message loading afterwards must not overwrite the newest. + val evenOlder = Note("33".repeat(32)) + c.addNote(evenOlder) + evenOlder.event = event(500) + c.refreshAfterEventLoad(evenOlder) + assertEquals(newer, c.lastNote) + } + + @Test + fun refreshIsANoOpForANoteNotInThisChannel() { + val c = channel() + val stray = Note("44".repeat(32)).apply { event = event(9999) } + c.refreshAfterEventLoad(stray) + assertNull(c.lastNote) + } +} From 18f1d8749274dfbdc8aedf8e36b6bd0dd23c8bc4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 15 Jul 2026 18:24:45 -0400 Subject: [PATCH 4/4] fix: Concord unread badge + last-message previews ignore thread replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unread count, the channel-list/hub last-message previews, and the Messages-row summary all read a broader note set than the channel feed renders: the feed's ChannelFeedFilter hides kind-1111 CommentEvent thread replies (and unacceptable authors), but these row surfaces counted/showed them. A trailing minichat reply therefore stuck the unread badge at a count opening the channel could never clear (markAsRead is monotonic and only advances for rendered timeline messages), and showed up as a "last message" that isn't on the timeline. Centralizes the feed's predicate as isConcordTimelineMessage (loaded, acceptable, not a CommentEvent) plus a newestTimelineNote helper, and routes the unread count, both list-row previews, and the Messages hub filter through it — so every channel-row summary agrees with what the open channel renders. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordChannelListScreen.kt | 4 +- .../concord/ConcordHomeScreen.kt | 4 +- .../publicChannels/concord/ConcordUnread.kt | 45 +++++++++++++++++-- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 10 ++++- 4 files changed, 56 insertions(+), 7 deletions(-) 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 c68fcb05fb..3aebe57917 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 @@ -338,7 +338,9 @@ private fun ConcordChannelListRow( .flow() .notes.stateFlow .collectAsStateWithLifecycle() - val lastNote = channelState.channel.lastNote + // The newest *timeline* message (not the raw lastNote): skips kind-1111 thread replies and + // hidden authors so the preview + time match the channel feed and the unread badge below. + val lastNote = remember(channelState) { channel.newestTimelineNote(account) } val unread by remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } .collectAsStateWithLifecycle(0) 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 34def3f473..6211adcf9d 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 @@ -402,7 +402,9 @@ private fun ConcordChannelRow( .flow() .notes.stateFlow .collectAsStateWithLifecycle() - val lastNote = channelState.channel.lastNote + // The newest *timeline* message (not the raw lastNote): skips kind-1111 thread replies and + // hidden authors so the preview + time match the channel feed and the unread badge below. + val lastNote = remember(channelState) { channel.newestTimelineNote(account) } val unreadCount by remember(communityId, channelKey) { concordChannelUnreadCountFlow(account, communityId, channelKey) } .collectAsStateWithLifecycle(0) 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 index a453cc031f..f7590d71c1 100644 --- 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 @@ -20,11 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -51,12 +54,48 @@ fun concordChannelUnreadCountFlow( account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)), channel.flow().notes.stateFlow, ) { lastRead, _ -> - channel.newMessagesSince(lastRead) + channel.newMessagesSince(account, 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 } +/** + * True for a note the Concord channel *timeline* actually renders — the same predicate as + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedFilter]'s + * `isTimelineMessage`: a loaded, acceptable message that is **not** a kind-1111 [CommentEvent]. + * + * A [CommentEvent] is a *minichat thread reply* that lives inside its parent's thread, not on the + * flat timeline, so it never composes on the channel screen and never advances the last-read + * marker. Every list-row surface that summarizes a channel — the unread badge + * ([newMessagesSince]), the last-message preview + timestamp ([newestTimelineNote]), and the + * Messages hub row — reuses this so none of them can disagree with the open channel's feed: + * a trailing comment can't stick the badge at a count the user can never clear, nor show up as a + * "last message" that isn't in the timeline. Unacceptable (muted/blocked) authors are hidden for + * the same reason. + */ +fun isConcordTimelineMessage( + note: Note, + account: Account, +): Boolean = note.event.let { it != null && it !is CommentEvent } && account.isAcceptable(note) + +/** + * The newest timeline message in this channel (see [isConcordTimelineMessage]), or null if none — + * the note the list/hub rows show as the channel's "last message". Unlike [ConcordChannel.lastNote] + * (the raw newest note of any kind), this skips thread replies and hidden authors so the preview + * matches what the channel feed renders and the unread badge counts. + */ +fun ConcordChannel.newestTimelineNote(account: Account): Note? = + notes + .filter { _, note -> isConcordTimelineMessage(note, account) } + .minWithOrNull(Channel.DefaultFeedOrder) + +/** The number of this channel's timeline messages created strictly after [sinceSecs] (0 if none). */ +private fun ConcordChannel.newMessagesSince( + account: Account, + sinceSecs: Long, +): Int = + notes.count { _, note -> + (note.createdAt() ?: 0L) > sinceSecs && isConcordTimelineMessage(note, account) + } /** * The pubkeys of the [limit] most-recent distinct posters in this channel, newest first — the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index c6def6253c..d99770ba6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.isConcordTimelineMessage import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -400,10 +401,15 @@ class ChatroomListKnownFeedFilter( .sortedByDefaultFeedOrder() .firstOrNull() - /** The newest decrypted message loaded in this Concord channel, or null if none yet. */ + /** + * The newest decrypted *timeline* message loaded in this Concord channel, or null if none yet. + * Uses [isConcordTimelineMessage] so a trailing kind-1111 thread reply (or a hidden author) + * isn't shown as the Messages-row "last message" — the same predicate the channel feed and the + * unread badge use, so the row summary can't disagree with what opening the channel renders. + */ private fun ConcordChannel.newestConcordNote(account: Account): Note? = notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } + .filter { _, it -> isConcordTimelineMessage(it, account) } .sortedByDefaultFeedOrder() .firstOrNull()