mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
+3
@@ -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)
|
||||
|
||||
+5
@@ -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)
|
||||
|
||||
+102
-58
@@ -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,
|
||||
|
||||
-115
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
+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()
|
||||
}
|
||||
+66
-114
@@ -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()
|
||||
}
|
||||
|
||||
+10
-37
@@ -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
|
||||
}
|
||||
|
||||
+2
-4
@@ -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)
|
||||
|
||||
+58
@@ -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()
|
||||
}
|
||||
+50
@@ -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() }
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+101
@@ -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() }
|
||||
}
|
||||
}
|
||||
+60
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+59
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+43
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+71
@@ -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()
|
||||
}
|
||||
+68
@@ -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()
|
||||
}
|
||||
+60
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
+41
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user