mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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() }
|
||||
}
|
||||
|
||||
+29
-2
@@ -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) }
|
||||
|
||||
+11
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-54
@@ -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() },
|
||||
)
|
||||
|
||||
+36
@@ -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),
|
||||
|
||||
+33
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+152
@@ -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()
|
||||
}
|
||||
+37
-10
@@ -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
|
||||
}
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
+163
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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()
|
||||
}
|
||||
|
||||
+52
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user