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..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 @@ -28,8 +28,12 @@ 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.distinctUntilChanged +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 +62,41 @@ 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 = + // 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 * [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..75659f454e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupUnread.kt @@ -0,0 +1,88 @@ +/* + * 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.distinctUntilChanged +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 } } + } + }.distinctUntilChanged() + +/** 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 =