From 18f1d8749274dfbdc8aedf8e36b6bd0dd23c8bc4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 15 Jul 2026 18:24:45 -0400 Subject: [PATCH] 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()