From d93abf26fc318ecee8dab0fe01c8e244b9997f24 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 18 Aug 2026 19:40:49 -0400 Subject: [PATCH] feat: keep invite replies, reactions and zaps inside the relay that sent them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invite card's actions behaved like actions on a public note: the reply routed to a generic kind-1111 whose broadcast reached the account's outbox, and the like and zap did the same. On a Buzz relay all three are room content — the room is the only place they mean anything, and for a private or closed group publishing them elsewhere advertises who is in which room. - Group-scoped events (anything carrying an `h` tag) now publish to the room's host relays and nowhere else. `EventBroadcaster` resolves the hosts from the cached channels for that group id and returns them outright instead of unioning them with the outbox and broadcast lists; a room this cache doesn't know still stays off the broadcast list. This is the rule the group reply composer already applied on its own path, applied to every group-scoped event. - Zap requests copy the room's `h` tag (reactions already did) and name the room's host as the relay for the receipt, so the kind-9735 lands where the message it pays for lives — and matches the recipient's `#h` notification query. - Tapping an invite row opens the reply page. The room block inside the card keeps its own click and opens the room, so the two destinations each have a target. The reply itself needed no change: it was already a kind-1111 carrying the room's `h` tag, rooted on the kind-44100 (`E`/`K`/`P`) — only its delivery was wrong. Device-verified against a local Buzz relay. Before: the comment reached nos.lol and nostr.mom. After: the comment and the reaction exist on the workspace relay only, absent from all three public relays checked, and the row-tap opens the composer while the room block still opens the room. Co-Authored-By: Claude Opus 5 (1M context) --- .../amethyst/model/AccountZapActions.kt | 10 +- .../amethyst/model/EventBroadcaster.kt | 16 +++ .../amethyst/model/LocalCache.kt | 15 +++ .../ui/navigation/routes/RouteMaker.kt | 10 ++ .../quartz/nip57Zaps/LnZapRequestEvent.kt | 8 ++ .../nip57Zaps/LnZapRequestGroupTagTest.kt | 117 ++++++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt index 1c9f6ae8bf..ea88acf512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -72,7 +72,15 @@ class AccountZapActions( lnurl: String? = null, ) = LnZapRequestEvent.create( zappedEvent = event, - relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), + // Where the provider should publish the receipt. Zapping group content pins that to the room's + // host relay: the receipt belongs where the message it pays for lives, so the room can show it + // and the recipient's group query can find it — and, for a private or closed group, so a + // kind-9735 naming the room never lands on a relay outside it. Everything else keeps the + // ordinary NIP-65 inbox routing. + relays = + account.cache.relayGroupHostsFor(event).ifEmpty { + account.nip65RelayList.inboxFlow.value + } + (additionalRelays ?: emptySet()), signer = account.signer, pollOption = pollOption, message = message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt index 33ed7a5369..11a7854c98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/EventBroadcaster.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent @@ -111,6 +112,11 @@ class EventBroadcaster( val channelRelays = account.cache.getAnyChannel(event)?.relays() if (channelRelays != null && channelRelays.isNotEmpty()) return false + // A group-scoped event whose room this cache doesn't know yet: it still must not go to the + // broadcast list. Its `h` tag names a room only its host can serve, so broadcasting it says + // "I am in this group" to relays that can do nothing with the content. + if (event.isGroupScoped()) return false + return true } @@ -143,6 +149,16 @@ class EventBroadcaster( return emptySet() } + // NIP-29 group content, and everything that refers to it — a kind-9 message, a kind-1111 comment, + // a like, a zap request — exists in a room on a host relay and nowhere else. The room's members + // read it there; the author's outbox and the broadcast list can neither serve it to them nor do + // anything else useful with it, and for a private or closed group publishing it there advertises + // who is in which room. So the host wins outright rather than being one more relay in the union. + // Same rule the group reply composer already applies (CommentPostViewModel), applied to every + // group-scoped event instead of just that one path. + val groupHosts = account.cache.relayGroupHostsFor(event) + if (groupHosts.isNotEmpty()) return groupHosts + val includeBroadcast = wantsBroadcastRelays(event) val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet() 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 e4527fc901..75fc1a1908 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -749,6 +749,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { return relayGroupChannels.filter { key, _ -> key.id == groupId }.singleOrNull() } + /** + * Every host relay of the NIP-29 group [event] is scoped to (its `h` tag), or an empty set when the + * event carries no group scope or the group is unknown to this cache. + * + * Keyed by group id alone rather than by [GroupId]: an event about to be *sent* (a reaction, a zap + * request, a comment) knows which room it belongs to but not which relay hosts it — that is exactly + * what this resolves. Group ids are relay-minted UUIDs, so the same id on two hosts is a + * theoretical case, and answering with both is the safe reading of it: the content reaches every + * host that claims the room, and none that don't. + */ + fun relayGroupHostsFor(event: Event): Set { + val groupId = event.groupId() ?: return emptySet() + return relayGroupChannels.filter { key, _ -> key.id == groupId }.mapTo(mutableSetOf()) { it.groupId.relayUrl } + } + fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key) fun getNoteIfExists(event: Event): Note? = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index a5a320de33..49fa2282e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent @@ -117,6 +118,15 @@ fun routeFor( return routeFor(relayGroup) } + // A channel invite (kind-44100) has two destinations and gives each its own target: the room block + // inside the card carries its own click and opens the room, so the rest of the row — the header, the + // body around the block, the space below the author — opens the reply page instead. Replying is the + // one thing you can do with an invite that the card itself doesn't already offer a button for, and + // the generic thread view has nothing to show for a relay-signed notification nobody replied to yet. + if (note.event is MemberAddedNotificationEvent) { + return Route.GenericCommentPost(replyTo = note.idHex) + } + // Concord channel content (kind 9 chat, 1111 reply, 7 reaction) lands in LocalCache as a real // Note attached to its ConcordChannel gatherer. Route to the Concord chat instead of the generic // thread view it would otherwise fall through to: a minichat reply (kind-1111) opens its thread diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index 9e57317be8..4e5aed0948 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -36,6 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.kinds.KindTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip29RelayGroups.groupId +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -120,6 +122,12 @@ class LnZapRequestEvent( if (zappedEvent is AddressableEvent) { tags = tags + listOf(ATag.assemble(zappedEvent.address(), null)) } + // Zapping NIP-29 group content is itself group content: carry the room's `h` tag so the + // receipt the provider publishes is scoped to the room, which is what lets the host relay + // serve it to the members (and to the recipient's `#h` notification query) instead of + // dropping an unscoped kind-9735 nobody in the group will ever see. Same rule reactions + // follow (ReactionAction copies the `h` tag onto the kind-7). + zappedEvent.groupId()?.let { tags = tags + listOf(GroupIdTag.assemble(it)) } if (pollOption != null && pollOption >= 0) { tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString())) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt new file mode 100644 index 0000000000..2553b9e31d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestGroupTagTest.kt @@ -0,0 +1,117 @@ +/* + * 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.nip57Zaps + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Zapping NIP-29 group content is itself group content. + * + * Without the room's `h` tag the receipt the provider publishes is an unscoped kind-9735: the host + * relay has no reason to serve it to the room, and the recipient's group notification query (`#h` = + * their rooms) never matches it — so a zap on a group message would be invisible exactly where it was + * meant to be seen. Reactions already copy the tag (`ReactionAction`); this pins the same rule for zaps. + */ +class LnZapRequestGroupTagTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + private val nostrSigner = NostrSignerInternal(signer.key) + + private val relays = setOf(NormalizedRelayUrl("wss://groups.example.com/")) + + private fun event( + kind: Int, + tags: Array>, + ) = Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000L, + kind = kind, + tags = tags, + content = "", + sig = "c".repeat(128), + ) + + private suspend fun zapRequestFor(zapped: Event) = + LnZapRequestEvent.create( + zappedEvent = zapped, + relays = relays, + signer = nostrSigner, + pollOption = null, + message = "", + zapType = LnZapEvent.ZapType.PUBLIC, + toUserPubHex = null, + ) + + @Test + fun `zapping a group message carries the room's h tag`() = + runTest { + val groupMessage = event(9, arrayOf(arrayOf("h", "chan-engineering"))) + + val hTag = zapRequestFor(groupMessage).tags.firstOrNull { it[0] == "h" } + + assertTrue(hTag != null, "a zap on group content must stay scoped to the room") + assertEquals("chan-engineering", hTag[1]) + } + + @Test + fun `zapping a membership notification carries the room it announces`() = + runTest { + // The kind-44100 an invite card renders: relay-signed, `h`-scoped to the channel it added + // the viewer to. Zapping it is zapping something that happened inside that room. + val invite = + event( + 44100, + arrayOf( + arrayOf("p", "d".repeat(64)), + arrayOf("h", "chan-design"), + ), + ) + + val hTag = zapRequestFor(invite).tags.firstOrNull { it[0] == "h" } + + assertTrue(hTag != null, "a membership notification is group content too") + assertEquals("chan-design", hTag[1]) + } + + @Test + fun `zapping an ordinary note stays unscoped`() = + runTest { + val note = event(1, arrayOf(arrayOf("t", "nostr"))) + + assertNull( + zapRequestFor(note).tags.firstOrNull { it[0] == "h" }, + "a note that belongs to no room must not claim one", + ) + } +}