diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt index 171d478aff..a4d966671b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupState.kt @@ -22,45 +22,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datas import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent -import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent - -/** Relay-signed group directory kinds: metadata + admins + members + roles + pins. */ -private val RELAY_GROUP_METADATA_KINDS = - listOf( - GroupMetadataEvent.KIND, - GroupAdminsEvent.KIND, - GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, - GroupPinnedEvent.KIND, - ) /** * The relay-signed metadata for a NIP-29 group (name/picture/about + admin, * member and role lists), addressed by the group id (`d` tag) and pinned to the * group's host relay. The relay signs these with its own key, so a single-relay * query scoped by `#d` returns exactly this group's directory. + * + * The 39000-39003 metadata block and the 39005 pin list go out as **two separate filters**: relay29-family + * relays (0xchat's included) reject a filter that mixes them and drop the whole REQ, which would leave the + * group with no name, no roster and no membership. See + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_PIN_KINDS]. */ fun filterRelayGroupState( channel: RelayGroupChannel, since: SincePerRelayMap?, ): List { val relays = channel.relays().toSet() + val scope = mapOf("d" to listOf(channel.groupId.id)) val directory = - relays.map { - RelayBasedFilter( - relay = it, - filter = - Filter( - kinds = RELAY_GROUP_METADATA_KINDS, - tags = mapOf("d" to listOf(channel.groupId.id)), - since = since?.get(it)?.time, - ), + relays.flatMap { + val floor = since?.get(it)?.time + listOf( + RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = floor)), + RelayBasedFilter(relay = it, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = floor)), ) } 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 dfcfbee1dd..a68436ff0d 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 @@ -45,16 +45,43 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent * See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md and the companion test plan. */ -/** Relay-signed group *state*: metadata + admins + members + roles + pins. Small replaceable events. */ -val RELAY_GROUP_STATE_KINDS = +/** + * The relay's **directory** kinds for a group — metadata + admins + members + roles (39000-39003). + * These four are what NIP-29 relays treat as a group's "metadata" block, and they must be requested + * **alone**: see [RELAY_GROUP_PIN_KINDS]. + */ +val RELAY_GROUP_METADATA_KINDS = listOf( GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, SupportedRolesEvent.KIND, - GroupPinnedEvent.KIND, ) +/** + * The pin list (39005), deliberately kept in its **own** filter rather than merged into + * [RELAY_GROUP_METADATA_KINDS]. + * + * NIP-29 relays derived from `relay29`/`khatru29` (0xchat's `groups.0xchat.com` among them) reject a REQ + * whose filter mixes the 39000-39003 metadata kinds with any other kind, replying + * `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"`. A single filter asking for + * 39000-39003 **plus** 39005 is therefore dropped **whole** — the group never resolves its name, roster, + * roles or the user's own membership, so it renders as a raw id and offers "Join" to somebody the relay + * already lists as an admin. + * + * Splitting into two filter objects fixes it: those relays evaluate the rule per filter, so the + * metadata filter is served normally and the pins filter is served (or harmlessly ignored) on its own. + */ +val RELAY_GROUP_PIN_KINDS = listOf(GroupPinnedEvent.KIND) + +/** + * 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 + * [RELAY_GROUP_PIN_KINDS] as separate filters instead (see [RELAY_GROUP_PIN_KINDS]). Kept as the + * semantic "all state kinds" set for cache/consume-side code. + */ +val RELAY_GROUP_STATE_KINDS = RELAY_GROUP_METADATA_KINDS + RELAY_GROUP_PIN_KINDS + /** Timeline kinds shown in a group's chat — chat messages and polls. */ val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND) @@ -69,13 +96,7 @@ val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, Threa * Narrower than [RELAY_GROUP_STATE_KINDS] on purpose: the directory lists groups, it doesn't need each * group's pin list. */ -val RELAY_GROUP_DIRECTORY_KINDS = - listOf( - GroupMetadataEvent.KIND, - GroupAdminsEvent.KIND, - GroupMembersEvent.KIND, - SupportedRolesEvent.KIND, - ) +val RELAY_GROUP_DIRECTORY_KINDS = RELAY_GROUP_METADATA_KINDS /** How many directory entries to pull per relay when browsing its whole group list. */ const val RELAY_GROUP_DIRECTORY_LIMIT = 500 @@ -93,22 +114,21 @@ private fun byHostRelay(joined: Collection): Map, sinceForRelay: (NormalizedRelayUrl) -> Long?, ): List = - byHostRelay(joined).map { (relay, ids) -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = RELAY_GROUP_STATE_KINDS, - tags = mapOf(D_TAG to ids.distinct()), - since = sinceForRelay(relay), - ), + byHostRelay(joined).flatMap { (relay, ids) -> + val scope = mapOf(D_TAG to ids.distinct()) + val since = sinceForRelay(relay) + listOf( + RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = scope, since = since)), + RelayBasedFilter(relay = relay, filter = Filter(kinds = RELAY_GROUP_PIN_KINDS, tags = scope, since = since)), ) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt index 3410d1c55a..82a772f8ef 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -45,28 +46,51 @@ class FilterRelayGroupStateTest { private val relaySignKey = "b".repeat(64) private val sig = "0".repeat(128) - private val stateKinds = + private val metadataKinds = listOf( GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, SupportedRolesEvent.KIND, - GroupPinnedEvent.KIND, ) + private val pinKinds = listOf(GroupPinnedEvent.KIND) @Test - fun `with no pins it is a single host d-scoped state filter and no message window`() { + fun `with no pins it is host d-scoped state filters and no message window`() { val channel = RelayGroupChannel(groupId) val filters = filterRelayGroupState(channel, since = null) - val f = filters.single() - assertEquals(relayA, f.relay) - assertEquals(stateKinds, f.filter.kinds) - assertEquals(listOf("g1"), f.filter.tags!!["d"]) - assertNull("state is #d-scoped, never #h — the message window is the tail/pager's job", f.filter.tags!!["h"]) - assertNull("state carries no message-window kinds (9/poll), so no limit either", f.filter.limit) - assertNull(f.filter.until) + assertEquals(2, filters.size) + filters.forEach { f -> + assertEquals(relayA, f.relay) + assertEquals(listOf("g1"), f.filter.tags!!["d"]) + assertNull("state is #d-scoped, never #h — the message window is the tail/pager's job", f.filter.tags!!["h"]) + assertNull("state carries no message-window kinds (9/poll), so no limit either", f.filter.limit) + assertNull(f.filter.until) + } + assertEquals(metadataKinds, filters[0].filter.kinds) + assertEquals(pinKinds, filters[1].filter.kinds) + } + + /** + * Regression: relay29-family relays (0xchat's `groups.0xchat.com`) answer a filter that mixes the + * 39000-39003 metadata kinds with any other kind — 39005 pins included — with + * `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"`, dropping the WHOLE REQ. + * The group then never learns its name, roster or the user's own membership, so it renders as a raw + * id and offers "Join" to somebody the relay already lists as an admin. Keep the two apart. + */ + @Test + fun `pins are never mixed into the metadata filter`() { + val channel = RelayGroupChannel(groupId) + + filterRelayGroupState(channel, since = null).forEach { f -> + val kinds = f.filter.kinds ?: return@forEach + assertFalse( + "39005 must not share a filter with the 39000-39003 metadata block: $kinds", + kinds.contains(GroupPinnedEvent.KIND) && kinds.any { it in metadataKinds }, + ) + } } @Test @@ -85,7 +109,7 @@ class FilterRelayGroupStateTest { ) val filters = filterRelayGroupState(channel, since = null) - assertEquals(2, filters.size) + assertEquals(3, filters.size) val pinFilter = filters.first { it.filter.ids != null } assertEquals(relayA, pinFilter.relay) @@ -93,10 +117,12 @@ class FilterRelayGroupStateTest { assertNull("pinned bodies are fetched by id, so no kinds", pinFilter.filter.kinds) assertNull("pinned events are immutable, so no since either", pinFilter.filter.since) - // The state filter is still present and still carries no #h message window. - val stateFilter = filters.first { it.filter.ids == null } - assertEquals(stateKinds, stateFilter.filter.kinds) - assertTrue(stateFilter.filter.tags!!.containsKey("d")) - assertNull(stateFilter.filter.tags!!["h"]) + // The state filters are still present and still carry no #h message window. + val stateFilters = filters.filter { it.filter.ids == null } + assertEquals(listOf(metadataKinds, pinKinds), stateFilters.map { it.filter.kinds }) + stateFilters.forEach { + assertTrue(it.filter.tags!!.containsKey("d")) + assertNull(it.filter.tags!!["h"]) + } } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt index 2434214f12..c763a9bd6f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt @@ -65,24 +65,44 @@ class RelayGroupFilterBuildersTest { // --- State (always-on): one #d filter per host relay, batching that relay's group ids --- @Test - fun `state batches one d-filter per host relay`() { + fun `state batches d-filters per host relay, metadata and pins kept apart`() { val filters = buildRelayGroupStateFilters(joined) { null } - assertEquals(2, filters.size) + // two relays x (metadata + pins) + assertEquals(4, filters.size) - val a = filters.single { it.relay == relayA } - assertEquals(RELAY_GROUP_STATE_KINDS, a.filter.kinds) - assertEquals(setOf("g1", "g2"), a.filter.tags!!["d"]!!.toSet()) - assertNull("state is #d-scoped, never #h", a.filter.tags!!["h"]) + val a = filters.filter { it.relay == relayA } + assertEquals(listOf(RELAY_GROUP_METADATA_KINDS, RELAY_GROUP_PIN_KINDS), a.map { it.filter.kinds }) + a.forEach { + assertEquals(setOf("g1", "g2"), it.filter.tags!!["d"]!!.toSet()) + assertNull("state is #d-scoped, never #h", it.filter.tags!!["h"]) + } - val b = filters.single { it.relay == relayB } - assertEquals(listOf("g3"), b.filter.tags!!["d"]) + val b = filters.filter { it.relay == relayB } + b.forEach { assertEquals(listOf("g3"), it.filter.tags!!["d"]) } + } + + /** + * Regression: relay29-family relays (0xchat's `groups.0xchat.com`) reject a filter mixing the + * 39000-39003 metadata kinds with any other kind (39005 pins included) with + * `CLOSED … "blocked: it's not allowed to mix metadata kinds with others"` and drop the whole REQ — + * so every joined group on such a relay silently loses its name, roster and membership. + */ + @Test + fun `state never mixes pins into the metadata filter`() { + buildRelayGroupStateFilters(joined) { null }.forEach { f -> + val kinds = f.filter.kinds!! + assertFalse( + "39005 must not share a filter with the 39000-39003 metadata block: $kinds", + kinds.contains(GroupPinnedEvent.KIND) && kinds.any { it in RELAY_GROUP_METADATA_KINDS }, + ) + } } @Test fun `state applies the per-relay since`() { val filters = buildRelayGroupStateFilters(joined) { relay -> if (relay == relayA) 111L else null } - assertEquals(111L, filters.single { it.relay == relayA }.filter.since) - assertNull(filters.single { it.relay == relayB }.filter.since) + filters.filter { it.relay == relayA }.forEach { assertEquals(111L, it.filter.since) } + filters.filter { it.relay == relayB }.forEach { assertNull(it.filter.since) } } // --- Joined chat tail (always-on): batched #h per relay, time floor, NO per-group limit ---