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 03e5297089..3e4ddc88fe 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 @@ -180,7 +180,15 @@ fun buildRelayGroupJoinedChatTailFilters( ) } -/** The recent-chat live tail for a single open group, `#h`-scoped on its host relay. */ +/** + * The recent-chat live tail for a single open group, `#h`-scoped on its host relay. + * + * Always requests the FULL kind set (NIP-29 + Buzz): this single-group REQ is the + * dialect-detection bootstrap. Fleet-wide subs only widen once [BuzzRelayDialect] marks + * the relay, but the mark comes from consuming a Buzz kind — which never arrives if no + * filter asks for one. Opening a channel is explicit user intent on one group, so the + * wider ask is cheap, and on a vanilla relay the extra kinds simply match nothing. + */ fun buildRelayGroupOpenChatTailFilter( groupId: GroupId, sinceEpoch: Long, @@ -189,7 +197,7 @@ fun buildRelayGroupOpenChatTailFilter( relay = groupId.relayUrl, filter = Filter( - kinds = relayGroupTimelineKinds(groupId.relayUrl), + kinds = RELAY_GROUP_TIMELINE_KINDS + BUZZ_RELAY_GROUP_TIMELINE_EXTRA_KINDS, tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), since = sinceEpoch, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index c6eee16ab4..20cfbc0509 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -70,6 +70,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadS import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.buzz.threading.buzzThread +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent @@ -87,6 +89,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip01Core.tags.references.references @@ -688,16 +691,13 @@ open class ChannelNewMessageViewModel : // clients send. Checked BEFORE RelayGroupChannel: Buzz is a subclass. StreamMessageV2Event.build(channel.groupId.id, tagger.message) { replyTo.value?.let { parent -> - val parentRoot = - parent.event - ?.tags - ?.firstOrNull { it.size >= 4 && it[0] == "e" && it[3] == "root" } - ?.get(1) - if (parentRoot != null && parentRoot != parent.idHex) { - add(arrayOf("e", parentRoot, "", "root")) - } - add(arrayOf("e", parent.idHex, "", "reply")) - parent.author?.pubkeyHex?.let { add(arrayOf("p", it)) } + // Replying inside the parent's thread: the parent's own root + // marker (when it is itself a reply) becomes our root, else the + // parent starts the thread. buzzThread collapses to a single + // "reply" marker when root == parent, per thread_tags. + val root = parent.event?.tags?.buzzThreadRoot() ?: parent.idHex + buzzThread(root, parent.idHex) + parent.author?.pubkeyHex?.let { pTag(PTag(it)) } } hashtags(findHashtags(tagger.message)) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt index 91d3a95ccf..cd7f4c33a5 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/BuzzTimelineKindsTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -67,13 +68,29 @@ class BuzzTimelineKindsTest { } @Test - fun openChatTailFilterCarriesBuzzKindsOnlyOnBuzzRelays() { + fun openChatTailAlwaysCarriesBuzzKindsAsDialectBootstrap() { + // The single-group open-channel REQ is what DISCOVERS the dialect: with no + // filter asking for a Buzz kind, none ever arrives and the relay is never + // marked. It therefore always includes the Buzz kinds, even unmarked. + val vanillaFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L) + assertTrue(vanillaFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND)) + } + + @Test + fun fleetWideJoinedTailWidensOnlyOnMarkedRelays() { BuzzRelayDialect.mark(buzzRelay) - val buzzFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", buzzRelay), sinceEpoch = 0L) - val vanillaFilter = buildRelayGroupOpenChatTailFilter(GroupId("g1", vanillaRelay), sinceEpoch = 0L) + val filters = + buildRelayGroupJoinedChatTailFilters( + listOf( + GroupTag("g1", buzzRelay.url), + GroupTag("g2", vanillaRelay.url), + ), + sinceEpoch = 0L, + ) - assertTrue(buzzFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND)) - assertFalse(vanillaFilter.filter.kinds!!.contains(StreamMessageV2Event.KIND)) + val byRelay = filters.associateBy { it.relay } + assertTrue(byRelay[buzzRelay]!!.filter.kinds!!.contains(StreamMessageV2Event.KIND)) + assertFalse(byRelay[vanillaRelay]!!.filter.kinds!!.contains(StreamMessageV2Event.KIND)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/forum/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/forum/TagArrayBuilderExt.kt index d6e1742ff1..df82788dba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/forum/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/forum/TagArrayBuilderExt.kt @@ -20,12 +20,12 @@ */ package com.vitorpamplona.quartz.buzz.forum +import com.vitorpamplona.quartz.buzz.threading.buzzThread 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.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag /** The `h` channel tag (NIP-29) scoping a Buzz forum event to its channel UUID. */ @@ -38,19 +38,11 @@ fun TagArrayBuilder.forumMentions(mentions: List) = menti fun TagArrayBuilder.forumVoteTarget(targetEventId: HexKey) = addUnique(ETag.assemble(targetEventId, null, null)) /** - * NIP-10 thread e-tags for a forum comment, matching `thread_tags` in `builders.rs`: - * - direct reply (`root == parent`): one `["e", root, "", "reply"]` - * - nested reply (`root != parent`): `["e", root, "", "root"]` + `["e", parent, "", "reply"]` - * - * The empty relay slot is emitted verbatim so the marker stays at index 3, matching the - * reference wire form. + * NIP-10 thread e-tags for a forum comment. Buzz threads streams and forum comments + * identically (`thread_tags` in `builders.rs`), so this delegates to the shared + * [buzzThread] verb. */ fun TagArrayBuilder.forumThread( rootEventId: HexKey, parentEventId: HexKey, -) = if (rootEventId == parentEventId) { - add(arrayOf(MarkedETag.TAG_NAME, rootEventId, "", MarkedETag.MARKER.REPLY.code)) -} else { - add(arrayOf(MarkedETag.TAG_NAME, rootEventId, "", MarkedETag.MARKER.ROOT.code)) - add(arrayOf(MarkedETag.TAG_NAME, parentEventId, "", MarkedETag.MARKER.REPLY.code)) -} +) = buzzThread(rootEventId, parentEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/forum/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/forum/TagArrayExt.kt index b5e9934f41..422e93eae7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/forum/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/forum/TagArrayExt.kt @@ -20,12 +20,13 @@ */ package com.vitorpamplona.quartz.buzz.forum +import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply +import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag /** The `h` channel UUID this forum event belongs to. */ @@ -37,17 +38,8 @@ fun TagArray.forumMentions(): List = mapNotNull(PTag::parseKey) /** The `e` target event of a forum vote (`kind:45002`). */ fun TagArray.forumVoteTarget(): HexKey? = firstNotNullOfOrNull(ETag::parseId) -// Reads the NIP-10 marker positionally: the Buzz wire form keeps an explicit empty -// relay slot (`["e", id, "", "reply"]`), so the marker sits at index 3. A relay-less -// 3-element form (`["e", id, "reply"]`) is tolerated with the marker at index 2. -private fun Array.markedEventId(marker: MarkedETag.MARKER): HexKey? { - if (size < 3 || this[0] != MarkedETag.TAG_NAME || this[1].length != 64) return null - val code = marker.code - return if ((size >= 4 && this[3] == code) || this[2] == code) this[1] else null -} - /** The NIP-10 `root`-marked thread event id of a forum comment (null for a direct reply). */ -fun TagArray.forumThreadRoot(): HexKey? = firstNotNullOfOrNull { it.markedEventId(MarkedETag.MARKER.ROOT) } +fun TagArray.forumThreadRoot(): HexKey? = buzzThreadRoot() /** The NIP-10 `reply`-marked immediate-parent event id of a forum comment. */ -fun TagArray.forumThreadReply(): HexKey? = firstNotNullOfOrNull { it.markedEventId(MarkedETag.MARKER.REPLY) } +fun TagArray.forumThreadReply(): HexKey? = buzzThreadReply() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ddd6411993 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayBuilderExt.kt @@ -0,0 +1,46 @@ +/* + * 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.threading + +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.nip10Notes.tags.MarkedETag + +/** + * Buzz's NIP-10 thread e-tags, shared by stream messages (40002) and forum comments + * (45003) — both mirror `thread_tags` in `buzz-sdk/src/builders.rs`: + * - direct reply (`root == parent`): one `["e", root, "", "reply"]` + * - nested reply (`root != parent`): `["e", root, "", "root"]` + `["e", parent, "", "reply"]` + * + * The empty relay slot is emitted verbatim so the marker stays at index 3, matching the + * reference wire form. ([MarkedETag.assemble] cannot be used here: its `arrayOfNotNull` + * drops the null relay slot, sliding the marker into index 2.) + */ +fun TagArrayBuilder.buzzThread( + rootEventId: HexKey, + parentEventId: HexKey, +) = if (rootEventId == parentEventId) { + add(arrayOf(MarkedETag.TAG_NAME, rootEventId, "", MarkedETag.MARKER.REPLY.code)) +} else { + add(arrayOf(MarkedETag.TAG_NAME, rootEventId, "", MarkedETag.MARKER.ROOT.code)) + add(arrayOf(MarkedETag.TAG_NAME, parentEventId, "", MarkedETag.MARKER.REPLY.code)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt new file mode 100644 index 0000000000..c7d3b18c07 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/threading/TagArrayExt.kt @@ -0,0 +1,43 @@ +/* + * 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.threading + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag + +/** + * Positional reader for Buzz's thread e-tags (`["e", id, "", "root"|"reply"]`). + * [MarkedETag.parse] rejects the empty relay slot Buzz emits, so the marker is read + * positionally: index 3 in the canonical 4-element form, tolerating the relay-less + * 3-element form (`["e", id, "reply"]`) with the marker at index 2. + */ +private fun Array.buzzMarkedEventId(marker: MarkedETag.MARKER): HexKey? { + if (size < 3 || this[0] != MarkedETag.TAG_NAME || this[1].length != 64) return null + val code = marker.code + return if ((size >= 4 && this[3] == code) || this[2] == code) this[1] else null +} + +/** The thread root this event replies under, from its marked `root` e-tag. */ +fun TagArray.buzzThreadRoot(): HexKey? = firstNotNullOfOrNull { it.buzzMarkedEventId(MarkedETag.MARKER.ROOT) } + +/** The direct parent this event replies to, from its marked `reply` e-tag. */ +fun TagArray.buzzThreadReply(): HexKey? = firstNotNullOfOrNull { it.buzzMarkedEventId(MarkedETag.MARKER.REPLY) } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/interop/BuzzRelayLiveInteropTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/interop/BuzzRelayLiveInteropTest.kt index 8f9ec5917c..429936584d 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/interop/BuzzRelayLiveInteropTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/buzz/interop/BuzzRelayLiveInteropTest.kt @@ -29,6 +29,8 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent +import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -192,6 +194,71 @@ class BuzzRelayLiveInteropTest { } } + /** + * The workspace lifecycle Amethyst's group UI drives: discover the channel via its + * relay-signed 39000 metadata, join it with a NIP-29 kind-9021 request from a + * different member, post into it, then leave with kind-9022 — all against the real + * relay, using the exact Quartz events the UI sends. + */ + @Test + fun discoverJoinPostAndLeave() { + if (!envReady() || ownerSk == null) { + println("SKIP: BUZZ_RELAY_WS / BUZZ_MEMBER_SK / BUZZ_OWNER_SK not set") + return + } + runBlocking { + val creator = NostrSignerInternal(KeyPair(memberSk!!.hexToByteArray())) + val (ws, frames) = authedSocket(creator) + + val channelId = UUID.randomUUID().toString() + val create = + creator.sign( + CreateGroupEvent.build(channelId) { + add(arrayOf("name", "amethyst-lifecycle")) + }, + ) + ws.send("""["EVENT",${create.toJson()}]""") + assertTrue("true" in frames.nextOf("OK")) + + // SEE: the relay-signed kind-39000 discovery metadata must be queryable — + // this is what materializes the channel in Amethyst's group directory. + ws.send("""["REQ","meta",{"kinds":[39000],"#d":["$channelId"]}]""") + val metaFrame = frames.nextOf("EVENT") + val meta = Event.fromJson(metaFrame.substringAfter(",\"meta\",").dropLast(1)) + assertEquals(39000, meta.kind) + assertTrue( + meta.tags.any { it.size > 1 && it[0] == "d" && it[1] == channelId }, + "39000 must be addressed by the channel id", + ) + frames.nextOf("EOSE") + + // JOIN: a different workspace member requests membership with kind 9021 — + // the same event Amethyst's NIP-29 Join button sends. + val joiner = NostrSignerInternal(KeyPair(ownerSk.hexToByteArray())) + val (ws2, frames2) = authedSocket(joiner) + val join = + joiner.sign( + JoinRequestEvent.build(channelId), + ) + ws2.send("""["EVENT",${join.toJson()}]""") + val joinOk = frames2.nextOf("OK") + assertTrue("true" in joinOk, "join (9021) should be accepted: $joinOk") + + // POST: the joiner can now publish a 40002 into the channel. + val msg = joiner.sign(StreamMessageV2Event.build(channelId, "joined and posting")) + ws2.send("""["EVENT",${msg.toJson()}]""") + assertTrue("true" in frames2.nextOf("OK"), "post after join should be accepted") + + // LEAVE: kind 9022, the NIP-29 leave request. + val leave = joiner.sign(LeaveRequestEvent.build(channelId)) + ws2.send("""["EVENT",${leave.toJson()}]""") + assertTrue("true" in frames2.nextOf("OK"), "leave (9022) should be accepted") + + ws.close(1000, "done") + ws2.close(1000, "done") + } + } + @Test fun agentAuthenticatesViaOwnerAttestation() { if (!envReady() || ownerSk == null) {