feat: per-type relay group discovery (follows/admins/members + topics/geo)

Relay-signed kind-39000 has no author-of-a-follow, but the people dimension
still exists: a follow may be the relay signing key, a group admin (39001), or
a member (39002). Discovery now resolves each top-nav filter into a per-relay
GroupDiscoveryConstraint instead of collapsing every filter to the same REQ.

quartz:
- GroupMetadataEvent / EditMetadataEvent: build + read #t (topics) and #g
  (geohash, mip-mapped so a coarser followed geohash still matches). Interop
  tests for parse/build round-trips.

amethyst:
- dal/RelayGroupDiscoveryFeedFilter: sealed GroupDiscoveryConstraint
  (AllGroups / ByPeople / ByHashtags / ByGeohashes / AnyOf) + toGroupConstraints()
  mapping each IFeedTopNavPerRelayFilterSet to per-relay constraints, with
  matches() covering the relay-key/admin/member people paths and topic/geo tags.
  Unit tests.
- Directory REQ narrows to 39000 #t/#g for topic/geo filters, broad directory
  otherwise (people match needs the rosters).
- ViewModel keys the feed on the constraint map and re-scans on any directory
  event (metadata OR roster) so late-arriving admins/members surface groups.
- Create/edit form gains a Discovery section (topics + geohash) threaded through
  Account.createRelayGroup/editRelayGroupMetadata and EditMetadata.

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-08 21:42:38 +00:00
parent 1b1413f861
commit 0140b837b1
14 changed files with 608 additions and 73 deletions
@@ -1506,6 +1506,8 @@ class Account(
isClosed: Boolean = false,
isHidden: Boolean = false,
isRestricted: Boolean = false,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
): GroupId {
signAndSendPrivatelyOrBroadcast(CreateGroupEvent.build(groupId)) { listOf(relay) }
@@ -1516,6 +1518,8 @@ class Account(
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
)
signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
@@ -1593,6 +1597,8 @@ class Account(
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
) {
val template =
EditMetadataEvent.build(
@@ -1601,6 +1607,8 @@ class Account(
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
)
signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
@@ -1448,8 +1448,22 @@ class AccountViewModel(
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String>,
geohashes: List<String>,
) = launchSigner {
account.createRelayGroup(relay, groupId, name, about, picture, isPrivate, isClosed, isHidden, isRestricted)
account.createRelayGroup(
relay,
groupId,
name,
about,
picture,
isPrivate,
isClosed,
isHidden,
isRestricted,
hashtags,
geohashes,
)
}
fun createRelayGroupInvite(
@@ -1483,8 +1497,21 @@ class AccountViewModel(
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String>,
geohashes: List<String>,
) = launchSigner {
account.editRelayGroupMetadata(channel, name, about, picture, isPrivate, isClosed, isHidden, isRestricted)
account.editRelayGroupMetadata(
channel,
name,
about,
picture,
isPrivate,
isClosed,
isHidden,
isRestricted,
hashtags,
geohashes,
)
}
fun follow(users: List<User>) = launchSigner { account.follow(users) }
@@ -83,18 +83,24 @@ fun RelayGroupDiscoveryScreen(
val viewModel: RelayGroupDiscoveryViewModel = viewModel()
viewModel.init(accountViewModel.account)
val relays by viewModel.relays.collectAsStateWithLifecycle()
val constraints by viewModel.constraints.collectAsStateWithLifecycle()
val groups by viewModel.groups.collectAsStateWithLifecycle()
val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
.collectAsStateWithLifecycle()
val favoriteRelays by accountViewModel.account.relayFeedsList.flow
.collectAsStateWithLifecycle()
// Fan the directory subscription out to each relay in the current set while the screen
// is visible; each is a lifecycle-aware per-relay REQ that EOSEs and dedupes by relay.
relays.forEach { relay ->
// Fan the directory subscription out to each relay in the current set while the screen is
// visible; each is a lifecycle-aware per-relay REQ (narrowed by the relay's constraint for
// topic/geo filters) that EOSEs and dedupes by relay.
constraints.forEach { (relay, constraint) ->
key(relay) {
RelayGroupDirectorySubscription(relay, accountViewModel.dataSources().relayGroupDirectory, accountViewModel)
RelayGroupDirectorySubscription(
relay,
accountViewModel.dataSources().relayGroupDirectory,
accountViewModel,
constraint,
)
}
}
@@ -26,18 +26,13 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -50,41 +45,23 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
/**
* The relays a top-nav filter resolves to. A NIP-29 group's kind-39000 is relay-signed
* (not authored by follows, not hashtag/geo-tagged), so unlike the author-based feeds
* the ONLY thing a filter contributes to group discovery is its RELAY SET. We take the
* keys of whatever per-relay set the selected [TopFilter] produced and query kind 39000
* on them.
*/
fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set<NormalizedRelayUrl> =
when (this) {
is GlobalTopNavPerRelayFilterSet -> set.keys
is AllFollowsTopNavPerRelayFilterSet -> set.keys
is AuthorsTopNavPerRelayFilterSet -> set.keys
is HashtagTopNavPerRelayFilterSet -> set.keys
is LocationTopNavPerRelayFilterSet -> set.keys
is AllCommunitiesTopNavPerRelayFilterSet -> set.keys
is SingleCommunityTopNavPerRelayFilterSet -> set.keys
is MutedAuthorsTopNavPerRelayFilterSet -> set.keys
// A single relay chip (includes a starred favorite relay).
is RelayTopNavPerRelayFilterSet -> setOf(relayUrl)
// DVM algo-feed selections carry no group-hosting relays.
else -> emptySet()
}
/**
* Drives the Relay Groups discovery feed. The top bar's [FeedFilterSpinner] writes the
* selection to the account's persisted `defaultRelayGroupsDiscoveryFollowList` (Global /
* Follows / a followed hashtag or geohash / a specific relay including favorite relays,
* which surface as relay chips), exactly like every other feed. Because a group's kind-39000
* is relay-signed, the only thing the filter contributes is its RELAY SET ([relayKeys]); the
* feed is every group those relays host, read from [LocalCache] as directory events arrive.
* Drives the Relay Groups discovery feed. The top bar's [com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner]
* writes the selection to the account's persisted `defaultRelayGroupsDiscoveryFollowList` (Global /
* Follows / a followed hashtag or geohash / a specific relay including favorite relays, which
* surface as relay chips), exactly like every other feed.
*
* Unlike the note feeds, a group's kind-39000 is relay-signed, so the resolved filter is turned into
* a per-relay [GroupDiscoveryConstraint] ([toGroupConstraints]): the relays to query, plus how each
* group on them is matched (relay-key follow / follow-is-admin / follow-is-member for people filters,
* `#t`/`#g` tag match for topic/geo filters, or every group for Global). The feed is every cached
* group that satisfies its host relay's constraint, re-scanned as directory events arrive.
*/
@Stable
class RelayGroupDiscoveryViewModel : ViewModel() {
private lateinit var account: Account
lateinit var relays: StateFlow<Set<NormalizedRelayUrl>>
/** The relay → constraint map the selected top-nav filter resolved to. */
lateinit var constraints: StateFlow<Map<NormalizedRelayUrl, GroupDiscoveryConstraint>>
private set
lateinit var groups: StateFlow<List<RelayGroupChannel>>
@@ -95,31 +72,42 @@ class RelayGroupDiscoveryViewModel : ViewModel() {
if (this::account.isInitialized) return
account = acc
relays =
constraints =
account.liveRelayGroupsDiscoveryFollowListsPerRelay
.map { it.relayKeys() }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptySet())
.map { it.toGroupConstraints() }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
groups =
relays
.flatMapLatest { relaySet ->
// Re-scan the cache whenever a new kind-39000 lands; the initial emit renders
// whatever's already cached for this relay set immediately.
constraints
.flatMapLatest { byRelay ->
// Re-scan the cache whenever any directory event lands — metadata (39000) OR
// a roster change (39001/39002), since the people match reads admins/members.
// The emitted list is ignored; matchingGroups reads the cache directly. The
// initial emit renders whatever's already cached for these relays immediately.
LocalCache
.observeEvents<GroupMetadataEvent>(Filter(kinds = listOf(GroupMetadataEvent.KIND)))
.onStart { emit(emptyList()) }
.map { groupsOnRelays(relaySet) }
.observeEvents<BaseAddressableEvent>(
Filter(
kinds =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
),
),
).onStart { emit(emptyList()) }
.map { matchingGroups(byRelay) }
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
}
private fun groupsOnRelays(relaySet: Set<NormalizedRelayUrl>): List<RelayGroupChannel> =
if (relaySet.isEmpty()) {
private fun matchingGroups(byRelay: Map<NormalizedRelayUrl, GroupDiscoveryConstraint>): List<RelayGroupChannel> =
if (byRelay.isEmpty()) {
emptyList()
} else {
LocalCache.relayGroupChannels
.filter { key, channel -> key.relayUrl in relaySet && channel.event != null }
.sortedWith(
.filter { key, channel ->
channel.event != null && byRelay[key.relayUrl]?.matches(channel) == true
}.sortedWith(
compareByDescending<RelayGroupChannel> { it.memberCount() }
.thenBy { it.toBestDisplayName().lowercase() },
)
@@ -291,6 +291,42 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = stringRes(R.string.relay_group_section_discovery),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringRes(R.string.relay_group_section_discovery_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
OutlinedTextField(
value = viewModel.topics.value,
onValueChange = {
viewModel.topics.value = it
viewModel.markTouched()
},
singleLine = true,
label = { Text(stringRes(R.string.relay_group_field_topics)) },
placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
OutlinedTextField(
value = viewModel.geohash.value,
onValueChange = {
viewModel.geohash.value = it
viewModel.markTouched()
},
singleLine = true,
label = { Text(stringRes(R.string.relay_group_field_geohash)) },
placeholder = { Text(stringRes(R.string.relay_group_field_geohash_hint)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = stringRes(R.string.relay_group_section_permissions),
@@ -75,6 +75,12 @@ class RelayGroupMetadataViewModel : ViewModel() {
val about = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
/** Comma/space-separated topic hashtags; drives the discovery hashtag filter. */
val topics = mutableStateOf(TextFieldValue())
/** A single geohash for the group's location; drives the discovery geo filter. */
val geohash = mutableStateOf(TextFieldValue())
var isPrivate by mutableStateOf(false)
var isClosed by mutableStateOf(false)
var isHidden by mutableStateOf(false)
@@ -126,8 +132,29 @@ class RelayGroupMetadataViewModel : ViewModel() {
isClosed = channel.isClosed()
isHidden = event?.isHidden() ?: false
isRestricted = event?.isRestricted() ?: false
topics.value = TextFieldValue(event?.hashtags()?.distinct()?.joinToString(" ") ?: "")
// Stored geohashes are mip-mapped into every prefix; the last (longest) is the real one.
geohash.value = TextFieldValue(event?.geohashes()?.maxByOrNull { it.length } ?: "")
}
/** Split the topics field into distinct, non-blank, lowercased hashtags (leading `#` dropped). */
private fun parseTopics(): List<String> =
topics.value.text
.split(',', ' ', '\n', '\t')
.map { it.trim().removePrefix("#").lowercase() }
.filter { it.isNotBlank() }
.distinct()
/** The single geohash (lowercased, `#` stripped), or empty when none. */
private fun parseGeohashes(): List<String> =
geohash.value.text
.trim()
.removePrefix("#")
.lowercase()
.ifBlank { null }
?.let { listOf(it) }
?: emptyList()
fun markTouched() {
touched = true
}
@@ -179,6 +206,8 @@ class RelayGroupMetadataViewModel : ViewModel() {
picture.value.text
.trim()
.ifBlank { null }
val hashtags = parseTopics()
val geohashes = parseGeohashes()
val existing = channel
if (existing == null) {
account.createRelayGroup(
@@ -191,6 +220,8 @@ class RelayGroupMetadataViewModel : ViewModel() {
isClosed = isClosed,
isHidden = isHidden,
isRestricted = isRestricted,
hashtags = hashtags,
geohashes = geohashes,
)
} else {
account.editRelayGroupMetadata(
@@ -202,6 +233,8 @@ class RelayGroupMetadataViewModel : ViewModel() {
isClosed = isClosed,
isHidden = isHidden,
isRestricted = isRestricted,
hashtags = hashtags,
geohashes = geohashes,
)
}
}
@@ -0,0 +1,152 @@
/*
* 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.relayGroup.dal
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* The per-type match a discovery filter applies to a [RelayGroupChannel] on ONE relay.
*
* A NIP-29 group's kind-39000 is relay-signed, so the naive "author = a follow" match the
* note feeds use never fires (the author is the relay). But the *people* dimension is still
* meaningful it just lives in the roster events and the relay's own key:
* - the relay signing the 39000 may be a followed key ([ByPeople] relay-key path);
* - a follow may be a group **admin** (kind 39001) or **member** (kind 39002).
*
* Topics/geo aren't defined by NIP-29, but a cooperating relay can copy requested `t`/`g`
* tags onto the 39000 ([ByHashtags]/[ByGeohashes]).
*/
sealed interface GroupDiscoveryConstraint {
fun matches(channel: RelayGroupChannel): Boolean
/** Every group the relay hosts (Global, or a specific relay chip). */
data object AllGroups : GroupDiscoveryConstraint {
override fun matches(channel: RelayGroupChannel) = channel.event != null
}
/** I follow the relay key, or a follow is an admin/member of the group. */
data class ByPeople(
val pubkeys: Set<HexKey>,
) : GroupDiscoveryConstraint {
override fun matches(channel: RelayGroupChannel): Boolean {
if (pubkeys.isEmpty()) return false
val relayKey = channel.event?.pubKey
return (relayKey != null && relayKey in pubkeys) ||
channel.admins.any { it.pubKey in pubkeys } ||
channel.members.any { it in pubkeys }
}
}
/** The 39000 carries a `t` tag matching one of these topics (compared lowercase). */
data class ByHashtags(
val hashtags: Set<String>,
) : GroupDiscoveryConstraint {
private val lower = hashtags.mapTo(mutableSetOf()) { it.lowercase() }
override fun matches(channel: RelayGroupChannel): Boolean {
if (lower.isEmpty()) return false
return channel.event?.hashtags()?.any { it.lowercase() in lower } == true
}
}
/** The 39000 carries a `g` tag matching one of these geohashes (mip-map prefixes intersect). */
data class ByGeohashes(
val geohashes: Set<String>,
) : GroupDiscoveryConstraint {
private val lower = geohashes.mapTo(mutableSetOf()) { it.lowercase() }
override fun matches(channel: RelayGroupChannel): Boolean {
if (lower.isEmpty()) return false
return channel.event?.geohashes()?.any { it.lowercase() in lower } == true
}
}
/** All-follows big-OR: a group matches if ANY of its people/topic/geo lenses match. */
data class AnyOf(
val constraints: List<GroupDiscoveryConstraint>,
) : GroupDiscoveryConstraint {
override fun matches(channel: RelayGroupChannel) = constraints.any { it.matches(channel) }
}
}
/**
* Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint]. The relays
* are exactly the relays the filter routes to; each relay's constraint carries only that relay's
* slice of the follow/topic/geo set (matching how the note feeds shard per relay). Filter kinds
* that don't map to a people/topic/geo dimension (communities, muted-only) fall back to
* [GroupDiscoveryConstraint.AllGroups] still constraining the RELAY set, just not the people.
*/
fun IFeedTopNavPerRelayFilterSet.toGroupConstraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> =
when (this) {
is GlobalTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups }
is RelayTopNavPerRelayFilterSet -> mapOf(relayUrl to GroupDiscoveryConstraint.AllGroups)
is AuthorsTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByPeople(f.authors) }
is HashtagTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByHashtags(f.hashtags) }
is LocationTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByGeohashes(f.geotags) }
is AllFollowsTopNavPerRelayFilterSet ->
set.mapValues { (_, f) ->
val lenses =
buildList {
f.authors?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByPeople(it)) }
f.hashtags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByHashtags(it)) }
f.geotags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByGeohashes(it)) }
}
when {
lenses.isEmpty() -> GroupDiscoveryConstraint.AllGroups
lenses.size == 1 -> lenses.first()
else -> GroupDiscoveryConstraint.AnyOf(lenses)
}
}
// Community / muted-authors / DVM algo selections carry no group-hosting dimension;
// show every group the resolved relays host (or nothing when there are no relays).
else -> relayKeys().associateWith { GroupDiscoveryConstraint.AllGroups }
}
/** The relays a filter set routes to, regardless of type. Used for the AllGroups fallback. */
private fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set<NormalizedRelayUrl> =
when (this) {
is GlobalTopNavPerRelayFilterSet -> set.keys
is RelayTopNavPerRelayFilterSet -> setOf(relayUrl)
is AuthorsTopNavPerRelayFilterSet -> set.keys
is HashtagTopNavPerRelayFilterSet -> set.keys
is LocationTopNavPerRelayFilterSet -> set.keys
is AllFollowsTopNavPerRelayFilterSet -> set.keys
is AllCommunitiesTopNavPerRelayFilterSet -> set.keys
is SingleCommunityTopNavPerRelayFilterSet -> set.keys
is MutedAuthorsTopNavPerRelayFilterSet -> set.keys
// DVM algo-feed selections carry no group-hosting relays.
else -> emptySet()
}
@@ -24,19 +24,29 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
/** One screen's request for the full channel directory of a single relay. */
/**
* One screen's request for a single relay's group directory. [constraint] narrows the REQ
* per top-nav filter: [GroupDiscoveryConstraint.ByHashtags]/[GroupDiscoveryConstraint.ByGeohashes]
* query only kind-39000 tagged with the topic/geo (relay must copy `t`/`g` onto the 39000);
* every other constraint pulls the broad directory (39000-39003) since the people match needs
* the rosters. Defaults to [GroupDiscoveryConstraint.AllGroups] for the "browse a relay" screen.
*/
class RelayGroupDirectoryQueryState(
val relay: NormalizedRelayUrl,
val account: Account,
val constraint: GroupDiscoveryConstraint = GroupDiscoveryConstraint.AllGroups,
)
private val RELAY_GROUP_DIRECTORY_KINDS =
@@ -76,18 +86,35 @@ class RelayGroupDirectorySubAssembler(
override fun updateFilter(
key: RelayGroupDirectoryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = key.relay,
filter =
): List<RelayBasedFilter> {
val sinceTime = since?.get(key.relay)?.time
val filter =
when (val c = key.constraint) {
is GroupDiscoveryConstraint.ByHashtags ->
Filter(
kinds = listOf(GroupMetadataEvent.KIND),
tags = mapOf(HashtagTag.TAG_NAME to c.hashtags.map { it.lowercase() }),
limit = 500,
since = sinceTime,
)
is GroupDiscoveryConstraint.ByGeohashes ->
Filter(
kinds = listOf(GroupMetadataEvent.KIND),
tags = mapOf(GeoHashTag.TAG_NAME to c.geohashes.map { it.lowercase() }),
limit = 500,
since = sinceTime,
)
// AllGroups / ByPeople / AnyOf need the rosters (39001/39002) for the people
// match and member counts, so pull the whole directory unnarrowed.
else ->
Filter(
kinds = RELAY_GROUP_DIRECTORY_KINDS,
limit = 500,
since = since?.get(key.relay)?.time,
),
),
)
since = sinceTime,
)
}
return listOf(RelayBasedFilter(relay = key.relay, filter = filter))
}
override fun id(key: RelayGroupDirectoryQueryState) = key.relay
}
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.GroupDiscoveryConstraint
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Composable
@@ -31,10 +32,11 @@ fun RelayGroupDirectorySubscription(
relay: NormalizedRelayUrl,
dataSource: RelayGroupDirectoryFilterAssembler,
accountViewModel: AccountViewModel,
constraint: GroupDiscoveryConstraint = GroupDiscoveryConstraint.AllGroups,
) {
val state =
remember(accountViewModel.account, relay) {
RelayGroupDirectoryQueryState(relay, accountViewModel.account)
remember(accountViewModel.account, relay, constraint) {
RelayGroupDirectoryQueryState(relay, accountViewModel.account, constraint)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
+6
View File
@@ -1977,6 +1977,12 @@
<string name="relay_group_field_picture">Group picture</string>
<string name="relay_group_add_photo">Add photo</string>
<string name="relay_group_change_photo">Change photo</string>
<string name="relay_group_section_discovery">Discovery</string>
<string name="relay_group_section_discovery_desc">Help people find this group. Topics and a location surface it under the matching discovery filters (if the host relay supports them).</string>
<string name="relay_group_field_topics">Topics</string>
<string name="relay_group_field_topics_hint">bitcoin, nostr, art</string>
<string name="relay_group_field_geohash">Location (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_section_permissions">Permissions</string>
<string name="relay_group_flag_private">Private</string>
<string name="relay_group_flag_private_desc">Only members can read messages.</string>
@@ -0,0 +1,163 @@
/*
* 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.relayGroup.dal
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The per-type discovery match for relay-signed NIP-29 groups. Verifies the three people
* paths ([relay-key follow / follow-is-admin / follow-is-member]) and the topic/geo tag
* matches, plus that [toGroupConstraints] resolves each top-nav filter to the right shape.
*/
class RelayGroupDiscoveryConstraintTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://groups.example/")!!
private val relayKey = "aa".repeat(32)
private val alice = "bb".repeat(32)
private val bob = "cc".repeat(32)
private val carol = "dd".repeat(32)
private val sig = "22".repeat(64)
private fun channel(
metaTags: Array<Array<String>>,
adminPubkeys: List<String> = emptyList(),
memberPubkeys: List<String> = emptyList(),
): RelayGroupChannel {
val gid = "group1"
val ch = RelayGroupChannel(GroupId(gid, relay))
ch.updateGroupInfo(
GroupMetadataEvent("00".repeat(32), relayKey, 1_000L, arrayOf(arrayOf("d", gid), *metaTags), "", sig),
)
if (adminPubkeys.isNotEmpty()) {
ch.updateAdmins(
GroupAdminsEvent(
"11".repeat(32),
relayKey,
1_000L,
arrayOf(arrayOf("d", gid), *adminPubkeys.map { arrayOf("p", it, "admin") }.toTypedArray()),
"",
sig,
),
)
}
if (memberPubkeys.isNotEmpty()) {
ch.updateMembers(
GroupMembersEvent(
"22".repeat(32),
relayKey,
1_000L,
arrayOf(arrayOf("d", gid), *memberPubkeys.map { arrayOf("p", it) }.toTypedArray()),
"",
sig,
),
)
}
return ch
}
@Test
fun allGroupsMatchesAnyLoadedGroup() {
assertTrue(GroupDiscoveryConstraint.AllGroups.matches(channel(arrayOf(arrayOf("name", "Any")))))
}
@Test
fun byPeopleMatchesFollowedRelayKey() {
val c = GroupDiscoveryConstraint.ByPeople(setOf(relayKey))
assertTrue(c.matches(channel(arrayOf(arrayOf("name", "G")))))
}
@Test
fun byPeopleMatchesFollowedAdminAndMember() {
val ch = channel(arrayOf(arrayOf("name", "G")), adminPubkeys = listOf(alice), memberPubkeys = listOf(bob))
assertTrue(GroupDiscoveryConstraint.ByPeople(setOf(alice)).matches(ch))
assertTrue(GroupDiscoveryConstraint.ByPeople(setOf(bob)).matches(ch))
}
@Test
fun byPeopleRejectsStrangers() {
val ch = channel(arrayOf(arrayOf("name", "G")), adminPubkeys = listOf(alice), memberPubkeys = listOf(bob))
assertFalse(GroupDiscoveryConstraint.ByPeople(setOf(carol)).matches(ch))
assertFalse(GroupDiscoveryConstraint.ByPeople(emptySet()).matches(ch))
}
@Test
fun byHashtagsMatchesCaseInsensitively() {
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("t", "Bitcoin")))
assertTrue(GroupDiscoveryConstraint.ByHashtags(setOf("bitcoin")).matches(ch))
assertFalse(GroupDiscoveryConstraint.ByHashtags(setOf("nostr")).matches(ch))
}
@Test
fun byGeohashesMatchesMipMapPrefix() {
// GroupMetadataEvent.build mip-maps geohashes; here we store the full one and a prefix.
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("g", "u0nd"), arrayOf("g", "u0")))
assertTrue(GroupDiscoveryConstraint.ByGeohashes(setOf("u0")).matches(ch))
assertFalse(GroupDiscoveryConstraint.ByGeohashes(setOf("9q")).matches(ch))
}
@Test
fun anyOfMatchesIfEitherLensMatches() {
val ch = channel(arrayOf(arrayOf("name", "G"), arrayOf("t", "bitcoin")))
val c =
GroupDiscoveryConstraint.AnyOf(
listOf(
GroupDiscoveryConstraint.ByPeople(setOf(carol)),
GroupDiscoveryConstraint.ByHashtags(setOf("bitcoin")),
),
)
assertTrue(c.matches(ch))
}
@Test
fun globalFilterResolvesToAllGroupsPerRelay() {
val set = GlobalTopNavPerRelayFilterSet(mapOf(relay to GlobalTopNavPerRelayFilter))
val constraints = set.toGroupConstraints()
assertEquals(GroupDiscoveryConstraint.AllGroups, constraints[relay])
}
@Test
fun authorsFilterResolvesToByPeople() {
val set = AuthorsTopNavPerRelayFilterSet(mapOf(relay to AuthorsTopNavPerRelayFilter(setOf(alice))))
val constraints = set.toGroupConstraints()
assertEquals(GroupDiscoveryConstraint.ByPeople(setOf(alice)), constraints[relay])
}
@Test
fun hashtagFilterResolvesToByHashtags() {
val set = HashtagTopNavPerRelayFilterSet(mapOf(relay to HashtagTopNavPerRelayFilter(setOf("bitcoin"))))
val constraints = set.toGroupConstraints()
assertTrue(constraints[relay] is GroupDiscoveryConstraint.ByHashtags)
}
}
@@ -28,6 +28,10 @@ import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.core.hasTagName
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -51,6 +55,19 @@ class GroupMetadataEvent(
fun picture() = tags.firstTagValue("picture")
/**
* Topic hashtags (`t` tags) the relay advertises for this group, used by the
* discovery feed's hashtag filter. NIP-29 doesn't define these; a group only
* carries them if its host relay copies the requested `t` tags onto the 39000.
*/
fun hashtags() = tags.hashtags()
/**
* Geohashes (`g` tags) the relay advertises for this group, used by the discovery
* feed's geo filter. Same relay-cooperation caveat as [hashtags].
*/
fun geohashes() = tags.geohashes()
/** Only members can read. Presence of the `private` flag; absent = public read. */
fun isPrivate() = tags.hasTagName("private")
@@ -108,6 +125,8 @@ class GroupMetadataEvent(
picture: String? = null,
status: Set<GroupStatus> = emptySet(),
supportedKinds: List<Int>? = null,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GroupMetadataEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
@@ -119,6 +138,9 @@ class GroupMetadataEvent(
supportedKinds?.let { kinds ->
add((listOf("supported_kinds") + kinds.map { it.toString() }).toTypedArray())
}
addAll(HashtagTag.assemble(hashtags))
// Mip-map each geohash into every prefix so a coarser followed geohash still matches.
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
initializer()
}
}
@@ -26,6 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -46,6 +50,10 @@ class EditMetadataEvent(
fun about() = tags.firstTagValue("about")
fun hashtags() = tags.hashtags()
fun geohashes() = tags.geohashes()
fun previousEvents() = tags.previousEvents()
override fun indexableContent() = listOfNotNull(name(), about()).joinToString("\n")
@@ -59,6 +67,8 @@ class EditMetadataEvent(
about: String? = null,
picture: String? = null,
status: Set<GroupMetadataEvent.GroupStatus> = emptySet(),
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
previousEvents: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<EditMetadataEvent>.() -> Unit = {},
@@ -68,6 +78,9 @@ class EditMetadataEvent(
about?.let { add(arrayOf("about", it)) }
picture?.let { add(arrayOf("picture", it)) }
status.forEach { add(arrayOf(it.code)) }
addAll(HashtagTag.assemble(hashtags))
// Mip-map each geohash into every prefix so a coarser followed geohash still matches.
geohashes.forEach { addAll(GeoHashTag.assemble(it).toList()) }
previous(previousEvents)
initializer()
}
@@ -319,6 +319,58 @@ class Nip29ArmadaInteropTest {
assertEquals(listOf(9, 11), parsed.supportedKinds())
}
@Test
fun parsesGroupMetadataTopicsAndGeohashes() {
// Amethyst-extension: a relay that copies requested topics/geo onto the 39000.
val e =
parse(
GroupMetadataEvent.KIND,
arrayOf(
arrayOf("d", gid),
arrayOf("name", "Bitcoin Devs"),
arrayOf("t", "bitcoin"),
arrayOf("t", "nostr"),
arrayOf("g", "u0nd"),
),
) as GroupMetadataEvent
assertEquals(listOf("bitcoin", "nostr"), e.hashtags())
assertEquals(listOf("u0nd"), e.geohashes())
}
@Test
fun buildsGroupMetadataWithTopicsAndGeohashes() {
val tags =
GroupMetadataEvent
.build(
groupId = gid,
name = "Bitcoin Devs",
hashtags = listOf("Bitcoin"),
geohashes = listOf("u0nd"),
).tags
// HashtagTag.assemble lowercases mixed-case topics alongside the original.
assertTrue(tags.any { it[0] == "t" && it[1] == "bitcoin" })
// GeoHashTag.assemble mip-maps the geohash into every prefix.
assertTrue(tags.any { it[0] == "g" && it[1] == "u0nd" })
assertTrue(tags.any { it[0] == "g" && it[1] == "u" })
val parsed = parse(GroupMetadataEvent.KIND, tags) as GroupMetadataEvent
assertTrue(parsed.hashtags().contains("bitcoin"))
assertTrue(parsed.geohashes().contains("u0nd"))
}
@Test
fun buildsEditMetadataWithTopicsAndGeohashes() {
val edit =
EditMetadataEvent.build(
groupId = gid,
name = "Bitcoin Devs",
hashtags = listOf("bitcoin"),
geohashes = listOf("u0nd"),
)
assertTrue(edit.tags.any { it[0] == "t" && it[1] == "bitcoin" })
assertTrue(edit.tags.any { it[0] == "g" && it[1] == "u0nd" })
}
@Test
fun buildsCreateAndModerationEvents() {
assertTrue(CreateGroupEvent.build(gid).tags.any { it[0] == "h" && it[1] == gid })