diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index fa940f597b..249ab174f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -160,6 +160,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent import com.vitorpamplona.quartz.buzz.dm.DmHideEvent import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent @@ -3422,6 +3423,26 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } + /** + * Re-reads a relay group's own state (39000-39003) from its host relay. + * + * Needed because Buzz never streams those. It signs them with `d`/`p` tags and **no `h`**, yet + * stores and fans them out channel-scoped — so a filter carrying `#h` does not match their tags, + * and one without `#h` is a global subscription, which by design receives no channel-scoped + * event. Neither shape can be live, so a role change or rename left the roster stale until the + * next cold start. What the relay does push is the kind-40099 that narrates the change; callers + * use that as the cue to call this. + */ + suspend fun refreshRelayGroupState(channel: RelayGroupChannel) { + val relay = channel.groupId.relayUrl + val filter = + Filter( + kinds = RELAY_GROUP_METADATA_KINDS, + tags = mapOf("d" to listOf(channel.groupId.id)), + ) + client.fetchAll(filters = mapOf(relay to listOf(filter)), timeoutMs = 8_000) + } + /** * Add [pubkey] to the group (or change its roles) with a kind 9000 put-user * event (moderator only). Pass an empty [roles] list for a plain member. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index adf62d63be..616c8b57ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -138,6 +138,18 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> + // Advance the room's last-read marker for whatever this row turns out to be. This used + // to live inside NormalChatNote — the `else` of the branch below — so a row rendered by + // any of the specialised paths (Buzz system lines and activity rows, diffs, forum votes, + // NIP-28 admin lines, zaps) never marked itself read. A channel whose newest events are + // system messages therefore kept its unread badge no matter how often it was opened, + // which on a Buzz relay is most channels: joins and role changes are system messages. + if (routeForLastRead != null) { + LaunchedEffect(key1 = routeForLastRead, key2 = baseNote.idHex) { + accountViewModel.loadAndMarkAsRead(routeForLastRead, baseNote.createdAt(), dismissNotificationId = baseNote.idHex) + } + } + val event = baseNote.event if (event is LnZapEvent) { RenderChatZap(baseNote, accountViewModel, nav) @@ -163,7 +175,6 @@ fun ChatroomMessageCompose( } else { NormalChatNote( baseNote, - routeForLastRead, innerQuote, canPreview, parentBackgroundColor, @@ -194,7 +205,6 @@ fun ChatroomMessageCompose( @Composable fun NormalChatNote( note: Note, - routeForLastRead: String?, innerQuote: Boolean = false, canPreview: Boolean = true, parentBackgroundColor: MutableState? = null, @@ -222,12 +232,6 @@ fun NormalChatNote( } } - if (routeForLastRead != null) { - LaunchedEffect(key1 = routeForLastRead) { - accountViewModel.loadAndMarkAsRead(routeForLastRead, note.createdAt(), dismissNotificationId = note.idHex) - } - } - // A geohash chat asks own messages to still show the author line (which identity posted), so the // usual "hide the name on my own bubbles" shortcut is opt-out there. val showSelfAuthorName = LocalChatShowSelfAuthorName.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 7c2101d041..bbfcb6012d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -86,13 +86,16 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.quartz.buzz.aoObserver.ObserverFrameEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext /** * The roster of a NIP-29 group: everyone the relay lists as an admin (kind 39001) @@ -116,6 +119,37 @@ fun RelayGroupMembersScreen( } } +/** + * Re-reads a group's 39000-39003 whenever a new relay system message (kind 40099) lands in it. + * + * Keyed on the newest system message's id, so it fires once per change rather than polling, and not + * at all on a relay that doesn't emit them. + */ +@Composable +private fun RefreshRelayGroupStateOnSystemMessage( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, +) { + val notesState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + + val newestSystemMessageId = + remember(notesState) { + channel.notes + .filter { _, note -> note.event is SystemMessageEvent } + .maxByOrNull { it.createdAt() ?: 0L } + ?.idHex + } + + LaunchedEffect(newestSystemMessageId) { + if (newestSystemMessageId != null) { + withContext(Dispatchers.IO) { accountViewModel.account.refreshRelayGroupState(channel) } + } + } +} + private class RosterEntry( val pubkey: HexKey, val membership: RelayGroupMembership, @@ -141,6 +175,13 @@ private fun RelayGroupMembers( val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? RelayGroupChannel ?: baseChannel + // Buzz never streams the roster: it signs 39001/39002 with `d`/`p` and no `h`, yet stores and + // fans them channel-scoped — so a filter with `#h` doesn't match their tags and one without is a + // global subscription, which receives no channel-scoped event. Adding or promoting somebody + // therefore left this screen showing the old roles until the next cold start. The relay *does* + // push the kind-40099 narrating the change, so treat that as the cue to re-read the state. + RefreshRelayGroupStateOnSystemMessage(channel, accountViewModel) + val myPubkey = accountViewModel.userProfile().pubkeyHex val iCanModerate = channel.membershipOf(myPubkey).canModerate() val iAmAdmin = channel.membershipOf(myPubkey) == RelayGroupMembership.ADMIN