From 0140b837b1161fe7fa4584e4a3821531bc78f2eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 21:42:38 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../vitorpamplona/amethyst/model/Account.kt | 8 + .../ui/screen/loggedIn/AccountViewModel.kt | 31 +++- .../relayGroup/RelayGroupDiscoveryScreen.kt | 16 +- .../RelayGroupDiscoveryViewModel.kt | 96 +++++------ .../relayGroup/RelayGroupMetadataScreen.kt | 36 ++++ .../relayGroup/RelayGroupMetadataViewModel.kt | 33 ++++ .../dal/RelayGroupDiscoveryFeedFilter.kt | 152 ++++++++++++++++ .../RelayGroupDirectoryFilterAssembler.kt | 47 +++-- .../RelayGroupDirectorySubscription.kt | 6 +- amethyst/src/main/res/values/strings.xml | 6 + .../dal/RelayGroupDiscoveryConstraintTest.kt | 163 ++++++++++++++++++ .../metadata/GroupMetadataEvent.kt | 22 +++ .../moderation/EditMetadataEvent.kt | 13 ++ .../Nip29ArmadaInteropTest.kt | 52 ++++++ 14 files changed, 608 insertions(+), 73 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c8e3f08fc8..b8f6eb22d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1506,6 +1506,8 @@ class Account( isClosed: Boolean = false, isHidden: Boolean = false, isRestricted: Boolean = false, + hashtags: List = emptyList(), + geohashes: List = 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 = emptyList(), + geohashes: List = 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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 8f7dc801b9..a508365c5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1448,8 +1448,22 @@ class AccountViewModel( isClosed: Boolean, isHidden: Boolean, isRestricted: Boolean, + hashtags: List, + geohashes: List, ) = 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, + geohashes: List, ) = 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) = launchSigner { account.follow(users) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index 1964e0ad49..9b2474236f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -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, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt index 9e672f1333..7d7ab4f2a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt @@ -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 = - 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> + /** The relay → constraint map the selected top-nav filter resolved to. */ + lateinit var constraints: StateFlow> private set lateinit var groups: StateFlow> @@ -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(Filter(kinds = listOf(GroupMetadataEvent.KIND))) - .onStart { emit(emptyList()) } - .map { groupsOnRelays(relaySet) } + .observeEvents( + 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): List = - if (relaySet.isEmpty()) { + private fun matchingGroups(byRelay: Map): List = + 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 { it.memberCount() } .thenBy { it.toBestDisplayName().lowercase() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index eead0bddb6..ebf4e329da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt index ab4fdf36bd..5dc7735f39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataViewModel.kt @@ -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 = + 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 = + 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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt new file mode 100644 index 0000000000..ae560deb4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt @@ -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, + ) : 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, + ) : 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, + ) : 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 { + 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 = + 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 = + 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() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt index e39e019fdc..89fe60559d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt @@ -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 = - listOf( - RelayBasedFilter( - relay = key.relay, - filter = + ): List { + 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt index c33473ae25..9f01f66b68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt @@ -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) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2f85dd4332..fc48c5f138 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1977,6 +1977,12 @@ Group picture Add photo Change photo + Discovery + Help people find this group. Topics and a location surface it under the matching discovery filters (if the host relay supports them). + Topics + bitcoin, nostr, art + Location (geohash) + u0nd Permissions Private Only members can read messages. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt new file mode 100644 index 0000000000..5a6c8910b1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryConstraintTest.kt @@ -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>, + adminPubkeys: List = emptyList(), + memberPubkeys: List = 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt index 09b7b07d42..618c41fab1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupMetadataEvent.kt @@ -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 = emptySet(), supportedKinds: List? = null, + hashtags: List = emptyList(), + geohashes: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> 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() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt index a5f263a96d..00695e733e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/EditMetadataEvent.kt @@ -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 = emptySet(), + hashtags: List = emptyList(), + geohashes: List = emptyList(), previousEvents: List = emptyList(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> 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() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt index f5d3f54aae..33703c3ae3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/Nip29ArmadaInteropTest.kt @@ -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 })