fix: Concord unread badge + last-message previews ignore thread replies

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-15 18:24:45 -04:00
co-authored by Claude Opus 4.8
parent b8966049b8
commit 18f1d87492
4 changed files with 56 additions and 7 deletions
@@ -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)
@@ -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)
@@ -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
@@ -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()