fix: persist MLS chat unread state across app restarts

The unread dot for Marmot/MLS group rooms was driven by an in-memory
unreadCount on MarmotGroupChatroom. On restart the MLS group state is
restored from the last persisted commit, the kind:445 subscription
restarts with since=null, and the in-memory processed-event dedup set is
empty — so relays redeliver old group events, they re-decrypt as fresh
application messages, and the counter was re-bumped, resurrecting the
dot for chats already read.

Marking-as-read was already persisted: opening a group chat writes the
newest rendered message's createdAt to the MarmotGroup/<groupId> route
in lastReadPerRoute (saved to disk with account settings). Compute the
unread indicators from that timestamp instead — exactly how DM rooms
and public channels do it — in both the Messages screen row and the
Marmot group list row.

With no consumer left, drop the volatile counter and collapse the
addMessageSync/restoreMessageSync split (they only differed in the
counter bump).
This commit is contained in:
Claude
2026-06-10 22:08:51 +00:00
parent 08f6c63d02
commit af7d61f361
6 changed files with 11 additions and 68 deletions
@@ -3549,7 +3549,7 @@ class Account(
if (isNew) {
innerNote.event = innerEvent
}
marmotGroupList.restoreMessage(groupId, innerNote)
marmotGroupList.addMessage(groupId, innerNote)
} catch (e: Exception) {
Log.w(
"Account",
@@ -100,11 +100,6 @@ fun MarmotGroupChatView(
newMessageModel.init(accountViewModel)
newMessageModel.load(nostrGroupId)
DisposableEffect(nostrGroupId) {
newMessageModel.chatroom?.markAsRead()
onDispose { }
}
// Resolve the navigation-supplied replyId (e.g. tapping reply on an MLS
// message in the Notifications screen) into the actual Note once it has
// landed in LocalCache. checkGetOrCreateNote is a no-op for unknown ids.
@@ -222,11 +222,16 @@ fun MarmotGroupListItem(
onClick: () -> Unit,
) {
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val unread by chatroom.unreadCount.collectAsStateWithLifecycle()
val members by chatroom.members.collectAsStateWithLifecycle()
val memberPubkeys = remember(members) { members.map { it.pubkey } }
val newestMessage = chatroom.newestMessage
val lastReadTime by accountViewModel.account.loadLastReadFlow("MarmotGroup/$groupId").collectAsStateWithLifecycle()
val unread =
remember(newestMessage, lastReadTime) {
chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime }
}
Row(
modifier =
Modifier
@@ -260,7 +260,6 @@ private fun MarmotGroupRoomCompose(
nav: INav,
) {
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
val unread by chatroom.unreadCount.collectAsStateWithLifecycle()
val author = lastMessage.author
val noteEvent = lastMessage.event
@@ -274,13 +273,15 @@ private fun MarmotGroupRoomCompose(
stringRes(R.string.marmot_group_no_messages_yet)
}
val lastReadTime by accountViewModel.account.loadLastReadFlow("MarmotGroup/${chatroom.nostrGroupId}").collectAsStateWithLifecycle()
ChannelName(
channelIdHex = chatroom.nostrGroupId,
channelPicture = null,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, R.string.marmot_group, modifier) },
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
hasNewMessages = unread > 0,
hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
@@ -53,7 +53,6 @@ class MarmotGroupChatroom(
var memberCount = MutableStateFlow(0)
var members = MutableStateFlow<List<GroupMemberInfo>>(emptyList())
var newestMessage: Note? = null
val unreadCount = MutableStateFlow(0)
/**
* Tracks the most recent createdAt (seconds) of a kind:445 group event
@@ -125,30 +124,6 @@ class MarmotGroupChatroom(
}
fun addMessageSync(msg: Note): Boolean =
syncLock.withLock {
if (msg !in messages) {
messages = messages + msg
msg.addGatherer(this)
val createdAt = msg.createdAt() ?: 0L
if (createdAt > (newestMessage?.createdAt() ?: 0L)) {
newestMessage = msg
}
unreadCount.value += 1
changesFlow?.get()?.tryEmit(ListChange.Addition(msg))
return@withLock true
}
return@withLock false
}
/**
* Add a message that is being restored from persistent storage on app
* startup. Behaves like [addMessageSync] but does NOT bump the unread
* count restored messages were already seen by the user in a previous
* session.
*/
fun restoreMessageSync(msg: Note): Boolean =
syncLock.withLock {
if (msg !in messages) {
messages = messages + msg
@@ -181,10 +156,6 @@ class MarmotGroupChatroom(
return@withLock false
}
fun markAsRead() {
unreadCount.value = 0
}
fun recordRelayActivity(
relay: NormalizedRelayUrl,
createdAt: Long,
@@ -220,7 +191,6 @@ class MarmotGroupChatroom(
if (toRemove.isEmpty()) return@withLock toRemove
messages = emptySet()
newestMessage = null
unreadCount.value = 0
changesFlow?.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
toRemove
}
@@ -49,35 +49,7 @@ class MarmotGroupList(
) {
if (!isDisplayableFeedMessage(msg)) return
val chatroom = getOrCreateGroup(nostrGroupId)
val isSelfAuthored = msg.author?.pubkeyHex == ownerPubKey
// Use the quiet path for our own messages so the relay round-trip
// doesn't mark the user's own outgoing message as unread.
val added =
if (isSelfAuthored) {
chatroom.restoreMessageSync(msg)
} else {
chatroom.addMessageSync(msg)
}
if (added) {
noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId }
if (isSelfAuthored) {
chatroom.ownerSentMessage = true
}
_groupListChanges.tryEmit(nostrGroupId)
}
}
/**
* Add a message that was restored from persistent storage at app startup.
* Does not bump the chatroom's unread counter.
*/
fun restoreMessage(
nostrGroupId: HexKey,
msg: Note,
) {
if (!isDisplayableFeedMessage(msg)) return
val chatroom = getOrCreateGroup(nostrGroupId)
if (chatroom.restoreMessageSync(msg)) {
if (chatroom.addMessageSync(msg)) {
noteToGroupIndex.getOrCreate(msg.idHex) { nostrGroupId }
if (msg.author?.pubkeyHex == ownerPubKey) {
chatroom.ownerSentMessage = true