fix: don't treat a group reaction as the group's latest message

filterRelevantRelayGroupMessages picked ANY group-scoped note (isGroupScoped()
= carries the group's `h` tag), so a reaction (kind 7) to my message became the
group's "last message" on the Messages tab — a wrong row that the chat renderer
can't display. Whitelist actual chat content (kind 9 chat / 1068 poll / 11
thread / 1111 comment) and reject reactions, deletions, labels, etc. Apply the
same guard to the feed()'s channel-notes scan as defense in depth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-09 22:11:44 +00:00
parent 0366d6d584
commit 5425b09fef
@@ -33,12 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
class ChatroomListKnownFeedFilter(
val account: Account,
@@ -103,10 +107,11 @@ class ChatroomListKnownFeedFilter(
account.relayGroupList.liveRelayGroupList.value.mapNotNull { groupTag ->
val relay = RelayUrlNormalizer.normalizeOrNull(groupTag.relayUrl) ?: return@mapNotNull null
val channel = LocalCache.getOrCreateRelayGroupChannel(GroupId(groupTag.groupId, relay))
// Newest loaded message, or a placeholder row so a just-joined group shows up on
// Messages before its first kind-9 arrives (mirrors the Marmot-group path above).
// Newest loaded chat message, or a placeholder row so a just-joined group shows
// up on Messages before its first kind-9 arrives (mirrors the Marmot-group path
// above). Content kinds only — never a reaction/deletion as the "last message".
channel.notes
.filter { _, it -> account.isAcceptable(it) && it.event != null }
.filter { _, it -> account.isAcceptable(it) && it.event != null && it.isRelayGroupChatContent() }
.sortedByDefaultFeedOrder()
.firstOrNull()
?: channel.placeholderNote()
@@ -284,6 +289,15 @@ class ChatroomListKnownFeedFilter(
return newRelevantEphemeralChats
}
// Only actual group content represents a room in the Messages list. A reaction (kind 7),
// deletion, label, etc. carries the group's `h` tag too, so isGroupScoped() alone would let a
// reaction to my message become the group's "last message" — a wrong, unrenderable row.
private fun Note.isRelayGroupChatContent(): Boolean =
when (event) {
is ChatEvent, is PollEvent, is ThreadEvent, is CommentEvent -> true
else -> false
}
/** Latest message per joined NIP-29 group (inline view mode), keyed by group id. */
private fun filterRelevantRelayGroupMessages(
newItems: Set<Note>,
@@ -295,7 +309,7 @@ class ChatroomListKnownFeedFilter(
.mapTo(HashSet()) { it.groupId }
val result = mutableMapOf<String, Note>()
newItems.forEach { newNote ->
val gid = newNote.event?.takeIf { it.isGroupScoped() }?.groupId()
val gid = newNote.event?.takeIf { it.isGroupScoped() && newNote.isRelayGroupChatContent() }?.groupId()
if (gid != null && gid in joinedGroupIds && account.isAcceptable(newNote)) {
val lastNote = result[gid]
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {