fix(buzz): refresh the roster after a role change, and clear unread on open

**The roster went stale.** Promoting somebody changed nothing on screen until the
next cold start. Buzz signs its 39000-39003 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; measured it directly, a role
change delivers only the kind-40099 that narrates it.

So use that as the cue: the members screen re-reads the group's state whenever a
new system message lands in it, keyed on the message id so it fires once per
change rather than polling. Account.refreshRelayGroupState does the fetch.

**Unread badges never cleared.** loadAndMarkAsRead lived inside NormalChatNote —
the `else` of the render switch — so a row drawn by any specialised path (Buzz
system lines and activity rows, diffs, forum votes, NIP-28 admin lines, zaps)
never advanced the room's last-read marker. On a Buzz relay that is most rows:
joins, adds and role changes are all system messages, so a channel whose newest
events were those kept its badge no matter how often it was opened. Hoisted the
call to cover every row type; NormalChatNote's now-dead routeForLastRead
parameter is gone.

Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting a
member now shows the `admin` badge without restarting the app, and opening
`general` cleared its badge while the channels left untouched kept theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-27 11:38:50 -04:00
co-authored by Claude Opus 5
parent ceb9f9c3db
commit 8bb074c6ae
3 changed files with 74 additions and 8 deletions
@@ -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.
@@ -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<Color>? = 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
@@ -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