mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+39
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+32
@@ -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()}"
|
||||
+88
@@ -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
|
||||
+31
-4
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user