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/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..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 @@ -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>()) }) + .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) } @@ -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, + 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, + 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?, 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/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index a368f2429c..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 @@ -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) } } 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..f7590d71c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnread.kt @@ -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 { + 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 { + 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 new file mode 100644 index 0000000000..7b321b32af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordUnreadBadge.kt @@ -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, + ) + } +} 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() 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 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) + } +}