mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
co-authored by
Claude Opus 5
parent
ceb9f9c3db
commit
8bb074c6ae
@@ -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.
|
||||
|
||||
+12
-8
@@ -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
|
||||
|
||||
+41
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user