Merge pull request #3631 from vitorpamplona/claude/concord-nip29-message-indicators-jwyvgt

Add unread indicators for NIP-29 relay groups and Concord channels
This commit is contained in:
Vitor Pamplona
2026-07-17 21:06:55 -04:00
committed by GitHub
5 changed files with 191 additions and 5 deletions
@@ -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<Boolean> =
// 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
@@ -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,
@@ -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()}"
@@ -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<Boolean> {
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<Boolean> =
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
@@ -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 =