refactor: unify Relay Groups onto the discovery feed

The top-level "Relay Groups" tab was a thin server-list home screen while a
separate "Find Groups" discovery feed did the real work. They're now one screen:
the discovery feed IS the Relay Groups tab, defaulting to a "My Groups" filter.

- Route.RelayGroups now renders the discovery feed (top-level DisappearingScaffold
  + AppBottomBar + drawer top bar with the filter spinner and browse action).
- "My Groups" (TopFilter.Mine) lists the groups you've joined. These live on their
  host relays (kind 10009), not your outbox, so the filter scans the cache for
  groups where you're the relay-key / an admin / a member; the joined rosters are
  kept live by RelayGroupRosterSubscription mounted on the screen.
- Per-relay "server" browsing is still available via the relay chips in the filter;
  the grouped server rail still shows in the Messages tab (GROUPED mode).
- Default discovery filter is now Mine (was Global); the "Mine" chip is back in the
  route list.
- Deleted RelayGroupsHomeScreen and the redundant Route.RelayGroupDiscovery; the
  Messages "Find groups" row and everything else point at Route.RelayGroups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-09 14:31:02 +00:00
parent 6a663781de
commit c97e697ef0
9 changed files with 89 additions and 133 deletions
@@ -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),
@@ -215,7 +215,7 @@ class AccountSettings(
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultRelayGroupsDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
@@ -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<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupsHomeScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
@@ -637,13 +636,6 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.RelayGroupDiscovery> {
RelayGroupDiscoveryScreen(
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
@@ -679,8 +679,6 @@ sealed class Route {
@Serializable object RelayGroupBrowse : Route()
@Serializable object RelayGroupDiscovery : Route()
@Serializable data class ChannelMetadataEdit(
val id: String? = null,
) : Route()
@@ -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
@@ -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,
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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<NormalizedRelayUrl, GroupDiscoveryConstraint> = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints()
override fun feed(): List<Note> {
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<Note> {
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<Note>): Set<Note> {
val byRelay = constraints()
return newItems.flatMapTo(HashSet()) { note ->
@@ -100,8 +116,9 @@ class RelayGroupDiscoveryFeedFilter(
note: Note,
byRelay: Map<NormalizedRelayUrl, GroupDiscoveryConstraint>,
): 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
}