mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge pull request #3951 from vitorpamplona/feat/invite-actions-in-relay
feat: keep invite replies, reactions and zaps inside the relay that sent them
This commit is contained in:
@@ -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()))
|
||||
}
|
||||
|
||||
+117
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user