From 26b24c91250d2e48812f162a2b66964f6aff05c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:27:03 +0000 Subject: [PATCH 1/2] feat: show unread dot for NIP-29 groups and Concord communities in Messages The Messages list showed the purple "new messages" dot (NewItemsBubble) for DMs, NIP-28 public chats, ephemeral chats, geohash chats and Marmot groups, but hardcoded hasNewMessages = false for the four NIP-29 / Concord rows, so those never indicated unread activity. Wire real unread detection into all four, reusing the last-read markers their open-chat screens already advance: - Individual NIP-29 group row and Concord channel row compare the newest message against the persisted last-read timestamp, exactly like the Marmot / public-chat rows do. - The collapsed "grouped by relay" (RelayGroupServerRoomNote) and "grouped by community" (ConcordServerRoomNote) rows light the dot when ANY child group / channel has unread, via new reactive fan-in flows that follow the joined list / community session so joins and folds re-subscribe. Extract the relay-group last-read route into relayGroupChannelLastReadRoute so the read side (row) and write side (RelayGroupChannelView) can't drift, mirroring marmotGroupLastReadRoute / concordChannelLastReadRoute. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C --- .../publicChannels/concord/ConcordUnread.kt | 31 +++++++ .../relayGroup/RelayGroupChannelView.kt | 2 +- .../relayGroup/RelayGroupLastRead.kt | 32 +++++++ .../relayGroup/RelayGroupUnread.kt | 86 +++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 35 +++++++- 5 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupLastRead.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt 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 f7590d71c1..4bcbfef652 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 @@ -28,8 +28,11 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf /** * A reactive flow of how many messages in [communityId]/[channelKey] are newer than the @@ -58,6 +61,34 @@ fun concordChannelUnreadCountFlow( } } +/** + * True when ANY channel in Concord [communityId] has unread messages — the unread signal for the + * collapsed "grouped by community" Messages row ([ConcordServerRoomNote]). It follows the community's + * live session state so a channel added/removed by a Control-Plane fold re-subscribes the fan-in, and + * each channel contributes its own [concordChannelUnreadCountFlow]. Emits false for a community with + * no session or no channels yet. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun concordCommunityHasUnreadFlow( + account: Account, + communityId: String, +): Flow { + val session = account.concordSessions.sessionFor(communityId) ?: return flowOf(false) + return session.state.flatMapLatest { state -> + val channelKeys = + state + ?.channels + ?.keys + ?.toList() + .orEmpty() + if (channelKeys.isEmpty()) { + flowOf(false) + } else { + combine(channelKeys.map { concordChannelUnreadCountFlow(account, communityId, it) }) { counts -> counts.any { it > 0 } } + } + } +} + /** * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt index fb93f065a1..2f15360789 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelView.kt @@ -168,7 +168,7 @@ private fun ChannelView( feedContentState = feedViewModel.feedState, accountViewModel = accountViewModel, nav = nav, - routeForLastRead = "RelayGroup/${channel.groupId.toKey()}", + routeForLastRead = relayGroupChannelLastReadRoute(channel.groupId), avoidDraft = newPostModel.draftTag, onWantsToReply = newPostModel::reply, onWantsToEditDraft = newPostModel::editFromDraft, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupLastRead.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupLastRead.kt new file mode 100644 index 0000000000..f5f563f050 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupLastRead.kt @@ -0,0 +1,32 @@ +/* + * 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.relayGroup + +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * Key into the account's persisted last-read map (`lastReadPerRoute`) for a NIP-29 + * relay group. The mark-as-read side (the open group's feed, [RelayGroupChannelView]) + * and the unread indicators (Messages screen row, grouped-by-relay row) must use the + * identical key, or read state silently stops persisting for one of them. Keyed by the + * group's `id@relay` so the same group id on two different relays stays distinct. + */ +fun relayGroupChannelLastReadRoute(groupId: GroupId): String = "RelayGroup/${groupId.toKey()}" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt new file mode 100644 index 0000000000..60ab012f9c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -0,0 +1,86 @@ +/* + * 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.relayGroup + +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.isGroupChatContent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +/** + * True when this NIP-29 group has at least one chat message newer than the timestamp this account + * last read it ([relayGroupChannelLastReadRoute]). Reactive: it recombines both when a fresh message + * folds in (the channel's notes flow ticks) and when the user opens the group (which advances the + * last-read marker). Only actual group chat content counts (see [isGroupChatContent]) so a trailing + * reaction/deletion can't stick the dot on; unacceptable (muted/blocked) authors are ignored too. + */ +fun relayGroupChannelHasUnreadFlow( + account: Account, + groupId: GroupId, +): Flow { + val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) + return combine( + account.loadLastReadFlow(relayGroupChannelLastReadRoute(groupId)), + channel.flow().notes.stateFlow, + ) { lastRead, _ -> + channel.hasChatNewerThan(account, lastRead) + } +} + +/** + * True when ANY of the account's joined groups on [relay] has unread chat — the unread signal for + * the collapsed "grouped by relay" Messages row ([RelayGroupServerRoomNote]). It follows the joined + * list ([RelayGroupListState.liveRelayGroupList]) so a group joined/left on that relay re-subscribes + * the fan-in, and each group contributes its own [relayGroupChannelHasUnreadFlow]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +fun relayGroupServerHasUnreadFlow( + account: Account, + relay: NormalizedRelayUrl, +): Flow = + account.relayGroupList.liveRelayGroupList.flatMapLatest { tags -> + val groupIds = + tags.mapNotNull { tag -> + if (RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) == relay) GroupId(tag.groupId, relay) else null + } + if (groupIds.isEmpty()) { + flowOf(false) + } else { + combine(groupIds.map { relayGroupChannelHasUnreadFlow(account, it) }) { perGroup -> perGroup.any { it } } + } + } + +/** Whether this group's message store holds any acceptable chat content created after [sinceSecs]. */ +private fun RelayGroupChannel.hasChatNewerThan( + account: Account, + sinceSecs: Long, +): Boolean = + notes.count { _, note -> + (note.createdAt() ?: 0L) > sinceSecs && account.isAcceptable(note) && note.event?.isGroupChatContent() == true + } > 0 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index c1192c4548..4c797b7d0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -87,8 +87,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGro import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordCommunityHasUnreadFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupChannelLastReadRoute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.relayGroupServerHasUnreadFlow import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote import com.vitorpamplona.amethyst.ui.stringRes @@ -460,6 +464,11 @@ private fun RelayGroupRoomCompose( relayInfo.icon?.ifBlank { null } } + // Unread dot: the newest chat is newer than the last time I opened this group. Same last-read + // route the open group's feed advances (relayGroupChannelLastReadRoute), so opening clears it. + // A placeholder row (no messages yet) has a null createdAt and never lights the dot. + val lastReadTime by accountViewModel.account.loadLastReadFlow(relayGroupChannelLastReadRoute(channel.groupId)).collectAsStateWithLifecycle() + ChannelName( channelIdHex = channel.groupId.id, channelPicture = channelPicture, @@ -481,7 +490,7 @@ private fun RelayGroupRoomCompose( }, channelLastTime = lastMessage.createdAt(), channelLastContent = lastContent, - hasNewMessages = false, + hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = @@ -513,6 +522,14 @@ private fun ConcordRoomCompose( channel.communityName ?: stringRes(R.string.relay_group_no_messages_yet) } + // Unread dot: the newest timeline message is newer than the last time I opened this channel. + // Same last-read route the open channel's feed advances (concordChannelLastReadRoute), so + // opening clears it. `lastMessage` is already the newest timeline note (or a null-timestamp + // placeholder), so this stays in step with the badges on the Concord channel-list screen. + val lastReadTime by accountViewModel.account + .loadLastReadFlow(concordChannelLastReadRoute(channel.channelId.communityId, channel.channelId.channelId)) + .collectAsStateWithLifecycle() + ChannelName( channelIdHex = channel.channelId.channelId, channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel), @@ -538,7 +555,7 @@ private fun ConcordRoomCompose( }, channelLastTime = lastMessage.createdAt(), channelLastContent = lastContent, - hasNewMessages = false, + hasNewMessages = (lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = @@ -570,13 +587,18 @@ private fun RelayGroupServerRoomCompose( stringRes(R.string.relay_group_no_messages_yet) } + // Collapsed relay row: light the dot when ANY joined group on this relay has unread chat. + val hasNewMessages by remember(relay) { + relayGroupServerHasUnreadFlow(accountViewModel.account, relay) + }.collectAsStateWithLifecycle(false) + ChannelName( channelIdHex = relay.url, channelPicture = relayInfo.icon, channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, MaterialSymbols.Dns, R.string.relay_group_server_label, modifier) }, channelLastTime = row.newestMessage?.createdAt(), channelLastContent = lastContent, - hasNewMessages = false, + hasNewMessages = hasNewMessages, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = @@ -616,13 +638,18 @@ private fun ConcordServerRoomCompose( stringRes(R.string.relay_group_no_messages_yet) } + // Collapsed community row: light the dot when ANY channel in this community has unread messages. + val hasNewMessages by remember(row.communityId) { + concordCommunityHasUnreadFlow(accountViewModel.account, row.communityId) + }.collectAsStateWithLifecycle(false) + ChannelName( channelIdHex = row.communityId, channelPicture = rememberConcordImageModel(metadata?.icon, accountViewModel), channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, MaterialSymbols.Group, R.string.concord_server_label, modifier) }, channelLastTime = row.newestMessage?.createdAt(), channelLastContent = lastContent, - hasNewMessages = false, + hasNewMessages = hasNewMessages, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = From 5ad3e143e49c894bb6f72839b48cffd5fd6097a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 00:55:30 +0000 Subject: [PATCH 2/2] fix: keep Concord community unread flow alive across Refounding + dedupe emissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-ups on the Messages unread dot: - concordCommunityHasUnreadFlow captured the community session once, but a Refounding rebuilds a still-joined community's session in place (same id, new object). Because the row's flow is remember(communityId)-scoped it was never recreated, so the fan-in kept watching the dead session and the dot froze. Re-resolve the session on every concordSessions.revision tick instead — the same signal ConcordServerRoomCompose already uses to refresh the row's name/icon — which also covers channel-set changes from folds. - distinctUntilChanged() on both aggregated server flows so a new message that doesn't flip the has-unread boolean no longer pushes a redundant value into composition. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C --- .../publicChannels/concord/ConcordUnread.kt | 40 +++++++++++-------- .../relayGroup/RelayGroupUnread.kt | 22 +++++----- 2 files changed, 36 insertions(+), 26 deletions(-) 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 4bcbfef652..5f5b6124ef 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 @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -72,22 +73,29 @@ fun concordChannelUnreadCountFlow( fun concordCommunityHasUnreadFlow( account: Account, communityId: String, -): Flow { - val session = account.concordSessions.sessionFor(communityId) ?: return flowOf(false) - return session.state.flatMapLatest { state -> - val channelKeys = - state - ?.channels - ?.keys - ?.toList() - .orEmpty() - if (channelKeys.isEmpty()) { - flowOf(false) - } else { - combine(channelKeys.map { concordChannelUnreadCountFlow(account, communityId, it) }) { counts -> counts.any { it > 0 } } - } - } -} +): Flow = + // Re-resolve the session on every revision tick rather than capturing it once. A Refounding + // rebuilds a still-joined community's session in place (same id, new object) and a fold changes + // the channel set — both bump `revision`; capturing the session once would leave the fan-in + // pointed at a dead session so the dot freezes. This mirrors how ConcordServerRoomCompose already + // re-reads the row's name/icon off `revision`. + account.concordSessions.revision + .flatMapLatest { + val channelKeys = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.channels + ?.keys + ?.toList() + .orEmpty() + if (channelKeys.isEmpty()) { + flowOf(false) + } else { + combine(channelKeys.map { concordChannelUnreadCountFlow(account, communityId, it) }) { counts -> counts.any { it > 0 } } + } + }.distinctUntilChanged() /** * True for a note the Concord channel *timeline* actually renders — the same predicate as diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt index 60ab012f9c..75659f454e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.isGroupChatContent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -64,17 +65,18 @@ fun relayGroupServerHasUnreadFlow( account: Account, relay: NormalizedRelayUrl, ): Flow = - account.relayGroupList.liveRelayGroupList.flatMapLatest { tags -> - val groupIds = - tags.mapNotNull { tag -> - if (RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) == relay) GroupId(tag.groupId, relay) else null + account.relayGroupList.liveRelayGroupList + .flatMapLatest { tags -> + val groupIds = + tags.mapNotNull { tag -> + if (RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) == relay) GroupId(tag.groupId, relay) else null + } + if (groupIds.isEmpty()) { + flowOf(false) + } else { + combine(groupIds.map { relayGroupChannelHasUnreadFlow(account, it) }) { perGroup -> perGroup.any { it } } } - if (groupIds.isEmpty()) { - flowOf(false) - } else { - combine(groupIds.map { relayGroupChannelHasUnreadFlow(account, it) }) { perGroup -> perGroup.any { it } } - } - } + }.distinctUntilChanged() /** Whether this group's message store holds any acceptable chat content created after [sinceSecs]. */ private fun RelayGroupChannel.hasChatNewerThan(