Merge pull request #3582 from vitorpamplona/claude/channel-list-enhancements-wktw8t

Concord: richer channel rows — previews, unread counts, facepiles, live typing
This commit is contained in:
Vitor Pamplona
2026-07-15 19:35:23 -04:00
committed by GitHub
10 changed files with 638 additions and 68 deletions
@@ -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)
}
}
}
@@ -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,20 +58,31 @@ 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
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
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 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
@@ -94,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<HexKey, Map<HexKey, Long>>()) })
.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<String?>(null) }
var minting by remember { mutableStateOf(false) }
@@ -254,28 +282,26 @@ 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) },
)
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)) },
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 +309,169 @@ 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,
typingAuthors: List<HexKey>,
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/facepile reactive as messages arrive.
val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) }
val channelState by channel
.flow()
.notes.stateFlow
.collectAsStateWithLifecycle()
// 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)
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
.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), 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)
}
}
if (canManageChannels) {
ConcordChannelRowMenu(
onRename = onRename,
onDelete = onDelete,
)
}
}
}
/** How many recent-poster avatars a channel row's facepile shows at most. */
private const val FACEPILE_MAX = 4
/**
* 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<HexKey>,
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)
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,
)
}
/** 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?,
@@ -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<HexKey>,
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)
}
}
}
}
}
}
@@ -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.
@@ -432,9 +402,13 @@ private fun ConcordChannelRow(
.flow()
.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
// 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)
val unread = unreadCount > 0
// In the UNREAD peek, a read channel simply isn't shown.
if (hideIfRead && !unread) return
@@ -486,12 +460,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,117 @@
/*
* 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.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
/**
* 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(account, lastRead)
}
}
/**
* 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
* 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<HexKey> {
val latestByAuthor = HashMap<HexKey, Long>()
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 }
}
@@ -0,0 +1,78 @@
/*
* 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.animation.animateContentSize
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 }
// 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)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
@@ -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()
+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>
@@ -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)
@@ -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)
}
}