diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 71f73471b3..b1e132d3e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -962,7 +962,7 @@ object LocalPreferences { discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global), polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global), pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global), - relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global), + relayGroupsDiscovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_RELAY_GROUPS_DISCOVERY_FOLLOW_LIST, null), TopFilter.Mine), napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global), nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global), workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 6ec2e769a3..002cd04e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -215,7 +215,7 @@ class AccountSettings( val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultAppRecommendationsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), - val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val clinkDebitWallets: MutableStateFlow> = MutableStateFlow(emptyList()), // The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 3b13dcc4ef..75e89017b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -114,7 +114,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupNewThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupsHomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen @@ -377,7 +376,7 @@ fun BuildNavigation( composableFromEnd { ProductsScreen(accountViewModel, nav) } composableFromEnd { ShortsScreen(accountViewModel, nav) } composableFromEnd { PublicChatsScreen(accountViewModel, nav) } - composableFromEnd { RelayGroupsHomeScreen(accountViewModel, nav) } + composableFromEnd { RelayGroupDiscoveryScreen(accountViewModel, nav) } composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, nav) } @@ -637,13 +636,6 @@ fun BuildNavigation( ) } - composableFromEndArgs { - RelayGroupDiscoveryScreen( - accountViewModel = accountViewModel, - nav = nav, - ) - } - composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } composableFromBottomArgs { NewEphemeralChatScreen(accountViewModel, nav) } composableFromBottomArgs { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9903c88929..b6301cffc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -679,8 +679,6 @@ sealed class Route { @Serializable object RelayGroupBrowse : Route() - @Serializable object RelayGroupDiscovery : Route() - @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() 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 8579921869..5135d3dda3 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 @@ -337,8 +337,8 @@ class TopNavFilterState( 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), + // favorite-relay chip; "Mine" is the joined-groups view (the tab's default). + listOf(mineFollow, allFollows, userFollows, kind3Follows, aroundMe, globalFollow), peopleLists, interests, listOf(muteListFollow), @@ -447,7 +447,7 @@ class TopNavFilterState( val relayGroupsDiscoveryRoutes = _relayGroupsDiscoveryRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(mineFollow, allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)) val podcastRoutes = _podcastRoutes 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 c2c6b3c1fd..20fafd1a25 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 @@ -33,10 +33,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -64,14 +64,19 @@ 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.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar 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.navigation.topbars.LoggedInUserPictureDrawer +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -106,35 +111,19 @@ fun RelayGroupDiscoveryScreen( WatchLifecycleAndUpdateModel(feedContentState) WatchAccountForRelayGroupDiscovery(feedContentState, accountViewModel) RelayGroupsDiscoveryFilterAssemblerSubscription(accountViewModel) + // Keep the joined groups' metadata + rosters live so the "My Groups" filter can list them + // (their host relays aren't fetched by the discovery filter set). + RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) - Scaffold( - topBar = { - val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList - .collectAsStateWithLifecycle() - val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes - .collectAsStateWithLifecycle() - TopBarExtensibleWithBackButton( - title = { - FeedFilterSpinner( - placeholderCode = selectedFilter, - explainer = stringRes(R.string.select_list_to_filter), - options = options, - onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList, - accountViewModel = accountViewModel, - ) - }, - actions = { - IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) { - Icon( - symbol = MaterialSymbols.Link, - contentDescription = stringRes(R.string.relay_group_browse_title), - modifier = Modifier.size(22.dp), - ) - } - }, - popBack = nav::popBack, - ) + DisappearingScaffold( + isInvertedLayout = false, + topBar = { RelayGroupsDiscoveryTopBar(accountViewModel, nav) }, + bottomBar = { + AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route -> + if (route == Route.RelayGroups) feedContentState.sendToTop() else nav.navBottomBar(route) + } }, + accountViewModel = accountViewModel, ) { padding -> Column(Modifier.padding(padding)) { RefresheableBox(feedContentState, true) { @@ -168,6 +157,51 @@ fun RelayGroupDiscoveryScreen( } } +/** + * Root top bar for the Relay Groups tab: the drawer opener (or back arrow on a sub-stack), the + * feed-filter spinner as title, and a browse-a-relay-by-URL action. Mirrors GitRepositoriesTopBar + * but keeps the browse action, which the shared UserDrawerSearchTopBar doesn't expose. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RelayGroupsDiscoveryTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + val selectedFilter by accountViewModel.account.settings.defaultRelayGroupsDiscoveryFollowList + .collectAsStateWithLifecycle() + val options by accountViewModel.feedStates.feedListOptions.relayGroupsDiscoveryRoutes + .collectAsStateWithLifecycle() + + ShorterTopAppBar( + title = { + FeedFilterSpinner( + placeholderCode = selectedFilter, + explainer = stringRes(R.string.select_list_to_filter), + options = options, + onSelect = accountViewModel.account.settings::changeDefaultRelayGroupsDiscoveryFollowList, + accountViewModel = accountViewModel, + ) + }, + navigationIcon = { + if (nav.canPop()) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } else { + LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) + } + }, + actions = { + IconButton(onClick = { nav.nav(Route.RelayGroupBrowse) }) { + Icon( + symbol = MaterialSymbols.Link, + contentDescription = stringRes(R.string.relay_group_browse_title), + modifier = Modifier.size(22.dp), + ) + } + }, + ) +} + @Composable private fun WatchAccountForRelayGroupDiscovery( feedContentState: FeedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index 6670409fc3..7270680b71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -71,8 +71,8 @@ fun RelayGroupServerList( } // Always offer a discovery entry, so a user with no groups yet still has - // somewhere to start. - FindChannelsRow { nav.nav(Route.RelayGroupDiscovery) } + // somewhere to start — the Relay Groups tab is the discovery feed. + FindChannelsRow { nav.nav(Route.RelayGroups) } HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt deleted file mode 100644 index 315e022816..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupsHomeScreen.kt +++ /dev/null @@ -1,85 +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.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription -import com.vitorpamplona.amethyst.ui.stringRes - -/** - * The top-level "Relay Groups" destination — one tap from the nav drawer / bottom - * bar, distinct from the DM-centric Messages tab. Lists the host relays of the - * groups the user has joined (each drilling into that relay's channels) and offers - * the discovery entry to find more. Keeps every joined group's roster live while - * on top via [RelayGroupRosterSubscription]. - */ -@Composable -fun RelayGroupsHomeScreen( - accountViewModel: AccountViewModel, - nav: INav, -) { - RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel) - - DisappearingScaffold( - isInvertedLayout = false, - topBar = { - UserDrawerSearchTopBar(accountViewModel, nav) { - Text( - text = stringRes(R.string.relay_groups_title), - fontWeight = FontWeight.Bold, - ) - } - }, - bottomBar = { - AppBottomBar(Route.RelayGroups, nav, accountViewModel) { route -> - nav.navBottomBar(route) - } - }, - accountViewModel = accountViewModel, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()), - ) { - // Joined relays (each drills into its channels), followed by the - // always-present "Find groups" discovery row. - RelayGroupServerList(accountViewModel, nav) - } - } -} 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 79d02b87f1..4d74dc3beb 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 @@ -64,9 +64,18 @@ class RelayGroupDiscoveryFeedFilter( fun followList(): TopFilter = account.settings.defaultRelayGroupsDiscoveryFollowList.value + /** + * "My Groups" = the groups I've joined. These live on their host relays (from kind 10009), not + * my outbox, so the standard [TopFilter.Mine] relay-set resolution wouldn't reach them — instead + * we scan the cache for groups where I'm the relay-key / an admin / a member. The joined groups' + * metadata + rosters are kept in cache by RelayGroupRosterSubscription mounted on the screen. + */ + private fun isMine(): Boolean = followList() is TopFilter.Mine + private fun constraints(): Map = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints() override fun feed(): List { + if (isMine()) return sort(myGroupNotes()) val byRelay = constraints() val notes = LocalCache.addressables.filterIntoSet(GroupMetadataEvent.KIND) { _, note -> @@ -75,6 +84,13 @@ class RelayGroupDiscoveryFeedFilter( return sort(notes) } + private fun myGroupNotes(): Set { + val me = account.userProfile().pubkeyHex + return LocalCache.relayGroupChannels + .filter { _, channel -> channel.event != null && channel.membershipOf(me).isMember() } + .mapNotNullTo(HashSet()) { it.metadataNote } + } + override fun applyFilter(newItems: Set): Set { val byRelay = constraints() return newItems.flatMapTo(HashSet()) { note -> @@ -100,8 +116,9 @@ class RelayGroupDiscoveryFeedFilter( note: Note, byRelay: Map, ): Boolean { - if (byRelay.isEmpty()) return false val channel = relayGroupDiscoveryChannelFor(note) ?: return false + if (isMine()) return channel.membershipOf(account.userProfile().pubkeyHex).isMember() + if (byRelay.isEmpty()) return false return byRelay[channel.groupId.relayUrl]?.matches(channel) == true }