refactor: rebuild relay-group discovery on the canonical feed stack

Restructures the discovery feed to match the gitRepositories package exactly —
the shape every feed screen in the app uses — replacing the bespoke ViewModel +
per-relay directory fan-out.

New package layout (mirrors gitRepositories/):
- dal/RelayGroupDiscoveryFeedFilter — AdditiveFeedFilter<Note> over the 39000
  addressables; GroupDiscoveryConstraint (moved to its own file) supplies the
  relay-signed match (relay-key / admin / member, or #t/#g tag).
- datasource/RelayGroupsDiscoveryFilter — makeRelayGroupsDiscoveryFilter per-type
  dispatch over IFeedTopNavPerRelayFilterSet.
- datasource/subassemblies/ — FilterRelayGroupsGlobal / ByFollows / ByAuthors
  (+ muted) / ByHashtag / ByGeohashes / ByCommunity, plus the shared directory
  builder. Authors can't be a REQ constraint (a 39000 is relay-signed), so the
  people filters pull the directory per relay and the dal narrows locally; only
  topic/geo carry a relay-side #t/#g constraint.
- datasource/RelayGroupsDiscoveryFilterAssembler + SubAssembler
  (PerUserAndFollowListEoseManager) + FilterAssemblerSubscription.

Wiring, parallel to gitRepositories:
- AccountFeedContentStates.relayGroupsDiscoveryFeed (+ update/delete/trim fan-out)
- TopNavFilterState.relayGroupsDiscoveryRoutes
- RelaySubscriptionsCoordinator.relayGroupsDiscovery
- ScrollStateKeys.RELAY_GROUPS_DISCOVERY_SCREEN

Screen now runs on FeedContentState + RefresheableBox + RenderFeedContentState
(custom onLoaded rendering the joinable group cards). Old ViewModel deleted; the
constraint additions to the directory assembler reverted (browse-a-relay path
keeps the plain broad directory).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 22:17:50 +00:00
parent 0140b837b1
commit dc5bf6499a
21 changed files with 1023 additions and 328 deletions
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
@@ -121,6 +122,7 @@ class RelaySubscriptionsCoordinator(
val relayGroupRoster = RelayGroupRosterFilterAssembler(client)
val relayGroupThreads = RelayGroupThreadsFilterAssembler(client)
val relayGroupPreview = RelayGroupPreviewFilterAssembler(client)
val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client)
val chatroom = ChatroomFilterAssembler(client)
val community = CommunityFilterAssembler(client)
val gitRepository = RepositoryFilterAssembler(client)
@@ -186,6 +188,7 @@ class RelaySubscriptionsCoordinator(
relayGroupRoster,
relayGroupThreads,
relayGroupPreview,
relayGroupsDiscovery,
account,
accountForeground,
home,
@@ -67,6 +67,7 @@ object ScrollStateKeys {
const val PICTURES_SCREEN = "PicturesFeed"
const val WORKOUTS_SCREEN = "WorkoutsFeed"
const val GIT_REPOSITORIES_SCREEN = "GitRepositoriesFeed"
const val RELAY_GROUPS_DISCOVERY_SCREEN = "RelayGroupsDiscoveryFeed"
const val CALENDARS_SCREEN = "CalendarsFeed"
const val CALENDAR_COLLECTIONS_SCREEN = "CalendarCollectionsFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
@@ -328,6 +328,24 @@ class TopNavFilterState(
)
}
private val _relayGroupsDiscoveryRoutes =
combineTransform(
livePeopleListsFlow,
liveInterestFlows,
) { peopleLists, interests ->
checkNotInMainThread()
emit(
listOf(
// Relay-group discovery routes to relays by author, hashtag and geohash, plus a
// favorite-relay chip; mirrors the kind3 catalog like the git-repositories feed.
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
peopleLists,
interests,
listOf(muteListFollow),
).flatten().toImmutableList(),
)
}
private val _podcastRoutes =
combineTransform(
livePeopleListsFlow,
@@ -426,6 +444,11 @@ class TopNavFilterState(
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
val relayGroupsDiscoveryRoutes =
_relayGroupsDiscoveryRoutes
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow))
val podcastRoutes =
_podcastRoutes
.flowOn(Dispatchers.IO)
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFi
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarAppointmentsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.CalendarCollectionsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.RelayGroupDiscoveryFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal.CommunitiesFeedFilter
@@ -113,6 +114,7 @@ class AccountFeedContentStates(
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
val workoutsFeed = FeedContentState(WorkoutFeedFilter(account), scope, LocalCache)
val gitRepositoriesFeed = FeedContentState(GitRepositoriesFeedFilter(account), scope, LocalCache)
val relayGroupsDiscoveryFeed = FeedContentState(RelayGroupDiscoveryFeedFilter(account), scope, LocalCache)
val calendarAppointmentsFeed = FeedContentState(CalendarAppointmentsFeedFilter(account), scope, LocalCache)
val calendarCollectionsFeed = FeedContentState(CalendarCollectionsFeedFilter(account), scope, LocalCache)
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
@@ -231,6 +233,7 @@ class AccountFeedContentStates(
picturesFeed.updateFeedWith(newNotes)
workoutsFeed.updateFeedWith(newNotes)
gitRepositoriesFeed.updateFeedWith(newNotes)
relayGroupsDiscoveryFeed.updateFeedWith(newNotes)
productsFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
publicChatsFeed.updateFeedWith(newNotes)
@@ -293,6 +296,7 @@ class AccountFeedContentStates(
picturesFeed.deleteFromFeed(newNotes)
workoutsFeed.deleteFromFeed(newNotes)
gitRepositoriesFeed.deleteFromFeed(newNotes)
relayGroupsDiscoveryFeed.deleteFromFeed(newNotes)
productsFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
publicChatsFeed.deleteFromFeed(newNotes)
@@ -351,6 +355,7 @@ class AccountFeedContentStates(
picturesFeed.trimToSize(maxItems)
workoutsFeed.trimToSize(maxItems)
gitRepositoriesFeed.trimToSize(maxItems)
relayGroupsDiscoveryFeed.trimToSize(maxItems)
calendarAppointmentsFeed.trimToSize(maxItems)
calendarCollectionsFeed.trimToSize(maxItems)
productsFeed.trimToSize(maxItems)
@@ -22,15 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relay
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
@@ -41,8 +39,9 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,62 +50,66 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupDirectorySubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/**
* Discover NIP-29 groups across the relay set the top-bar filter resolves to. Mirrors the
* Git/Pictures feeds: a [FeedFilterSpinner] persists the selection to
* `defaultRelayGroupsDiscoveryFollowList` (Global / Follows / a followed hashtag or geohash /
* a specific relay — favorite relays show up as relay chips). The group directory query is
* fanned out to every relay in that set and each group is listed as a joinable card.
* Discover NIP-29 groups across the relay set the top-bar filter resolves to. Built on the shared
* feed stack exactly like the Git-repositories screen: a [FeedContentState]
* ([com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates.relayGroupsDiscoveryFeed])
* fed by the per-type datasource, a [FeedFilterSpinner] persisting the selection to
* `defaultRelayGroupsDiscoveryFollowList`, and a [RefresheableBox] + [RenderFeedContentState]. Rows
* are the relay-signed 39000 metadata notes, rendered as joinable group cards.
*/
@Composable
fun RelayGroupDiscoveryScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: RelayGroupDiscoveryViewModel = viewModel()
viewModel.init(accountViewModel.account)
RelayGroupDiscoveryScreen(
feedContentState = accountViewModel.feedStates.relayGroupsDiscoveryFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
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 (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,
constraint,
)
}
}
@Composable
fun RelayGroupDiscoveryScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedContentState)
WatchAccountForRelayGroupDiscovery(feedContentState, accountViewModel)
RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel)
Scaffold(
topBar = {
val options by accountViewModel.feedStates.feedListOptions.kind3GlobalPeopleRoutes
val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
.collectAsStateWithLifecycle()
val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes
.collectAsStateWithLifecycle()
TopBarExtensibleWithBackButton(
title = {
@@ -131,38 +134,79 @@ fun RelayGroupDiscoveryScreen(
)
},
) { padding ->
if (groups.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
text =
if (selectedFilter == TopFilter.Global) {
stringRes(R.string.relay_group_discovery_empty)
} else {
stringRes(R.string.relay_group_discovery_empty_filtered)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(32.dp),
)
}
} else {
val myPubkey = accountViewModel.userProfile().pubkeyHex
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(groups, key = { it.groupId.toKey() }) { channel ->
RelayGroupDiscoveryCard(
channel = channel,
myPubkey = myPubkey,
isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays,
Column(Modifier.padding(padding)) {
RefresheableBox(feedContentState, true) {
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.RELAY_GROUPS_DISCOVERY_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = feedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = null,
onLoaded = { loaded ->
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(Modifier.fillMaxWidth(), state = listState) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
RelayGroupDiscoveryRow(item, accountViewModel, nav)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
},
)
HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
}
}
@Composable
private fun WatchAccountForRelayGroupDiscovery(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listName by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList
.collectAsStateWithLifecycle()
val perRelay by accountViewModel.account.liveRelayGroupsDiscoveryFollowListsPerRelay
.collectAsStateWithLifecycle()
LaunchedEffect(listName, perRelay) {
feedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
/**
* Resolves the 39000 metadata note to its live [RelayGroupChannel] (keyed by host relay + group
* id) and recomposes in place as the relay-signed metadata / roster changes, so member counts and
* membership stay current without moving the row.
*/
@Composable
private fun RelayGroupDiscoveryRow(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event as? GroupMetadataEvent ?: return
val relay = note.relays.firstOrNull() ?: return
val baseChannel = remember(event.groupId(), relay) { LocalCache.getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) }
val channelState by baseChannel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val channel = channelState.channel as? RelayGroupChannel ?: baseChannel
val favoriteRelays by accountViewModel.account.relayFeedsList.flow
.collectAsStateWithLifecycle()
RelayGroupDiscoveryCard(
channel = channel,
myPubkey = accountViewModel.userProfile().pubkeyHex,
isFavoriteRelay = channel.groupId.relayUrl in favoriteRelays,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
private fun RelayGroupDiscoveryCard(
channel: RelayGroupChannel,
@@ -1,115 +0,0 @@
/*
* 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
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
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.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
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
/**
* 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
/** 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>>
private set
@OptIn(ExperimentalCoroutinesApi::class)
fun init(acc: Account) {
if (this::account.isInitialized) return
account = acc
constraints =
account.liveRelayGroupsDiscoveryFollowListsPerRelay
.map { it.toGroupConstraints() }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
groups =
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<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 matchingGroups(byRelay: Map<NormalizedRelayUrl, GroupDiscoveryConstraint>): List<RelayGroupChannel> =
if (byRelay.isEmpty()) {
emptyList()
} else {
LocalCache.relayGroupChannels
.filter { key, channel ->
channel.event != null && byRelay[key.relayUrl]?.matches(channel) == true
}.sortedWith(
compareByDescending<RelayGroupChannel> { it.memberCount() }
.thenBy { it.toBestDisplayName().lowercase() },
)
}
}
@@ -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()
}
@@ -21,132 +21,84 @@
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.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/**
* 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]).
* The Relay Groups discovery feed. Rows are the relay-signed kind-39000 metadata notes; the
* top-nav filter is resolved into a per-relay [GroupDiscoveryConstraint] ([toGroupConstraints])
* that decides which groups qualify: every group for Global, relay-key / admin / member for the
* people filters (rosters read from the group's [RelayGroupChannel]), or a `#t`/`#g` tag match
* for topic/geo filters. Structurally identical to
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.dal.GitRepositoriesFeedFilter];
* the only difference is that a group's author is its relay, so the match runs through the
* roster-aware constraint rather than the author-based [com.vitorpamplona.amethyst.ui.dal.FilterByListParams].
*/
sealed interface GroupDiscoveryConstraint {
fun matches(channel: RelayGroupChannel): Boolean
class RelayGroupDiscoveryFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
/** Every group the relay hosts (Global, or a specific relay chip). */
data object AllGroups : GroupDiscoveryConstraint {
override fun matches(channel: RelayGroupChannel) = channel.event != null
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultRelayGroupsDiscoveryFollowList.value
private fun constraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints()
override fun feed(): List<Note> {
val byRelay = constraints()
val notes =
LocalCache.addressables.filterIntoSet(GroupMetadataEvent.KIND) { _, note ->
matches(note, byRelay)
}
return sort(notes)
}
/** 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 }
override fun applyFilter(newItems: Set<Note>): Set<Note> {
val byRelay = constraints()
return newItems.filterTo(HashSet()) { matches(it, byRelay) }
}
/**
* The group qualifies when it was served by a relay in the filter's set AND that relay's
* constraint accepts it. The 39000 note carries its own relay-signed metadata; its roster
* (admins/members) lives on the [RelayGroupChannel] keyed by (serving relay + group id),
* which [LocalCache.consume] created when the metadata arrived.
*/
private fun matches(
note: Note,
byRelay: Map<NormalizedRelayUrl, GroupDiscoveryConstraint>,
): Boolean {
if (byRelay.isEmpty()) return false
val event = note.event as? GroupMetadataEvent ?: return false
return note.relays.any { relay ->
val constraint = byRelay[relay] ?: return@any false
val channel = LocalCache.getRelayGroupChannelIfExists(GroupId(event.groupId(), relay))
channel != null && constraint.matches(channel)
}
}
/** 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
override fun sort(items: Set<Note>): List<Note> {
fun channelOf(note: Note): RelayGroupChannel? {
val event = note.event as? GroupMetadataEvent ?: return null
return note.relays.firstNotNullOfOrNull {
LocalCache.getRelayGroupChannelIfExists(GroupId(event.groupId(), it))
}
}
}
/** 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() }
val memberCount = items.associateWith { channelOf(it)?.memberCount() ?: 0 }
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) }
return items.sortedWith(
compareByDescending<Note> { memberCount[it] }
.thenByDescending { it.createdAt() ?: 0L }
.thenBy { it.idHex },
)
}
}
/**
* Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint]. The relays
* are exactly the relays the filter routes to; each relay's constraint carries only that relay's
* slice of the follow/topic/geo set (matching how the note feeds shard per relay). Filter kinds
* that don't map to a people/topic/geo dimension (communities, muted-only) fall back to
* [GroupDiscoveryConstraint.AllGroups] — still constraining the RELAY set, just not the people.
*/
fun IFeedTopNavPerRelayFilterSet.toGroupConstraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> =
when (this) {
is GlobalTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups }
is RelayTopNavPerRelayFilterSet -> mapOf(relayUrl to GroupDiscoveryConstraint.AllGroups)
is AuthorsTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByPeople(f.authors) }
is HashtagTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByHashtags(f.hashtags) }
is LocationTopNavPerRelayFilterSet ->
set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByGeohashes(f.geotags) }
is AllFollowsTopNavPerRelayFilterSet ->
set.mapValues { (_, f) ->
val lenses =
buildList {
f.authors?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByPeople(it)) }
f.hashtags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByHashtags(it)) }
f.geotags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByGeohashes(it)) }
}
when {
lenses.isEmpty() -> GroupDiscoveryConstraint.AllGroups
lenses.size == 1 -> lenses.first()
else -> GroupDiscoveryConstraint.AnyOf(lenses)
}
}
// Community / muted-authors / DVM algo selections carry no group-hosting dimension;
// show every group the resolved relays host (or nothing when there are no relays).
else -> relayKeys().associateWith { GroupDiscoveryConstraint.AllGroups }
}
/** The relays a filter set routes to, regardless of type. Used for the AllGroups fallback. */
private fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set<NormalizedRelayUrl> =
when (this) {
is GlobalTopNavPerRelayFilterSet -> set.keys
is RelayTopNavPerRelayFilterSet -> setOf(relayUrl)
is AuthorsTopNavPerRelayFilterSet -> set.keys
is HashtagTopNavPerRelayFilterSet -> set.keys
is LocationTopNavPerRelayFilterSet -> set.keys
is AllFollowsTopNavPerRelayFilterSet -> set.keys
is AllCommunitiesTopNavPerRelayFilterSet -> set.keys
is SingleCommunityTopNavPerRelayFilterSet -> set.keys
is MutedAuthorsTopNavPerRelayFilterSet -> set.keys
// DVM algo-feed selections carry no group-hosting relays.
else -> emptySet()
}
@@ -24,29 +24,19 @@ 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 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.
*/
/** One screen's request for the full channel directory of a single relay. */
class RelayGroupDirectoryQueryState(
val relay: NormalizedRelayUrl,
val account: Account,
val constraint: GroupDiscoveryConstraint = GroupDiscoveryConstraint.AllGroups,
)
private val RELAY_GROUP_DIRECTORY_KINDS =
@@ -86,35 +76,18 @@ class RelayGroupDirectorySubAssembler(
override fun updateFilter(
key: RelayGroupDirectoryQueryState,
since: SincePerRelayMap?,
): 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 ->
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = key.relay,
filter =
Filter(
kinds = RELAY_GROUP_DIRECTORY_KINDS,
limit = 500,
since = sinceTime,
)
}
return listOf(RelayBasedFilter(relay = key.relay, filter = filter))
}
since = since?.get(key.relay)?.time,
),
),
)
override fun id(key: RelayGroupDirectoryQueryState) = key.relay
}
@@ -24,7 +24,6 @@ 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
@@ -32,11 +31,10 @@ fun RelayGroupDirectorySubscription(
relay: NormalizedRelayUrl,
dataSource: RelayGroupDirectoryFilterAssembler,
accountViewModel: AccountViewModel,
constraint: GroupDiscoveryConstraint = GroupDiscoveryConstraint.AllGroups,
) {
val state =
remember(accountViewModel.account, relay, constraint) {
RelayGroupDirectoryQueryState(relay, accountViewModel.account, constraint)
remember(accountViewModel.account, relay) {
RelayGroupDirectoryQueryState(relay, accountViewModel.account)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
@@ -0,0 +1,58 @@
/*
* 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.datasource
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.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAllCommunities
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByCommunity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByGeohashes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.subassemblies.filterRelayGroupsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makeRelayGroupsDiscoveryFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllCommunitiesTopNavPerRelayFilterSet -> filterRelayGroupsByAllCommunities(feedSettings, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterRelayGroupsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterRelayGroupsByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterRelayGroupsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterRelayGroupsByHashtag(feedSettings, since, defaultSince)
is LocationTopNavPerRelayFilterSet -> filterRelayGroupsByGeohashes(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterRelayGroupsByMutedAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterRelayGroupsByCommunity(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,50 @@
/*
* 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.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class RelayGroupsDiscoveryQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class RelayGroupsDiscoveryFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupsDiscoveryQueryState>() {
val group =
listOf(
RelayGroupsDiscoverySubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
RelayGroupsDiscoveryFilterAssemblerSubscription(
accountViewModel.dataSources().relayGroupsDiscovery,
accountViewModel,
)
}
@Composable
fun RelayGroupsDiscoveryFilterAssemblerSubscription(
dataSource: RelayGroupsDiscoveryFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
RelayGroupsDiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,101 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class RelayGroupsDiscoverySubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupsDiscoveryQueryState>,
) : PerUserAndFollowListEoseManager<RelayGroupsDiscoveryQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: RelayGroupsDiscoveryQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makeRelayGroupsDiscoveryFilter(
feedSettings,
since,
key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtIfFilled(),
)
}
override fun user(key: RelayGroupsDiscoveryQueryState) = key.account.userProfile()
override fun list(key: RelayGroupsDiscoveryQueryState) = key.listName()
fun RelayGroupsDiscoveryQueryState.listNameFlow() = account.settings.defaultRelayGroupsDiscoveryFollowList
fun RelayGroupsDiscoveryQueryState.listName() = listNameFlow().value
fun RelayGroupsDiscoveryQueryState.followsPerRelayFlow() = account.liveRelayGroupsDiscoveryFollowListsPerRelay
fun RelayGroupsDiscoveryQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: RelayGroupsDiscoveryQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.relayGroupsDiscoveryFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,60 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
// Authors can't be pushed into the REQ (a group's 39000 is signed by its relay, never a follow),
// so an author filter contributes only its RELAY set; the directory is pulled per relay and the
// dal keeps the groups whose relay-key/admins/members intersect the selected authors.
fun filterRelayGroupsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
fun filterRelayGroupsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
@@ -0,0 +1,59 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
// A community selection has no group-hosting dimension of its own; it constrains only the RELAY
// set, so the directory is pulled per relay and every group those relays host is shown.
fun filterRelayGroupsByAllCommunities(
communitySet: AllCommunitiesTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
fun filterRelayGroupsByCommunity(
communitySet: SingleCommunityTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (communitySet.set.isEmpty()) return emptyList()
return communitySet.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
@@ -0,0 +1,43 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
// A follows filter pulls the whole directory (metadata + rosters) from each relay the follow set
// routes to; the dal keeps groups where a follow is the relay key, an admin, or a member, and any
// topic/geo groups the AllFollows big-OR also selects (all present in the directory pull).
fun filterRelayGroupsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}
@@ -0,0 +1,71 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
fun filterRelayGroupsByGeohashes(
relay: NormalizedRelayUrl,
geotags: Set<String>,
since: Long?,
): List<RelayBasedFilter> {
if (geotags.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GroupMetadataEvent.KIND),
tags = mapOf(GeoHashTag.TAG_NAME to geotags.map { it.lowercase() }.sorted()),
limit = 100,
since = since,
),
),
)
}
fun filterRelayGroupsByGeohashes(
geoSet: LocationTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long?,
): List<RelayBasedFilter> {
if (geoSet.set.isEmpty()) return emptyList()
return geoSet.set
.mapNotNull {
if (it.value.geotags.isEmpty()) {
null
} else {
filterRelayGroupsByGeohashes(
relay = it.key,
geotags = it.value.geotags,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,68 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
fun filterRelayGroupsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GroupMetadataEvent.KIND),
tags = mapOf(HashtagTag.TAG_NAME to hashtags.map { it.lowercase() }),
limit = 200,
since = since,
),
),
)
fun filterRelayGroupsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterRelayGroupsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,60 @@
/*
* 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.datasource.subassemblies
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.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/**
* A group's kind-39000 is relay-signed, so — unlike git repositories, whose author IS a follow —
* the people dimension (relay-key follow / follow-is-admin / follow-is-member) can't be expressed
* as an `authors` REQ. For Global and the people filters we therefore pull the whole directory
* (metadata + rosters) for each relay in the resolved set and let
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.RelayGroupDiscoveryFeedFilter]
* narrow it locally. Only the topic/geo filters ([filterRelayGroupsByHashtag]/[filterRelayGroupsByGeohashes])
* carry a real relay-side constraint.
*/
val RELAY_GROUP_DISCOVERY_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
)
fun filterRelayGroupsDirectory(
relay: NormalizedRelayUrl,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_DISCOVERY_KINDS,
limit = 500,
since = since,
),
),
)
@@ -0,0 +1,41 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.utils.TimeUtils
fun filterRelayGroupsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.flatMap {
filterRelayGroupsDirectory(
relay = it.key,
since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo(),
)
}
}