feat(nip29): add RelayGroupChannel model + group message datasource

First feature-layer slice for NIP-29 relay groups, cloning the ephemeral-chat
(NIP-C7) pattern and reusing the existing relay-pinned send/subscribe
primitives (RelayBasedFilter + signAndSendPrivatelyOrBroadcast) — no new
transport.

- quartz: GroupId(id, relayUrl) identifier for a relay group (host relay +
  group id), mirroring ephemChat's RoomId.
- commons: RelayGroupChannel — a metadata-backed Channel (like NIP-28's
  PublicChatChannel) keyed by GroupId, deriving name/picture/about/flags from
  the relay-signed kind 39000 event and pinning relays() to the single host.
- amethyst: filterMessagesToRelayGroup / filterMyMessagesToRelayGroup
  datasource sub-assemblers — kind 9 + poll timeline scoped by the `h` tag and
  pinned to the host relay via RelayBasedFilter, mirroring the ephemeral-chat
  sub-assemblers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-07 20:05:35 +00:00
parent 2620ce9a50
commit dc4fe7500f
4 changed files with 247 additions and 0 deletions
@@ -0,0 +1,55 @@
/*
* 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.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** Timeline kinds shown in a NIP-29 group: chat messages and polls. */
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* The message timeline for a NIP-29 group. A group's chat lives entirely on its
* host relay, scoped by the `h` tag, so the filter is pinned to
* [RelayGroupChannel.relays] (always the single host) and never fans out to the
* user's other relays.
*/
fun filterMessagesToRelayGroup(
channel: RelayGroupChannel,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
limit = 200,
since = since?.get(it)?.time,
),
)
}
@@ -0,0 +1,57 @@
/*
* 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.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* The current user's own messages in a NIP-29 group. A smaller companion to
* [filterMessagesToRelayGroup] that back-fills the user's sent messages (so
* optimistic sends reconcile) — pinned to the group's host relay and scoped by
* both the `h` group tag and the author.
*/
fun filterMyMessagesToRelayGroup(
channel: RelayGroupChannel,
pubKey: HexKey,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
channel.relays().toSet().map {
RelayBasedFilter(
relay = it,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(channel.groupId.id)),
authors = listOf(pubKey),
limit = 50,
since = since?.get(it)?.time,
),
)
}
@@ -0,0 +1,87 @@
/*
* 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.amethyst.commons.model.nip29RelayGroups
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/**
* A NIP-29 relay-based group ("channel" in Discord terms). Unlike a NIP-C7
* [com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel] — a
* bare relay+room with no server-owned metadata — a relay group has a
* relay-signed metadata event (kind 39000) carrying its name, picture, about and
* status flags, so this channel is metadata-backed like a NIP-28
* [com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel].
*
* It is keyed by [GroupId] (host relay + group id): the group only exists on its
* host relay, so every read and write is pinned there via [relays].
*/
@Stable
class RelayGroupChannel(
val groupId: GroupId,
) : Channel() {
/** The latest relay-signed kind 39000 metadata event, when known. */
var event: GroupMetadataEvent? = null
/** Long-term reference to the metadata note (LocalCache holds weak references). */
var metadataNote: Note? = null
var updatedMetadataAt: Long = 0
/** A relay group lives on exactly one relay: its host. */
override fun relays() = setOf(groupId.relayUrl)
override fun toBestDisplayName(): String = event?.name() ?: groupId.id
fun summary(): String? = event?.about()
fun profilePicture(): String? = event?.picture()
fun isPrivate(): Boolean = event?.isPrivate() ?: false
fun isRestricted(): Boolean = event?.isRestricted() ?: false
fun isClosed(): Boolean = event?.isClosed() ?: false
fun isHidden(): Boolean = event?.isHidden() ?: false
fun hasLivekit(): Boolean = event?.hasLivekit() ?: false
fun updateGroupInfo(
event: GroupMetadataEvent,
eventNote: Note? = null,
) {
// Only newer metadata supersedes.
if (event.createdAt < updatedMetadataAt) return
this.event = event
this.metadataNote = eventNote
this.updatedMetadataAt = event.createdAt
updateChannelInfo()
}
fun anyNameStartsWith(prefix: String): Boolean =
groupId.id.contains(prefix, true) ||
event?.name()?.contains(prefix, true) == true ||
event?.about()?.contains(prefix, true) == true
}
@@ -0,0 +1,48 @@
/*
* 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.nip29RelayGroups
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* The identity of a NIP-29 relay-based group. A group is NOT global: its id is
* only unique within its host relay, so a group is always the pair of the host
* relay and the group id (the `h` tag on its events, the `d` tag on its kind
* 39000 metadata). Mirrors [com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId].
*/
data class GroupId(
val id: String,
val relayUrl: NormalizedRelayUrl,
) : Comparable<GroupId> {
fun toKey() = "$id@${relayUrl.url}"
fun toDisplayKey() = "$id@${relayUrl.displayUrl()}"
override fun compareTo(other: GroupId): Int {
val result = id.compareTo(other.id)
return if (result == 0) {
relayUrl.url.compareTo(other.relayUrl.url)
} else {
result
}
}
}