From fa6b272213c64a4ce7158ac6c3b35804dc7f4af9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Jul 2026 09:52:30 -0400 Subject: [PATCH 1/6] fix(buzz): make "create" on a Buzz relay actually create a channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The community screen's FAB opened the NIP-29 create-group flow, and on a Buzz relay it published two events and produced nothing: no channel in the list, no channel anywhere. Two independent reasons, both silent. **The create was rejected.** NIP-29 puts the id on the kind-9007 and leaves the metadata to a following 9002. Buzz's ingest.rs validates the 9007 *before storage* and rejects it with "invalid: channel name is required" unless the create event itself carries a `name`; it also reads `about`, `visibility` and `channel_type` off that same event. Our 9007 had only the `h` tag, so it never stored, and the 9002 behind it addressed a channel that was never made. Send the metadata on both events: relay29 ignores the extra tags and takes the 9002, Buzz takes the 9007. **The id was not a UUID.** Buzz keys channels by UUID and parses the `h` tag with `val.parse::()`. Our 8-random-bytes hex id doesn't parse, so the relay discarded it and created the channel under an id of its own — the app then opened the id *it* had picked, which is why the one channel that did get created showed a hex title over an empty feed. Generate a v4 UUID when the host speaks Buzz; NIP-29 ids are opaque strings, so nothing else changes. The screen matched NIP-29 rather than Buzz, too. It offered a photo, hashtags, a geohash and four permission flags — of which Buzz's 9002 handler honours exactly one (`visibility`, two-valued). Those controls looked like they configured the channel and were dropped on the floor. On a Buzz relay it is now: name, description, "Private channel" (Buzz's open/private in Buzz's words), and "Forum channel" for `channel_type` — offered only on create, since Buzz has no `channel_type` key on edit. Titled "New channel", because Buzz calls them channels, and the FAB says so. Plain NIP-29 relays are untouched. Also stops the NIP-11 gate blocking creation on Buzz relays, which advertise no NIP-29 support yet implement 9007/9002, and makes RelayGroupMetadataViewModel's `relay` snapshot state — it is assigned after first composition, so a plain var left the screen stuck rendering its NIP-29 shape. Verified against nosfabrica.communities.buzz.xyz: creating "amethyst-create-test2" lands a real channel — right name in the title, Moderator badge, member count 1, the relay's own "Vitor Pamplona created this channel" system line, and a row in the channel list. BuzzChannelCreateTest covers both wire-level fixes. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 17 ++- .../relayGroup/RelayGroupChannelListScreen.kt | 3 +- .../relayGroup/RelayGroupMetadataScreen.kt | 100 +++++++++++------- .../relayGroup/RelayGroupMetadataViewModel.kt | 31 +++++- amethyst/src/main/res/values/strings.xml | 6 ++ .../buzz/workspace/BuzzChannelMetadata.kt | 29 +++++ .../moderation/CreateGroupEvent.kt | 25 +++++ .../buzz/workspace/BuzzChannelCreateTest.kt | 95 +++++++++++++++++ 8 files changed, 264 insertions(+), 42 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelCreateTest.kt 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 4de3614b1c..f1b070ce93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -168,6 +168,8 @@ import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot @@ -3302,8 +3304,21 @@ class Account( hashtags: List = emptyList(), geohashes: List = emptyList(), parent: String? = null, + channelType: String? = null, ): GroupId { - signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) } + // The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes + // its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without + // a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay + // publish two events and produce nothing at all. + signAndSendPrivatelyOrBroadcast( + CreateGroupEvent.build( + groupId = groupId, + name = name, + about = about, + visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN, + channelType = channelType, + ), + ) { listOf(relay) } val edit = EditMetadataEvent.build( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 8feb4ef174..a7a8671434 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -314,7 +314,8 @@ fun RelayGroupChannelListScreen( FloatingActionButton(onClick = { nav.nav(Route.RelayGroupCreate(relay.url)) }, shape = CircleShape) { Icon( symbol = MaterialSymbols.Add, - contentDescription = stringRes(R.string.relay_group_create_title), + contentDescription = + stringRes(if (isBuzz) R.string.buzz_channel_create_title else R.string.relay_group_create_title), modifier = Modifier.size(24.dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index 0618c5188a..c71641b669 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -198,8 +198,8 @@ private fun RelayGroupMetadataScaffold( topBar = { if (viewModel.isNewGroup) { CreatingTopBar( - titleRes = R.string.relay_group_create_title, - isActive = { viewModel.canPost && nip29Support == true }, + titleRes = if (viewModel.isBuzzRelay) R.string.buzz_channel_create_title else R.string.relay_group_create_title, + isActive = { viewModel.canPost && (nip29Support == true || viewModel.isBuzzRelay) }, onCancel = nav::popBack, onPost = onSubmit, ) @@ -226,20 +226,23 @@ private fun RelayGroupMetadataScaffold( .verticalScroll(scrollState) .padding(horizontal = 16.dp, vertical = 12.dp), ) { - if (nip29Support == false) { + if (nip29Support == false && !viewModel.isBuzzRelay) { NoNip29Warning() Spacer(Modifier.height(16.dp)) } - GroupImagePicker(viewModel) { wantsToPickImage = true } - - Spacer(Modifier.height(16.dp)) + if (!viewModel.isBuzzRelay) { + GroupImagePicker(viewModel) { wantsToPickImage = true } + Spacer(Modifier.height(16.dp)) + } GroupMetadataFields(viewModel) - Spacer(Modifier.height(16.dp)) - - ParentGroupSection(viewModel, accountViewModel) + // Sub-groups are a NIP-29 relation; Buzz has no parent channel. + if (!viewModel.isBuzzRelay) { + Spacer(Modifier.height(16.dp)) + ParentGroupSection(viewModel, accountViewModel) + } } } } @@ -356,32 +359,38 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) - Spacer(Modifier.height(12.dp)) - Text( - text = stringRes(R.string.relay_group_section_discovery), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringRes(R.string.relay_group_section_discovery_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - ) + // Everything below is NIP-29 vocabulary. A Buzz relay stores only `name`, `about` and a + // two-valued `visibility` (its 9002 handler accepts nothing else), so offering hashtags, a + // geohash, or the invite-only/restricted/hidden flags there would be four controls that look + // like they configure the channel and are silently dropped by the relay. + if (!viewModel.isBuzzRelay) { + Spacer(Modifier.height(12.dp)) + Text( + text = stringRes(R.string.relay_group_section_discovery), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.relay_group_section_discovery_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) - OutlinedTextField( - value = viewModel.topics.value, - onValueChange = { - viewModel.topics.value = it - viewModel.markTouched() - }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_field_topics)) }, - placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) - Spacer(Modifier.height(8.dp)) - GroupLocationField(viewModel) + OutlinedTextField( + value = viewModel.topics.value, + onValueChange = { + viewModel.topics.value = it + viewModel.markTouched() + }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_field_topics)) }, + placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + Spacer(Modifier.height(8.dp)) + GroupLocationField(viewModel) + } Spacer(Modifier.height(12.dp)) Text( @@ -390,14 +399,33 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { color = MaterialTheme.colorScheme.primary, ) + // Buzz's `visibility`: open = searchable and anyone may join, private = hidden and invite-only. + // One switch covers both, so it keeps NIP-29's private flag but says what Buzz actually does. LabeledSwitchRow( - label = stringRes(R.string.relay_group_flag_private), - description = stringRes(R.string.relay_group_flag_private_desc), + label = stringRes(if (viewModel.isBuzzRelay) R.string.buzz_channel_flag_private else R.string.relay_group_flag_private), + description = stringRes(if (viewModel.isBuzzRelay) R.string.buzz_channel_flag_private_desc else R.string.relay_group_flag_private_desc), checked = viewModel.isPrivate, ) { viewModel.isPrivate = it viewModel.markTouched() } + + if (viewModel.isBuzzRelay) { + // Buzz's `channel_type`. Only offered on create: the relay takes it on the 9007 and its + // 9002 handler has no `channel_type` key, so an existing channel cannot be converted. + if (viewModel.isNewGroup) { + LabeledSwitchRow( + label = stringRes(R.string.buzz_channel_flag_forum), + description = stringRes(R.string.buzz_channel_flag_forum_desc), + checked = viewModel.isForum, + ) { + viewModel.isForum = it + viewModel.markTouched() + } + } + return + } + LabeledSwitchRow( label = stringRes(R.string.relay_group_flag_invite_only), description = stringRes(R.string.relay_group_flag_invite_only_desc), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index db8e535d49..f4519f4bcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException @@ -43,6 +44,9 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM +import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -65,9 +69,26 @@ class RelayGroupMetadataViewModel : ViewModel() { private var channel: RelayGroupChannel? = null val isNewGroup by derivedStateOf { channel == null } - /** Host relay (create + edit) and the group id (generated in create mode). */ - var relay: NormalizedRelayUrl? = null + /** + * Host relay (create + edit) and the group id (generated in create mode). + * + * Snapshot state rather than a plain var: it is assigned by initCreate/initEdit *after* the + * first composition and [isBuzzRelay] derives from it, so a plain var would leave the screen + * rendering its NIP-29 shape forever. + */ + var relay: NormalizedRelayUrl? by mutableStateOf(null) private set + + /** + * True when the target relay speaks the Buzz dialect. Buzz calls these **channels**, and honours + * only a subset of NIP-29's metadata: `name`, `about` and a two-valued `visibility`. Its create + * path also takes a `channel_type`, which is what [isForum] selects. + */ + val isBuzzRelay by derivedStateOf { relay?.let { BuzzRelayDialect.isBuzz(it) } == true } + + /** Buzz only: create a `forum` channel (threaded posts) instead of a `stream` (chat) one. */ + var isForum by mutableStateOf(false) + var groupId: String = "" private set @@ -120,8 +141,9 @@ class RelayGroupMetadataViewModel : ViewModel() { this.account = accountViewModel.account if (this.relay == null) { this.relay = relay - // Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada). - this.groupId = RandomInstance.bytes(8).toHexKey() + // Random NIP-29 group id: 8 secure bytes, hex-encoded (matches Armada) — except on a + // Buzz relay, which keys channels by UUID and ignores an id it cannot parse as one. + this.groupId = if (BuzzRelayDialect.isBuzz(relay)) newBuzzChannelId() else RandomInstance.bytes(8).toHexKey() } } @@ -245,6 +267,7 @@ class RelayGroupMetadataViewModel : ViewModel() { hashtags = hashtags, geohashes = geohashes, parent = parentGroupId, + channelType = if (isBuzzRelay) (if (isForum) BUZZ_CHANNEL_TYPE_FORUM else BUZZ_CHANNEL_TYPE_STREAM) else null, ) } else { account.editRelayGroupMetadata( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0253dfbb19..2e10ffed3a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2541,6 +2541,12 @@ Ephemeral chats Lightweight, relay-scoped rooms that don\'t persist history. Create a group + + New channel + Private channel + Hidden from the channel list and invite-only. Off means anyone on this relay can find and join it. + Forum channel + Threaded posts instead of a chat timeline. This cannot be changed later. This relay doesn\'t advertise support for NIP-29 relay groups. A group created here won\'t work — its name, members and messages won\'t be managed by the relay. Pick a relay that supports relay-based groups. Group name Topic (optional) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt index 2ebfc6c31c..06b61a62ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.quartz.buzz.workspace import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.utils.RandomInstance /* * Buzz-specific readers over a NIP-29 group's relay-signed metadata (kind:39000). @@ -51,3 +53,30 @@ fun GroupMetadataEvent.buzzParticipants(): List = tags.mapNotNull(PTag:: const val BUZZ_CHANNEL_TYPE_DM = "dm" const val BUZZ_CHANNEL_TYPE_FORUM = "forum" const val BUZZ_CHANNEL_TYPE_STREAM = "stream" + +/** + * Buzz's two channel visibilities, from `crates/buzz-db/src/channel.rs`: [BUZZ_VISIBILITY_OPEN] is + * searchable and anyone may join, [BUZZ_VISIBILITY_PRIVATE] is hidden and invite-only. They ride a + * `visibility` tag on the create (9007) and metadata (9002) events — NIP-29's own `private` status + * flag is a separate vocabulary the Buzz relay does not read. + */ +const val BUZZ_VISIBILITY_OPEN = "open" +const val BUZZ_VISIBILITY_PRIVATE = "private" + +/** + * A new Buzz channel id: a RFC-4122 v4 UUID string. + * + * Buzz keys channels by UUID and parses the create event's `h` tag with `val.parse::()` + * (`extract_h_tag_channel`). NIP-29's own convention — a short random hex id — does not parse, so + * the relay silently ignores the id the client chose and creates the channel under one of its own. + * The client then subscribes to an id the relay never used: the new channel shows an empty feed and + * never gets a name. Group ids are opaque strings in NIP-29, so a UUID is valid there too. + */ +fun newBuzzChannelId(): String { + val bytes = RandomInstance.bytes(16) + // v4, RFC-4122 variant. + bytes[6] = ((bytes[6].toInt() and 0x0F) or 0x40).toByte() + bytes[8] = ((bytes[8].toInt() and 0x3F) or 0x80).toByte() + val hex = bytes.toHexKey() + return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt index 0dbed7662e..20bc7e50eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/CreateGroupEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @@ -38,15 +39,39 @@ class CreateGroupEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun groupId() = tags.groupId() + fun name() = tags.firstTagValue("name") + companion object { const val KIND = 9007 + /** + * NIP-29 create-group. The spec carries only the group id here and leaves the metadata to a + * following kind-9002, which is what plain relay29 expects. + * + * Buzz is stricter: `ingest.rs` rejects a 9007 **before storage** with + * `invalid: channel name is required` unless the create event itself carries a `name` tag, + * and reads `about` / `visibility` / `channel_type` off the same event. A create without + * them is dropped outright, so the 9002 that follows addresses a channel that was never + * made — the group simply never appears. + * + * Sending the metadata on both events satisfies both: relay29 ignores the extra tags and + * takes the 9002, Buzz takes the 9007. [visibility] (`open` / `private`) and [channelType] + * (`stream` / `forum` / …) are Buzz's vocabulary and are omitted unless given. + */ fun build( groupId: String, + name: String? = null, + about: String? = null, + visibility: String? = null, + channelType: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { groupId(groupId) + name?.takeIf { it.isNotBlank() }?.let { add(arrayOf("name", it)) } + about?.takeIf { it.isNotBlank() }?.let { add(arrayOf("about", it)) } + visibility?.let { add(arrayOf("visibility", it)) } + channelType?.let { add(arrayOf("channel_type", it)) } initializer() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelCreateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelCreateTest.kt new file mode 100644 index 0000000000..8593da9d7a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelCreateTest.kt @@ -0,0 +1,95 @@ +/* + * 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.quartz.buzz.workspace + +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The two things a Buzz relay requires of a create (kind-9007) that plain NIP-29 does not. Both + * failed silently — the relay either rejected the event outright or created the channel under an id + * the client never used — so "create a group" published two events and produced nothing the user + * could see. + */ +class BuzzChannelCreateTest { + @Test + fun createCarriesTheMetadataBuzzReadsOffTheCreateEvent() { + // `ingest.rs` rejects a 9007 pre-storage with "invalid: channel name is required" unless the + // create event itself names the channel; NIP-29 alone would leave this to the 9002. + val tpl = + CreateGroupEvent.build( + groupId = "3f2504e0-4f89-41d3-9a0c-0305e82c3301", + name = "design", + about = "where design happens", + visibility = BUZZ_VISIBILITY_PRIVATE, + channelType = BUZZ_CHANNEL_TYPE_FORUM, + ) + + assertEquals(CreateGroupEvent.KIND, tpl.kind) + assertEquals("design", tpl.tags.firstTagValue("name")) + assertEquals("where design happens", tpl.tags.firstTagValue("about")) + assertEquals("private", tpl.tags.firstTagValue("visibility")) + assertEquals("forum", tpl.tags.firstTagValue("channel_type")) + assertEquals("3f2504e0-4f89-41d3-9a0c-0305e82c3301", tpl.tags.firstTagValue("h")) + } + + /** A plain NIP-29 create stays exactly as the spec has it: the id and nothing else. */ + @Test + fun createWithoutBuzzMetadataIsUnchanged() { + val tpl = CreateGroupEvent.build(groupId = "abc123") + + assertEquals("abc123", tpl.tags.firstTagValue("h")) + assertNull(tpl.tags.firstTagValue("name")) + assertNull(tpl.tags.firstTagValue("visibility")) + assertNull(tpl.tags.firstTagValue("channel_type")) + } + + /** Blank input is omitted rather than sent as an empty tag, which the relay rejects the same way. */ + @Test + fun blankMetadataIsOmitted() { + val tpl = CreateGroupEvent.build(groupId = "abc123", name = " ", about = "") + + assertNull(tpl.tags.firstTagValue("name")) + assertNull(tpl.tags.firstTagValue("about")) + } + + /** + * Buzz keys channels by UUID and parses the `h` tag with `val.parse::()`. A NIP-29-style + * 16-char hex id does not parse, so the relay ignores the client's id and creates the channel + * under one of its own — leaving the app subscribed to an id that does not exist, which is what + * made a freshly created channel open on an empty feed with a hex id for a title. + */ + @Test + fun newChannelIdIsAParseableV4Uuid() { + val id = newBuzzChannelId() + + assertEquals(36, id.length) + assertEquals(listOf(8, 4, 4, 4, 12), id.split("-").map { it.length }) + assertTrue(id.all { it.isDigit() || it in 'a'..'f' || it == '-' }, "lowercase hex + dashes only: $id") + assertEquals('4', id[14], "version nibble must say v4") + assertTrue(id[19] in "89ab", "variant nibble must be RFC-4122: ${id[19]}") + assertTrue(newBuzzChannelId() != newBuzzChannelId(), "ids must not repeat") + } +} From 98bda49abbd057689742c0b28d21bc4cafe5c916 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Jul 2026 10:30:51 -0400 Subject: [PATCH 2/6] refactor(buzz): give "add people" the app's ordinary user search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding someone to a workspace or channel offered a square button and a dialog whose own hint told you to paste a hex key. Both are now what the rest of the app does. The dialog searched **only** LocalCache, so anyone this device had never seen simply had no result — pasting a raw key was the only way through, which is why the field said "Add someone (npub or hex)". It now runs on UserSuggestionState, the same engine behind the @-mention typeahead: local cache, relay search (NIP-50) and NIP-05 resolution, plus a pasted npub/nprofile. The hint asks for a name; results show avatar, display name and a verified NIP-05 address. The members-screen button was the app's only FloatingActionButton without `shape = CircleShape`, so it rendered as Material3's rounded square next to circular FABs everywhere else. Two shared components needed to bend for this, both additive: - SlimListItem took a `colors` parameter and then painted its container with a hardcoded `MaterialTheme.colorScheme.background` regardless — so a row asked to be transparent still drew an opaque block. It honours `containerColor` now, defaulting to the same `background` it always painted, so every existing caller is byte-identical. - ShowUserSuggestionList's row colours, dividers and top padding are parameters. Their defaults are the dropdown's existing look — opaque rows and a divider each, which is what separates it from a composer it floats over. Inside a dialog that chrome reads as a black box with a gap above it, so this one caller passes transparent rows, no dividers and no padding. Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: searching "cloudfodder" returns relay results with verified NIP-05s; pasting an npub resolves to the person; adding them to a channel published the kind-9000, the relay narrated "Vitor was added by Vitor Pamplona", the member count went 1 → 2, and that member then posted from Buzz and the message rendered here. The mention dropdown is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/layouts/listItem/SlimListItemLayout.kt | 8 +- .../userSuggestions/ShowUserSuggestionList.kt | 31 +++- .../loggedIn/buzz/BuzzAddPeopleDialog.kt | 161 +++++++++--------- .../relayGroup/RelayGroupChannelListScreen.kt | 1 - .../relayGroup/RelayGroupMembersScreen.kt | 3 +- amethyst/src/main/res/values/strings.xml | 2 + 6 files changed, 112 insertions(+), 94 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt index d6d8925c93..269095c8c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -172,7 +172,11 @@ fun SlimListItem( supportingContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + // The container default stays `background` — what this layout has always painted — rather than + // ListItemDefaults' `surface`, so existing callers are unchanged. It is a parameter now because + // the row was painting its own opaque background even when the caller asked for another colour: + // inside a container that already has a surface (a dialog) that reads as a black block. + colors: ListItemColors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background), tonalElevation: Dp = ListItemContainerElevation, shadowElevation: Dp = ListItemContainerElevation, ) { @@ -232,7 +236,7 @@ fun SlimListItem( Surface( modifier = Modifier.semantics(mergeDescendants = true) {}.then(modifier), shape = ListItemDefaults.shape, - color = MaterialTheme.colorScheme.background, + color = colors.containerColor, contentColor = MaterialTheme.colorScheme.onBackground, tonalElevation = tonalElevation, shadowElevation = shadowElevation, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index dc8a448dce..ccd20a5bf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -32,6 +32,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -65,6 +67,9 @@ import com.vitorpamplona.amethyst.ui.theme.nip05 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +/** The dropdown's breathing room under the composer it floats over. */ +private val SuggestionListPadding = PaddingValues(top = 10.dp) + @Composable fun ShowUserSuggestionList( userSuggestions: UserSuggestionState, @@ -73,6 +78,13 @@ fun ShowUserSuggestionList( modifier: Modifier = Modifier, onEmpty: @Composable () -> Unit = {}, trailingContent: (@Composable (User) -> Unit)? = null, + // Defaults suit this list's usual home: a dropdown floating over a composer, where an opaque + // row and a divider per entry are what separate it from the text underneath. Inside a container + // that already provides its own surface — a dialog — that chrome reads as a black box bolted on, + // so those callers pass a transparent row and drop the dividers. + itemColors: ListItemColors = ListItemDefaults.colors(), + showDividers: Boolean = true, + contentPadding: PaddingValues = SuggestionListPadding, ) { UserSearchDataSourceSubscription(userSuggestions, accountViewModel) @@ -93,7 +105,7 @@ fun ShowUserSuggestionList( } } - WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty, trailingContent) + WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty, trailingContent, itemColors, showDividers, contentPadding) } @Composable @@ -117,6 +129,9 @@ fun WatchResponses( modifier: Modifier = Modifier, onEmpty: @Composable () -> Unit = {}, trailingContent: (@Composable (User) -> Unit)? = null, + itemColors: ListItemColors = ListItemDefaults.colors(), + showDividers: Boolean = true, + contentPadding: PaddingValues = SuggestionListPadding, ) { val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) @@ -125,7 +140,7 @@ fun WatchResponses( val priority = remember(suggestions) { userSuggestions.priorityPubkeys() } LazyColumn( - contentPadding = PaddingValues(top = 10.dp), + contentPadding = contentPadding, modifier = modifier, state = listState, ) { @@ -137,10 +152,12 @@ fun WatchResponses( } else { null } - UserLine(item, accountViewModel, trailing) { onSelect(item) } - HorizontalDivider( - thickness = DividerThickness, - ) + UserLine(item, accountViewModel, trailing, itemColors) { onSelect(item) } + if (showDividers) { + HorizontalDivider( + thickness = DividerThickness, + ) + } } } } else { @@ -169,9 +186,11 @@ fun UserLine( baseUser: User, accountViewModel: AccountViewModel, trailingContent: (@Composable (User) -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), onClick: () -> Unit, ) { SlimListItem( + colors = colors, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), leadingContent = { ClickableUserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, onClick = null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt index c07df3d3d6..bcaf03c8d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt @@ -20,80 +20,71 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.UserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import androidx.compose.runtime.LaunchedEffect as ComposeLaunchedEffect /** - * A reusable "add a person" dialog: a typeahead over the local user cache (name / NIP-05 / npub - * prefix, or a pasted npub/hex). Tapping a result that isn't already in the target invokes [onAdd]; - * members already present are shown with an "Added" hint and aren't tappable. + * A reusable "add a person" dialog for Buzz: the app's ordinary user search — the same + * [UserSuggestionState] engine the @-mention typeahead uses — over the local cache, the relays + * (NIP-50) and NIP-05 identifiers, plus a pasted npub/nprofile. + * + * It used to search only [com.vitorpamplona.amethyst.model.LocalCache], so anyone the device had + * never seen simply had no result and the only way through was to paste a raw hex key — which is + * what the field's own hint told you to do. Searching the relays is what makes finding a person by + * name work at all here. * * Context-agnostic — the caller supplies [isAlreadyIn] (membership predicate) and [onAdd] (the * actual add, e.g. a channel kind-9000 put-user or a community kind-9030 admin-add). Used by both - * the channel members screen and the Buzz community view. + * the channel members screen and the Buzz community view. Members already present render an "Added" + * hint instead of the add affordance and do nothing when tapped. */ @Composable fun BuzzAddPeopleDialog( title: String, accountViewModel: AccountViewModel, - nav: INav, isAlreadyIn: (HexKey) -> Boolean, onAdd: (HexKey) -> Unit, onDismiss: () -> Unit, ) { var query by remember { mutableStateOf("") } - var results by remember { mutableStateOf>(emptyList()) } - - LaunchedEffect(query) { - if (query.isBlank()) { - results = emptyList() - return@LaunchedEffect + val userSuggestions = + remember(accountViewModel) { + UserSuggestionState(accountViewModel.account, Amethyst.instance.nip05Client) } - delay(150) - results = - withContext(Dispatchers.IO) { - LocalCache - .findUsersStartingWith(query.trim(), accountViewModel.account) - .map { it.pubkeyHex } - .take(15) - } - } + val focusRequester = remember { FocusRequester() } + + ComposeLaunchedEffect(query) { userSuggestions.processCurrentWord(query) } + ComposeLaunchedEffect(Unit) { focusRequester.requestFocus() } AlertDialog( onDismissRequest = onDismiss, @@ -103,21 +94,61 @@ fun BuzzAddPeopleDialog( OutlinedTextField( value = query, onValueChange = { query = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), singleLine = true, - leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) }, - label = { Text(stringRes(R.string.buzz_dm_add_hint)) }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + label = { Text(stringRes(R.string.buzz_add_people_hint)) }, ) - LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - items(results, key = { it }) { hex -> - val alreadyIn = isAlreadyIn(hex) - AddPersonRow(hex, alreadyIn, accountViewModel, nav) { - if (!alreadyIn) { - onAdd(hex) + + // The typeahead needs a couple of characters before a relay search is worth firing; + // below that the list would flash every match in the cache. + if (query.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (!isAlreadyIn(user.pubkeyHex)) { + onAdd(user.pubkeyHex) onDismiss() } - } - } + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + // The dialog already supplies the surface and the spacing: drop the + // dropdown's opaque rows, per-row dividers and top gap, which are there for + // floating over a composer. + itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), + showDividers = false, + contentPadding = PaddingValues(0.dp), + onEmpty = { + Text( + text = stringRes(R.string.buzz_add_people_empty), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = { user -> + if (isAlreadyIn(user.pubkeyHex)) { + Text( + text = stringRes(R.string.buzz_import_added), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(R.string.relay_group_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) } } }, @@ -125,39 +156,3 @@ fun BuzzAddPeopleDialog( dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } }, ) } - -@Composable -private fun AddPersonRow( - hex: HexKey, - alreadyIn: Boolean, - accountViewModel: AccountViewModel, - nav: INav, - onClick: () -> Unit, -) { - val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(enabled = !alreadyIn, onClick = onClick) - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - UserPicture(hex, Size35dp, accountViewModel = accountViewModel, nav = nav) - Column(Modifier.weight(1f)) { - if (user != null) { - UsernameDisplay(user, accountViewModel = accountViewModel) - } else { - Text(hex.take(8), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - if (alreadyIn) { - Text( - text = stringRes(R.string.buzz_import_added), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index a7a8671434..fed4acca93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -488,7 +488,6 @@ fun RelayGroupChannelListScreen( BuzzAddPeopleDialog( title = stringRes(R.string.buzz_community_add_people), accountViewModel = accountViewModel, - nav = nav, isAlreadyIn = { BuzzCommunityMembership.isMember(relay, it) }, onAdd = { accountViewModel.addCommunityMember(relay, it) }, onDismiss = { showAddPeople = false }, 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 175b2e71e8..e73909607d 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 @@ -193,7 +193,7 @@ private fun RelayGroupMembers( // Only a moderator can add a member (the relay rejects a kind-9000 from anyone else). floatingActionButton = { if (iCanModerate) { - FloatingActionButton(onClick = { showAddMember = true }) { + FloatingActionButton(onClick = { showAddMember = true }, shape = CircleShape) { Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(R.string.relay_group_add_member)) } } @@ -229,7 +229,6 @@ private fun RelayGroupMembers( BuzzAddPeopleDialog( title = stringRes(R.string.relay_group_add_member), accountViewModel = accountViewModel, - nav = nav, isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE }, onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) }, onDismiss = { showAddMember = false }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2e10ffed3a..d0f92a98bf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3539,6 +3539,8 @@ Workspace To Add someone (npub or hex) + Search by name, NIP-05 or npub + No one found. Try a different name, a NIP-05 address, or paste an npub. Start conversation Opening… Remove From ceb9f9c3db0a1410634ad1420ac9054156d43779 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Jul 2026 11:14:29 -0400 Subject: [PATCH 3/6] feat(buzz): dock the member search at the bottom, and fix promotions on Buzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Adding people.** The Members screen hid its search behind a FAB, so adding a handful of people was "open dialog, search, pick, dialog closes, reopen" per person. The field now lives at the bottom of the screen with its results rising above it, like a chat composer: each pick lands in the roster above and clears the query while the keyboard stays up. It clears the gesture bar and rides above the IME, so the field you type into is not the part that gets covered. **Promotions did nothing.** "Make moderator" published a kind-9000 and changed nothing, on either client. Two reasons: - NIP-29 carries roles inside the `p` tag; Buzz reads a top-level `role` tag (`extract_tag_value(event, "role")`) and defaults to `member` without it. So every promotion re-added the target as a plain member. PutUserEvent can now carry that tag and Account maps our role onto Buzz's vocabulary before sending. - That vocabulary is `owner`/`admin`/`member`/`guest`/`bot` — there is **no moderator**, and a role the relay cannot parse fails the whole put-user. So the action is hidden on Buzz rather than offered and silently dropped. **The owner could not promote anyone.** membershipOf only mapped the literal `admin` to ADMIN, but a Buzz channel's creator carries `owner` — leaving the one person with full authority ranked below it, so "Make admin" never appeared. Both role strings now mean ADMIN. **The 3-dot button moved when tapped.** An expanded DropdownMenu still emits a node into its parent, and it sat as a direct child of a `spacedBy(12.dp)` Row — so opening the menu added a second gap and shoved the button sideways. Button and menu now share a Box. ConcordMembersScreen had the identical bug and is fixed too; GitBrowseUi looks like a third instance and is left alone as unrelated territory. Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: promoting the added member published the 9000, the relay narrated it, and after the roster refreshed the member carries an `admin` badge. The 3-dot sits at the same pixel column whether the menu is open or closed. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 17 +- .../concord/ConcordMembersScreen.kt | 112 ++++---- .../relayGroup/RelayGroupMembersScreen.kt | 251 ++++++++++++------ .../nip29RelayGroups/RelayGroupChannel.kt | 2 +- .../nip29RelayGroups/RelayGroupMembership.kt | 10 + .../buzz/workspace/BuzzChannelMetadata.kt | 11 + .../moderation/PutUserEvent.kt | 13 + 7 files changed, 279 insertions(+), 137 deletions(-) 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 f1b070ce93..fa940f597b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListS import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache @@ -168,6 +169,8 @@ import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent import com.vitorpamplona.quartz.buzz.threading.buzzThread import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN +import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -3428,7 +3431,19 @@ class Account( pubkey: HexKey, roles: List, ) { - val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles)) + // Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its + // own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails + // the whole put-user, which is why an unmapped role must become `member` rather than travel. + val buzzRole = + if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) { + when { + roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN + else -> BUZZ_ROLE_MEMBER + } + } else { + null + } + val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index d2a7b390be..57b146653b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -290,62 +290,66 @@ private fun ConcordMemberRow( MemberBadge(entry.membership, entry.roleName) if (hasMenu) { var expanded by remember { mutableStateOf(false) } - IconButton(onClick = { expanded = true }) { - SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - if (canToggleAdmin) { - DropdownMenuItem( - text = { Text(stringRes(if (isAdmin) R.string.concord_members_remove_admin else R.string.concord_members_make_admin)) }, - onClick = { - accountViewModel.setConcordAdmin(communityId, entry.pubkey, makeAdmin = !isAdmin) - expanded = false - }, - ) + // One Box for button + menu: an expanded DropdownMenu emits a node, and as a direct child + // of this `spacedBy` Row that adds a gap and shifts the button as you tap it. + Box { + IconButton(onClick = { expanded = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) } - if (viewerCanManageRoles) { - DropdownMenuItem( - text = { - Column { - Text(stringRes(R.string.concord_members_roles)) - rolesBlockedReason?.let { - Text( - it, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (canToggleAdmin) { + DropdownMenuItem( + text = { Text(stringRes(if (isAdmin) R.string.concord_members_remove_admin else R.string.concord_members_make_admin)) }, + onClick = { + accountViewModel.setConcordAdmin(communityId, entry.pubkey, makeAdmin = !isAdmin) + expanded = false + }, + ) + } + if (viewerCanManageRoles) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.concord_members_roles)) + rolesBlockedReason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - } - }, - enabled = rolesBlockedReason == null, - onClick = { - editRoles = true - expanded = false - }, - ) - } - if (canBan) { - DropdownMenuItem( - text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) }, - onClick = { - accountViewModel.setConcordBan(communityId, entry.pubkey, ban = !isBanned) - expanded = false - }, - ) - } - if (canRemove) { - DropdownMenuItem( - text = { - Text( - stringRes(R.string.concord_members_remove), - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - confirmRemove = true - expanded = false - }, - ) + }, + enabled = rolesBlockedReason == null, + onClick = { + editRoles = true + expanded = false + }, + ) + } + if (canBan) { + DropdownMenuItem( + text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) }, + onClick = { + accountViewModel.setConcordBan(communityId, entry.pubkey, ban = !isBanned) + expanded = false + }, + ) + } + if (canRemove) { + DropdownMenuItem( + text = { + Text( + stringRes(R.string.concord_members_remove), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + confirmRemove = true + expanded = false + }, + ) + } } } } 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 e73909607d..7c2101d041 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 @@ -25,9 +25,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -38,10 +41,10 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -60,6 +63,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -73,12 +77,14 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.BuzzAddPeopleDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz.PresenceDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription 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.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.subscribeAsFlow @@ -165,8 +171,6 @@ private fun RelayGroupMembers( .sortedBy { it.membership.rank() } } - var showAddMember by remember { mutableStateOf(false) } - Scaffold( topBar = { TopBarExtensibleWithBackButton( @@ -191,11 +195,17 @@ private fun RelayGroupMembers( ) }, // Only a moderator can add a member (the relay rejects a kind-9000 from anyone else). - floatingActionButton = { + // The search sits at the bottom of the screen rather than behind a button: adding people is + // usually adding *several*, and a dialog made that "open, search, pick, dialog closes, + // reopen" per person. Inline, each pick lands in the roster above while the field keeps + // focus for the next name. + bottomBar = { if (iCanModerate) { - FloatingActionButton(onClick = { showAddMember = true }, shape = CircleShape) { - Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(R.string.relay_group_add_member)) - } + AddMemberBar( + isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE }, + onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) }, + accountViewModel = accountViewModel, + ) } }, ) { padding -> @@ -224,14 +234,83 @@ private fun RelayGroupMembers( } } } +} - if (showAddMember) { - BuzzAddPeopleDialog( - title = stringRes(R.string.relay_group_add_member), - accountViewModel = accountViewModel, - isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE }, - onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) }, - onDismiss = { showAddMember = false }, +/** + * The always-present "add a member" search docked at the bottom of the roster: the app's ordinary + * user typeahead (local cache + relay + NIP-05 + a pasted npub), with its results rising above the + * field the way a chat composer's suggestions do. + * + * Picking someone adds them and clears the query but keeps the keyboard, so a moderator can add a + * handful of people in one pass. Someone already in the group shows an "Added" hint instead of the + * add icon and does nothing when tapped — the relay would reject the duplicate anyway. + */ +@Composable +private fun AddMemberBar( + isAlreadyIn: (HexKey) -> Boolean, + onAdd: (HexKey) -> Unit, + accountViewModel: AccountViewModel, +) { + var query by remember { mutableStateOf("") } + val userSuggestions = + remember(accountViewModel) { + UserSuggestionState(accountViewModel.account, Amethyst.instance.nip05Client) + } + + LaunchedEffect(query) { userSuggestions.processCurrentWord(query) } + + // Docked at the bottom, so it has to clear the gesture bar and ride above the keyboard — + // otherwise the field it is meant to be typed into is the part that gets covered. + Column( + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding(), + ) { + if (query.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (!isAlreadyIn(user.pubkeyHex)) { + onAdd(user.pubkeyHex) + query = "" + } + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + contentPadding = PaddingValues(0.dp), + trailingContent = { user -> + if (isAlreadyIn(user.pubkeyHex)) { + Text( + text = stringRes(R.string.buzz_import_added), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(R.string.relay_group_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + singleLine = true, + leadingIcon = { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + label = { Text(stringRes(R.string.buzz_add_people_hint)) }, ) } } @@ -254,6 +333,8 @@ private fun RelayGroupMemberRow( accountViewModel: AccountViewModel, nav: INav, ) { + val isBuzzRelay = remember(channel.groupId.relayUrl) { BuzzRelayDialect.isBuzz(channel.groupId.relayUrl) } + // Create-or-get (never a one-shot null): UsernameDisplay observes the user's // metadata flow, so the name fills in when the kind:0 arrives instead of being // stuck on truncated hex forever. @@ -300,88 +381,96 @@ private fun RelayGroupMemberRow( (viewerIsAdmin || entry.membership != RelayGroupMembership.ADMIN) if (canActOnTarget) { - IconButton(onClick = { menuOpen = true }) { - Icon( - symbol = MaterialSymbols.MoreVert, - contentDescription = stringRes(R.string.more_options), - modifier = Modifier.size(20.dp), - ) - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - val declaredRoles = channel.supportedRoles - if (declaredRoles.isNotEmpty()) { - // The relay declares its own role set (kind 39003) — offer exactly those - // instead of the built-in admin/moderator pair. Roles are privilege grants, - // so only admins assign them; the relay is the final authority. - if (viewerIsAdmin) { - declaredRoles.forEach { role -> - val alreadyHasRole = entry.roles.any { it.equals(role.name, true) } - if (!alreadyHasRole) { - DropdownMenuItem( - text = { - Column { - Text(stringRes(R.string.relay_group_assign_role, role.name)) - role.description?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Button and menu share one Box: an expanded DropdownMenu still emits a node into its + // parent, so as a direct child of this `spacedBy` Row it added a second 12.dp gap and + // visibly nudged the button sideways the moment you tapped it. + Box { + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + val declaredRoles = channel.supportedRoles + if (declaredRoles.isNotEmpty()) { + // The relay declares its own role set (kind 39003) — offer exactly those + // instead of the built-in admin/moderator pair. Roles are privilege grants, + // so only admins assign them; the relay is the final authority. + if (viewerIsAdmin) { + declaredRoles.forEach { role -> + val alreadyHasRole = entry.roles.any { it.equals(role.name, true) } + if (!alreadyHasRole) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.relay_group_assign_role, role.name)) + role.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - } - }, - onClick = { - menuOpen = false - // Additive: NIP-29 allows multiple roles per member and the menu only - // offers roles they lack, so keep the ones they already hold. - accountViewModel.putRelayGroupUser(channel, entry.pubkey, entry.roles + role.name) - }, - ) + }, + onClick = { + menuOpen = false + // Additive: NIP-29 allows multiple roles per member and the menu only + // offers roles they lack, so keep the ones they already hold. + accountViewModel.putRelayGroupUser(channel, entry.pubkey, entry.roles + role.name) + }, + ) + } } } + } else { + // No 39003 role set advertised: fall back to the built-in admin/moderator shortcuts. + if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_admin)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) + }, + ) + } + // Buzz's roles are owner/admin/member/guest/bot — there is no moderator, and a + // role it cannot parse fails the whole put-user. Offering it there is offering a + // menu item that cannot do anything. + if (!isBuzzRelay && entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { + DropdownMenuItem( + text = { Text(stringRes(R.string.relay_group_make_moderator)) }, + onClick = { + menuOpen = false + accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) + }, + ) + } } - } else { - // No 39003 role set advertised: fall back to the built-in admin/moderator shortcuts. - if (viewerIsAdmin && entry.membership != RelayGroupMembership.ADMIN) { + if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) { DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_make_admin)) }, + text = { Text(stringRes(R.string.relay_group_demote_member)) }, onClick = { menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_ADMIN)) + accountViewModel.putRelayGroupUser(channel, entry.pubkey, emptyList()) }, ) } - if (entry.membership != RelayGroupMembership.MODERATOR && entry.membership != RelayGroupMembership.ADMIN) { - DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_make_moderator)) }, - onClick = { - menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, listOf(RelayGroupMembership.ROLE_MODERATOR)) - }, - ) - } - } - if (entry.membership == RelayGroupMembership.MODERATOR || entry.membership == RelayGroupMembership.ADMIN) { DropdownMenuItem( - text = { Text(stringRes(R.string.relay_group_demote_member)) }, + text = { + Text( + text = stringRes(R.string.relay_group_remove_user), + color = MaterialTheme.colorScheme.error, + ) + }, onClick = { menuOpen = false - accountViewModel.putRelayGroupUser(channel, entry.pubkey, emptyList()) + confirmRemove = true }, ) } - DropdownMenuItem( - text = { - Text( - text = stringRes(R.string.relay_group_remove_user), - color = MaterialTheme.colorScheme.error, - ) - }, - onClick = { - menuOpen = false - confirmRemove = true - }, - ) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 8b72c3d6f6..11c5bb23bd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -301,7 +301,7 @@ class RelayGroupChannel( val admin = admins.firstOrNull { it.pubKey == pubkey } if (admin != null) { return when { - admin.roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> RelayGroupMembership.ADMIN + admin.roles.any { role -> RelayGroupMembership.ADMIN_ROLES.any { role.equals(it, true) } } -> RelayGroupMembership.ADMIN // Presence in the kind-39001 admins list IS the moderation signal; // the role labels (moderator, ceo, owner, …) are relay-defined. So // anyone in that list who isn't the top-level admin is at least a diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt index 8a4e55ca33..aeeedc4c36 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupMembership.kt @@ -56,5 +56,15 @@ enum class RelayGroupMembership { companion object { const val ROLE_ADMIN = "admin" const val ROLE_MODERATOR = "moderator" + + /** + * Buzz's top role. Its hierarchy is `owner` > `admin` > `member` (no moderator), so the + * channel's creator carries `owner` and never the literal `admin` — which used to leave the + * one person with full authority classified below it, unable to promote anyone. + */ + const val ROLE_OWNER = "owner" + + /** Role strings that mean full authority over the group, across both dialects. */ + val ADMIN_ROLES = listOf(ROLE_ADMIN, ROLE_OWNER) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt index 06b61a62ed..676ba91902 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/workspace/BuzzChannelMetadata.kt @@ -80,3 +80,14 @@ fun newBuzzChannelId(): String { val hex = bytes.toHexKey() return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}" } + +/** + * Buzz's channel member roles, from `crates/buzz-core/src/channel.rs`. Note there is **no + * moderator**: a role string outside this set fails the relay's put-user handler outright + * (`invalid role: …`), taking the membership change with it. + */ +const val BUZZ_ROLE_OWNER = "owner" +const val BUZZ_ROLE_ADMIN = "admin" +const val BUZZ_ROLE_MEMBER = "member" +const val BUZZ_ROLE_GUEST = "guest" +const val BUZZ_ROLE_BOT = "bot" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt index b6b9e48b9f..d2f9382413 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/PutUserEvent.kt @@ -45,10 +45,22 @@ class PutUserEvent( companion object { const val KIND = 9000 + /** + * NIP-29 put-user. The roles ride inside each `p` tag (`["p", pubkey, role, …]`), which is + * what relay29 reads. + * + * [buzzRole] additionally emits a top-level `["role", …]` tag. Buzz reads **only** that — + * `extract_tag_value(event, "role")`, defaulting to `member` — so without it every put-user + * lands as a plain member and a promotion silently does nothing. Its vocabulary is also its + * own (`owner`/`admin`/`member`/`guest`/`bot`, no moderator); an unparseable role fails the + * whole handler, so callers map to Buzz's set before passing it here. Harmless on relay29, + * which ignores the extra tag. + */ fun build( groupId: String, pubKeysWithRoles: List>>, previousEvents: List = emptyList(), + buzzRole: String? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { @@ -56,6 +68,7 @@ class PutUserEvent( pubKeysWithRoles.forEach { (pubKey, roles) -> userPubKeyWithRoles(pubKey, roles) } + buzzRole?.let { add(arrayOf("role", it)) } previous(previousEvents) initializer() } From 8bb074c6aebb77589a104cfc081acf5906507d0d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Jul 2026 11:38:50 -0400 Subject: [PATCH 4/6] fix(buzz): refresh the roster after a role change, and clear unread on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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) --- .../vitorpamplona/amethyst/model/Account.kt | 21 ++++++++++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 20 +++++---- .../relayGroup/RelayGroupMembersScreen.kt | 41 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) 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 From aeaacfb1e5a923c943144d27fd6f8c9581cdf41b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Jul 2026 12:09:37 -0400 Subject: [PATCH 5/6] refactor(buzz): make the group-state refresh app-wide, not one screen's job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit hung the refresh off the members screen, so only that screen recovered from a stale roster: a rename, a visibility flip or a join seen from the chat, the channel list or a Messages row stayed wrong until the next cold start. The rest of the app is reactive — relay to LocalCache to screen — and this should be too. Move it into AccountViewModel's always-on event collector: any kind-40099 that lands names its channel, so re-read that group's 39000-39003 into LocalCache and every screen observing the group updates through the flows it already has. One rule, no screen has to know about it. Two things this had to work around: - The event names its channel but not its host, and a note's relay list can still be empty when the bundle fires. LocalCache.relayGroupChannelsWithId resolves the host from the channels already in cache. - Invalidating the standing state subscription does nothing: its filters are unchanged, so no new REQ goes out and no events come back. Measured — the badge did not move. It takes an explicit fetch, which is what Account.refreshRelayGroupState now does by group id. Verified on emulator-5554: removing a role updates the badge on the open members screen with no restart and no screen-local refresh code left in it. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 21 +++++----- .../amethyst/model/LocalCache.kt | 7 ++++ .../ui/screen/loggedIn/AccountViewModel.kt | 26 ++++++++++++ .../relayGroup/RelayGroupMembersScreen.kt | 41 ------------------- 4 files changed, 43 insertions(+), 52 deletions(-) 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 249ab174f4..29f3e7a78e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3424,23 +3424,22 @@ class Account( } /** - * Re-reads a relay group's own state (39000-39003) from its host relay. + * Re-reads one relay group's own state (39000-39003) from its host relay, straight into + * [LocalCache] so every screen observing that group updates through the flows it already has. * - * 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. + * Buzz cannot stream those events: 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. + * Re-issuing the standing REQ doesn't help either: its filters are unchanged, so no new REQ goes + * out. An explicit fetch is the only thing that actually pulls them. */ - suspend fun refreshRelayGroupState(channel: RelayGroupChannel) { - val relay = channel.groupId.relayUrl + suspend fun refreshRelayGroupState(groupId: GroupId) { val filter = Filter( kinds = RELAY_GROUP_METADATA_KINDS, - tags = mapOf("d" to listOf(channel.groupId.id)), + tags = mapOf("d" to listOf(groupId.id)), ) - client.fetchAll(filters = mapOf(relay to listOf(filter)), timeoutMs = 8_000) + client.fetchAll(filters = mapOf(groupId.relayUrl to listOf(filter)), timeoutMs = 8_000) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 9ef7f5122d..b6520324e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -735,6 +735,13 @@ object LocalCache : ILocalCache, ICacheProvider { fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key) + /** + * Every known [GroupId] whose channel id matches [groupId], across relays. A NIP-29 group id is + * only unique per host, so this is normally one entry — it returns a list because the same id + * can legitimately exist on two relays. + */ + fun relayGroupChannelsWithId(groupId: String): List = relayGroupChannels.filter { key, _ -> key.id == groupId }.map { it.groupId } + /** Every relay group we know of that is hosted on [relay] (its channel directory). */ fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List = relayGroupChannels.filter { key, _ -> key.relayUrl == relay } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 070d520811..85cb4cf4e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -111,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -2240,6 +2241,31 @@ class AccountViewModel( } } } + + // A Buzz relay narrates every change to a channel — joins, removals, role changes, renames, + // visibility, archive — as a kind-40099, but it cannot stream the state those changes + // produce: it signs 39000-39003 with `d`/`p` tags and no `h`, then stores and fans them out + // channel-scoped, so a filter carrying `#h` doesn't match their tags while one without `#h` + // is a global subscription, which receives no channel-scoped event. Nothing arrives for the + // cache to be reactive to. + // + // The narration does arrive, so treat it as the relay saying "this channel changed" and + // re-issue the always-on state REQ. Its filters carry `since`, so the relay replays exactly + // the 39000-39003 written since the last EOSE; those land in LocalCache and every screen + // showing that group — roster, top bar, channel list, Messages row — updates through the + // flows it already observes. Central on purpose: the alternative was one screen refreshing + // itself while the rest of the app stayed stale. + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + newNotes + .mapNotNullTo(mutableSetOf()) { (it.event as? SystemMessageEvent)?.channel() } + // The event names its channel but not its host, and the note's relay list can + // still be empty this early — so resolve the host from the channels already in + // cache, which is where the group is being displayed from anyway. + .flatMap { LocalCache.relayGroupChannelsWithId(it) } + .forEach { account.refreshRelayGroupState(it) } + } + } } // --- Marmot Group Messaging --- 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 bbfcb6012d..7c2101d041 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,16 +86,13 @@ 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) @@ -119,37 +116,6 @@ 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, @@ -175,13 +141,6 @@ 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 From 07abf5e537c6cd9bee565f5e7b1b109fe5b3038f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 27 Jul 2026 12:47:06 -0400 Subject: [PATCH 6/6] fix(buzz): stream group state instead of refetching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous two commits refetched a group's 39000-39003 whenever the relay narrated a change, on the premise that Buzz could not stream them at all: it signs them with `d`/`p` tags and no `h`, so an `#h` filter looked unable to match and a filter without `#h` registers as a global subscription, which never receives a channel-scoped event. The first half of that was wrong. `filter_match_one` has an explicit fallback: for an `#h` filter, when the event carries no `h` tag at all, it matches against the stored `channel_id` — and these are stored channel-scoped. So an `#h` filter both indexes the subscription under the channel (which is what makes it eligible for the channel fan-out) and matches the events when they arrive. So subscribe, like the rest of the app does. The per-channel `#h` subscription that already keeps each joined group's chat live now carries its state kinds too, and every screen updates through the flows it already observes. The fetch, the event-bundle hook that triggered it and the cache lookup it needed are all gone. Buzz-only: on a relay29-family relay these events are addressable with no `channel_id` behind them, so an `#h` filter matches nothing there and the `#d` directory filters keep serving them. Verified on emulator-5554 with no fetch code left in the tree: promoting shows the `admin` badge on the open members screen within seconds, and removing the role clears it again. Co-Authored-By: Claude Opus 5 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 20 ------------- .../amethyst/model/LocalCache.kt | 7 ----- .../ui/screen/loggedIn/AccountViewModel.kt | 26 ----------------- .../datasource/RelayGroupFilterBuilders.kt | 29 +++++++++++++++++++ ...RelayGroupJoinedChatTailFilterAssembler.kt | 7 +++++ 5 files changed, 36 insertions(+), 53 deletions(-) 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 29f3e7a78e..fa940f597b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -160,7 +160,6 @@ 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 @@ -3423,25 +3422,6 @@ class Account( signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } } - /** - * Re-reads one relay group's own state (39000-39003) from its host relay, straight into - * [LocalCache] so every screen observing that group updates through the flows it already has. - * - * Buzz cannot stream those events: 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. - * Re-issuing the standing REQ doesn't help either: its filters are unchanged, so no new REQ goes - * out. An explicit fetch is the only thing that actually pulls them. - */ - suspend fun refreshRelayGroupState(groupId: GroupId) { - val filter = - Filter( - kinds = RELAY_GROUP_METADATA_KINDS, - tags = mapOf("d" to listOf(groupId.id)), - ) - client.fetchAll(filters = mapOf(groupId.relayUrl 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/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index b6520324e2..9ef7f5122d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -735,13 +735,6 @@ object LocalCache : ILocalCache, ICacheProvider { fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key) - /** - * Every known [GroupId] whose channel id matches [groupId], across relays. A NIP-29 group id is - * only unique per host, so this is normally one entry — it returns a list because the same id - * can legitimately exist on two relays. - */ - fun relayGroupChannelsWithId(groupId: String): List = relayGroupChannels.filter { key, _ -> key.id == groupId }.map { it.groupId } - /** Every relay group we know of that is hosted on [relay] (its channel directory). */ fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List = relayGroupChannels.filter { key, _ -> key.relayUrl == relay } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 85cb4cf4e2..070d520811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -111,7 +111,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow -import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -2241,31 +2240,6 @@ class AccountViewModel( } } } - - // A Buzz relay narrates every change to a channel — joins, removals, role changes, renames, - // visibility, archive — as a kind-40099, but it cannot stream the state those changes - // produce: it signs 39000-39003 with `d`/`p` tags and no `h`, then stores and fans them out - // channel-scoped, so a filter carrying `#h` doesn't match their tags while one without `#h` - // is a global subscription, which receives no channel-scoped event. Nothing arrives for the - // cache to be reactive to. - // - // The narration does arrive, so treat it as the relay saying "this channel changed" and - // re-issue the always-on state REQ. Its filters carry `since`, so the relay replays exactly - // the 39000-39003 written since the last EOSE; those land in LocalCache and every screen - // showing that group — roster, top bar, channel list, Messages row — updates through the - // flows it already observes. Central on purpose: the alternative was one screen refreshing - // itself while the rest of the app stayed stale. - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - newNotes - .mapNotNullTo(mutableSetOf()) { (it.event as? SystemMessageEvent)?.channel() } - // The event names its channel but not its host, and the note's relay list can - // still be empty this early — so resolve the host from the channels already in - // cache, which is where the group is being displayed from anyway. - .flatMap { LocalCache.relayGroupChannelsWithId(it) } - .forEach { account.refreshRelayGroupState(it) } - } - } } // --- Marmot Group Messaging --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index 3422b2b296..9c1a2b3e15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -96,6 +96,35 @@ val RELAY_GROUP_METADATA_KINDS = */ val RELAY_GROUP_PIN_KINDS = listOf(GroupPinnedEvent.KIND) +/** + * A live, channel-scoped subscription to this group's own relay-signed state (39000-39003) on a Buzz + * relay — the thing that keeps a roster, a name or a visibility flip current without a refetch. + * + * Buzz signs these with `d`/`p` tags and **no `h`**, so a `#h` filter looks like it could not match. + * It does: `filter_match_one` falls back to the stored `channel_id` for an `#h` filter **when the + * event carries no `h` tag at all**, and these are stored channel-scoped. Scoping the filter by `#h` + * is also what indexes the subscription under the channel, which is what makes it eligible for the + * channel fan-out in the first place — a `#d` filter has no channel tag, so on Buzz it registers as a + * global subscription and by design receives no channel-scoped event, which is why these updates + * never arrived live. + * + * Buzz-only. On a relay29-family relay the same events are addressable with no `channel_id` behind + * them, so an `#h` filter matches nothing there — those relays keep being served by the `#d` + * directory filters. + */ +fun buildRelayGroupLiveStateFilter(groupId: GroupId): List = + listOf( + RelayBasedFilter( + relay = groupId.relayUrl, + filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + ), + // Pins stay in their own filter for the same reason the directory filters split them out. + RelayBasedFilter( + relay = groupId.relayUrl, + filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id))), + ), + ) + /** * Every relay-signed group *state* kind: metadata + admins + members + roles + pins. Small replaceable * events. **Never put this list on the wire as one filter** — request [RELAY_GROUP_METADATA_KINDS] and diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt index f0279dca67..66cd9bdad0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager @@ -117,6 +118,12 @@ class RelayGroupJoinedChatTailSubAssembler( // Reactions/deletions for every message in this channel — the shape Buzz's own client uses. buildRelayGroupAuxFilter(key.groupId, DmHistoryTuning.recentBoundary()), ) + + // This group's own state (39000-39003 + pins), `#h`-scoped so Buzz streams it. The + // account-wide state subscription asks by `#d`, which carries no channel tag and so + // registers as a global subscription there — and Buzz never fans a channel-scoped event + // to one of those, which is why a rename or a role change used to sit stale until the + // next cold start. Costs nothing on a relay29 relay, where it simply matches nothing. + (if (BuzzRelayDialect.isBuzz(relay)) buildRelayGroupLiveStateFilter(key.groupId) else emptyList()) + filterGroupNotificationsToPubkey( relay = relay, pubkey = key.account.userProfile().pubkeyHex,