feat: keep invite replies, reactions and zaps inside the relay that sent them

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-18 19:40:49 -04:00
co-authored by Claude Opus 5
parent 75cec56a4a
commit d93abf26fc
6 changed files with 175 additions and 1 deletions
@@ -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,
@@ -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()
@@ -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<NormalizedRelayUrl> {
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? =
@@ -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
@@ -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()))
}
@@ -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<Array<String>>,
) = 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",
)
}
}