diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 1751ac47cb..1f83d9daa8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 68430b7104..4611f09a70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 9a2b7b26af..8579921869 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 50cdfd204e..cb90c18215 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index 9b2474236f..8069eeda3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt deleted file mode 100644 index 7d7ab4f2a6..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryViewModel.kt +++ /dev/null @@ -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> - private set - - lateinit var groups: StateFlow> - 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( - 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): List = - if (byRelay.isEmpty()) { - emptyList() - } else { - LocalCache.relayGroupChannels - .filter { key, channel -> - channel.event != null && byRelay[key.relayUrl]?.matches(channel) == true - }.sortedWith( - compareByDescending { it.memberCount() } - .thenBy { it.toBestDisplayName().lowercase() }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt new file mode 100644 index 0000000000..ae560deb4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/GroupDiscoveryConstraint.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal + +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * The per-type match a discovery filter applies to a [RelayGroupChannel] on ONE relay. + * + * A NIP-29 group's kind-39000 is relay-signed, so the naive "author = a follow" match the + * note feeds use never fires (the author is the relay). But the *people* dimension is still + * meaningful — it just lives in the roster events and the relay's own key: + * - the relay signing the 39000 may be a followed key ([ByPeople] relay-key path); + * - a follow may be a group **admin** (kind 39001) or **member** (kind 39002). + * + * Topics/geo aren't defined by NIP-29, but a cooperating relay can copy requested `t`/`g` + * tags onto the 39000 ([ByHashtags]/[ByGeohashes]). + */ +sealed interface GroupDiscoveryConstraint { + fun matches(channel: RelayGroupChannel): Boolean + + /** Every group the relay hosts (Global, or a specific relay chip). */ + data object AllGroups : GroupDiscoveryConstraint { + override fun matches(channel: RelayGroupChannel) = channel.event != null + } + + /** I follow the relay key, or a follow is an admin/member of the group. */ + data class ByPeople( + val pubkeys: Set, + ) : GroupDiscoveryConstraint { + override fun matches(channel: RelayGroupChannel): Boolean { + if (pubkeys.isEmpty()) return false + val relayKey = channel.event?.pubKey + return (relayKey != null && relayKey in pubkeys) || + channel.admins.any { it.pubKey in pubkeys } || + channel.members.any { it in pubkeys } + } + } + + /** The 39000 carries a `t` tag matching one of these topics (compared lowercase). */ + data class ByHashtags( + val hashtags: Set, + ) : GroupDiscoveryConstraint { + private val lower = hashtags.mapTo(mutableSetOf()) { it.lowercase() } + + override fun matches(channel: RelayGroupChannel): Boolean { + if (lower.isEmpty()) return false + return channel.event?.hashtags()?.any { it.lowercase() in lower } == true + } + } + + /** The 39000 carries a `g` tag matching one of these geohashes (mip-map prefixes intersect). */ + data class ByGeohashes( + val geohashes: Set, + ) : GroupDiscoveryConstraint { + private val lower = geohashes.mapTo(mutableSetOf()) { it.lowercase() } + + override fun matches(channel: RelayGroupChannel): Boolean { + if (lower.isEmpty()) return false + return channel.event?.geohashes()?.any { it.lowercase() in lower } == true + } + } + + /** All-follows big-OR: a group matches if ANY of its people/topic/geo lenses match. */ + data class AnyOf( + val constraints: List, + ) : GroupDiscoveryConstraint { + override fun matches(channel: RelayGroupChannel) = constraints.any { it.matches(channel) } + } +} + +/** + * Resolve the selected top-nav filter into a per-relay [GroupDiscoveryConstraint]. The relays + * are exactly the relays the filter routes to; each relay's constraint carries only that relay's + * slice of the follow/topic/geo set (matching how the note feeds shard per relay). Filter kinds + * that don't map to a people/topic/geo dimension (communities, muted-only) fall back to + * [GroupDiscoveryConstraint.AllGroups] — still constraining the RELAY set, just not the people. + */ +fun IFeedTopNavPerRelayFilterSet.toGroupConstraints(): Map = + when (this) { + is GlobalTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups } + is RelayTopNavPerRelayFilterSet -> mapOf(relayUrl to GroupDiscoveryConstraint.AllGroups) + is AuthorsTopNavPerRelayFilterSet -> + set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByPeople(f.authors) } + is HashtagTopNavPerRelayFilterSet -> + set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByHashtags(f.hashtags) } + is LocationTopNavPerRelayFilterSet -> + set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByGeohashes(f.geotags) } + is AllFollowsTopNavPerRelayFilterSet -> + set.mapValues { (_, f) -> + val lenses = + buildList { + f.authors?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByPeople(it)) } + f.hashtags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByHashtags(it)) } + f.geotags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByGeohashes(it)) } + } + when { + lenses.isEmpty() -> GroupDiscoveryConstraint.AllGroups + lenses.size == 1 -> lenses.first() + else -> GroupDiscoveryConstraint.AnyOf(lenses) + } + } + // Community / muted-authors / DVM algo selections carry no group-hosting dimension; + // show every group the resolved relays host (or nothing when there are no relays). + else -> relayKeys().associateWith { GroupDiscoveryConstraint.AllGroups } + } + +/** The relays a filter set routes to, regardless of type. Used for the AllGroups fallback. */ +private fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = + when (this) { + is GlobalTopNavPerRelayFilterSet -> set.keys + is RelayTopNavPerRelayFilterSet -> setOf(relayUrl) + is AuthorsTopNavPerRelayFilterSet -> set.keys + is HashtagTopNavPerRelayFilterSet -> set.keys + is LocationTopNavPerRelayFilterSet -> set.keys + is AllFollowsTopNavPerRelayFilterSet -> set.keys + is AllCommunitiesTopNavPerRelayFilterSet -> set.keys + is SingleCommunityTopNavPerRelayFilterSet -> set.keys + is MutedAuthorsTopNavPerRelayFilterSet -> set.keys + // DVM algo-feed selections carry no group-hosting relays. + else -> emptySet() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt index ae560deb4a..ede929f2a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt @@ -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() { + 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 = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints() + + override fun feed(): List { + 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, - ) : 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): Set { + 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, + ): 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, - ) : 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): List { + 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, - ) : 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 { - override fun matches(channel: RelayGroupChannel) = constraints.any { it.matches(channel) } + return items.sortedWith( + compareByDescending { 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 = - when (this) { - is GlobalTopNavPerRelayFilterSet -> set.keys.associateWith { GroupDiscoveryConstraint.AllGroups } - is RelayTopNavPerRelayFilterSet -> mapOf(relayUrl to GroupDiscoveryConstraint.AllGroups) - is AuthorsTopNavPerRelayFilterSet -> - set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByPeople(f.authors) } - is HashtagTopNavPerRelayFilterSet -> - set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByHashtags(f.hashtags) } - is LocationTopNavPerRelayFilterSet -> - set.mapValues { (_, f) -> GroupDiscoveryConstraint.ByGeohashes(f.geotags) } - is AllFollowsTopNavPerRelayFilterSet -> - set.mapValues { (_, f) -> - val lenses = - buildList { - f.authors?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByPeople(it)) } - f.hashtags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByHashtags(it)) } - f.geotags?.takeIf { it.isNotEmpty() }?.let { add(GroupDiscoveryConstraint.ByGeohashes(it)) } - } - when { - lenses.isEmpty() -> GroupDiscoveryConstraint.AllGroups - lenses.size == 1 -> lenses.first() - else -> GroupDiscoveryConstraint.AnyOf(lenses) - } - } - // Community / muted-authors / DVM algo selections carry no group-hosting dimension; - // show every group the resolved relays host (or nothing when there are no relays). - else -> relayKeys().associateWith { GroupDiscoveryConstraint.AllGroups } - } - -/** The relays a filter set routes to, regardless of type. Used for the AllGroups fallback. */ -private fun IFeedTopNavPerRelayFilterSet.relayKeys(): Set = - when (this) { - is GlobalTopNavPerRelayFilterSet -> set.keys - is RelayTopNavPerRelayFilterSet -> setOf(relayUrl) - is AuthorsTopNavPerRelayFilterSet -> set.keys - is HashtagTopNavPerRelayFilterSet -> set.keys - is LocationTopNavPerRelayFilterSet -> set.keys - is AllFollowsTopNavPerRelayFilterSet -> set.keys - is AllCommunitiesTopNavPerRelayFilterSet -> set.keys - is SingleCommunityTopNavPerRelayFilterSet -> set.keys - is MutedAuthorsTopNavPerRelayFilterSet -> set.keys - // DVM algo-feed selections carry no group-hosting relays. - else -> emptySet() - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt index 89fe60559d..e39e019fdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectoryFilterAssembler.kt @@ -24,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 { - 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 = + 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt index 9f01f66b68..c33473ae25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupDirectorySubscription.kt @@ -24,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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt new file mode 100644 index 0000000000..6118c05708 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilter.kt @@ -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 = + 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() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt new file mode 100644 index 0000000000..5dde33785b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssembler.kt @@ -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() { + val group = + listOf( + RelayGroupsDiscoverySubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..395e732d5e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoveryFilterAssemblerSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt new file mode 100644 index 0000000000..e88e5d509f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsDiscoverySubAssembler.kt @@ -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, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupsDiscoveryQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + @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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt new file mode 100644 index 0000000000..d947567c4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthors.kt @@ -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 { + 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 { + if (authorSet.set.isEmpty()) return emptyList() + + return authorSet.set.flatMap { + filterRelayGroupsDirectory( + relay = it.key, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt new file mode 100644 index 0000000000..6d4050bcdc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByCommunity.kt @@ -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 { + 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 { + if (communitySet.set.isEmpty()) return emptyList() + + return communitySet.set.flatMap { + filterRelayGroupsDirectory( + relay = it.key, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt new file mode 100644 index 0000000000..48d5850b18 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByFollows.kt @@ -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 { + if (followsSet.set.isEmpty()) return emptyList() + + return followsSet.set.flatMap { + filterRelayGroupsDirectory( + relay = it.key, + since = since?.get(it.key)?.time ?: defaultSince, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt new file mode 100644 index 0000000000..f0c140077b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByGeohashes.kt @@ -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, + since: Long?, +): List { + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt new file mode 100644 index 0000000000..908337a8fc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByHashtag.kt @@ -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, + since: Long? = null, +): List = + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt new file mode 100644 index 0000000000..41cd15fbbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsDirectory.kt @@ -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 = + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = RELAY_GROUP_DISCOVERY_KINDS, + limit = 500, + since = since, + ), + ), + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt new file mode 100644 index 0000000000..807f6ecf52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsGlobal.kt @@ -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 { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.flatMap { + filterRelayGroupsDirectory( + relay = it.key, + since = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneWeekAgo(), + ) + } +}