feat: promote Relay Groups to a top-level destination + live rosters

Renames the feature to "Relay Groups" and makes it a first-class
navigation destination rather than a Messages-tab sub-view, and keeps
joined-group membership accurate app-wide.

- Top-level Route.RelayGroups home (RelayGroupsHomeScreen): a root
  destination with the drawer top bar + bottom bar, listing the host
  relays of joined groups (each drilling into its channels) plus the
  discovery entry. Registered as NavBarItem.RELAY_GROUPS (Forum icon) in
  the drawer's Feeds section and pinnable to the bottom bar, with a
  roster preloader entry.
- Naming: user-facing strings now say "group(s)" instead of "channel(s)";
  added relay_groups_title = "Relay Groups".
- Live rosters: RelayGroupRosterFilterAssembler keeps every joined
  group's 39000/39001/39002 fresh (one #d-scoped filter per host relay,
  re-derived from the join list) while a groups-bearing screen is on top.
  Mounted on both Messages panes and the new home, so membership,
  pending→member transitions and member counts stay accurate without
  opening each chat — the gap that most affected closed/private groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 01:25:27 +00:00
parent f7be77c6e7
commit 7ffc38f55d
11 changed files with 277 additions and 13 deletions
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.Calend
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupDirectoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterFilterAssembler
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
@@ -115,6 +116,7 @@ class RelaySubscriptionsCoordinator(
// active depending on the screen.
val channel = ChannelFilterAssembler(client)
val relayGroupDirectory = RelayGroupDirectoryFilterAssembler(client)
val relayGroupRoster = RelayGroupRosterFilterAssembler(client)
val chatroom = ChatroomFilterAssembler(client)
val community = CommunityFilterAssembler(client)
val gitRepository = RepositoryFilterAssembler(client)
@@ -177,6 +179,7 @@ class RelaySubscriptionsCoordinator(
val all =
listOf(
relayGroupDirectory,
relayGroupRoster,
account,
accountForeground,
home,
@@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChannelListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen
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
@@ -371,6 +372,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.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
@@ -66,6 +66,7 @@ enum class NavBarItem {
PODCAST_EPISODES,
PODCASTS,
PUBLIC_CHATS,
RELAY_GROUPS,
FOLLOW_PACKS,
LIVE_STREAMS,
NESTS,
@@ -318,6 +319,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.AutoMirrored.Chat,
resolveRoute = { Route.PublicChats },
),
NavBarItem.RELAY_GROUPS to
NavBarItemDef(
id = NavBarItem.RELAY_GROUPS,
labelRes = R.string.relay_groups_title,
icon = MaterialSymbols.Forum,
resolveRoute = { Route.RelayGroups },
),
NavBarItem.FOLLOW_PACKS to
NavBarItemDef(
id = NavBarItem.FOLLOW_PACKS,
@@ -439,6 +447,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
@@ -653,6 +653,8 @@ sealed class Route {
val relayUrl: String,
) : Route()
@Serializable object RelayGroups : Route()
@Serializable object RelayGroupBrowse : Route()
@Serializable data class ChannelMetadataEdit(
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
@@ -131,6 +132,8 @@ private fun PreloadFor(
NavBarItem.PUBLIC_CHATS -> PublicChatsFilterAssemblerSubscription(accountViewModel)
NavBarItem.RELAY_GROUPS -> RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel)
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,85 @@
/*
* 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)
}
}
}
@@ -0,0 +1,106 @@
/*
* 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.commons.relayClient.composeSubscriptionManagers.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.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.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
/** One screen's request to keep the roster of the user's joined groups fresh. */
class RelayGroupRosterQueryState(
val account: Account,
)
/**
* Roster kinds only (39000 metadata + 39001 admins + 39002 members) — enough to
* resolve name, member count and this user's membership. Roles (39003) are pulled
* by the per-chat / directory subscriptions when actually needed.
*/
private val RELAY_GROUP_ROSTER_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
)
/**
* Keeps the relay-signed roster (metadata/admins/members) of every group the user
* has joined live while a groups-bearing screen is on top, so membership,
* pending→member transitions and member counts stay accurate in list views
* without having to open each chat. This matters most for closed/private groups,
* where the only way to confirm a join was admitted is a fresh 39002.
*
* One subscription per host relay, scoped by `#d` to just that relay's joined
* group ids — so we don't pull a relay's whole directory, only what we're in.
*/
class RelayGroupRosterFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RelayGroupRosterQueryState>() {
val group =
listOf(
RelayGroupRosterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
class RelayGroupRosterSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupRosterQueryState>,
) : PerUniqueIdEoseManager<RelayGroupRosterQueryState, Account>(client, allKeys) {
override fun updateFilter(
key: RelayGroupRosterQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
val joined = key.account.relayGroupList.liveRelayGroupList.value
if (joined.isEmpty()) return null
// Group the joined group ids by their host relay: one #d-scoped filter each.
val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId })
return idsByRelay.mapNotNull { (relayUrl, groupIds) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_ROSTER_KINDS,
tags = mapOf("d" to groupIds.distinct()),
since = since?.get(relay)?.time,
),
)
}
}
override fun id(key: RelayGroupRosterQueryState) = key.account
}
@@ -0,0 +1,45 @@
/*
* 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 com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Mount on any screen that lists the user's joined groups (the Messages tab's
* inline/grouped views, the Relay Groups home) to keep their rosters live. The
* assembler re-derives its per-relay filters from the joined-group list, so it
* follows joins/leaves automatically.
*/
@Composable
fun RelayGroupRosterSubscription(
dataSource: RelayGroupRosterFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
RelayGroupRosterQueryState(accountViewModel.account)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
@@ -43,6 +43,7 @@ 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.RelayGroupServerList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager
@@ -106,6 +107,10 @@ fun MessagesSinglePane(
val viewMode by accountViewModel.account.settings.relayGroupViewMode
.collectAsStateWithLifecycle()
// Keep joined groups' rosters live while the messages list is on top, so
// membership/pending state is accurate inline without opening each chat.
RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel)
Column {
RelayGroupViewModeToggle(accountViewModel)
if (viewMode == RelayGroupViewMode.GROUPED) {
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomVie
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupServerList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupViewModeToggle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.theme.Size20dp
@@ -102,6 +103,8 @@ fun MessagesTwoPane(
val viewMode by accountViewModel.account.settings.relayGroupViewMode
.collectAsStateWithLifecycle()
RelayGroupRosterSubscription(accountViewModel.dataSources().relayGroupRoster, accountViewModel)
Column(Modifier.fillMaxSize()) {
RelayGroupViewModeToggle(accountViewModel)
if (viewMode == RelayGroupViewMode.GROUPED) {
+14 -13
View File
@@ -1929,22 +1929,23 @@
<string name="messages_create_public_chat">Public</string>
<string name="messages_create_group">Group</string>
<string name="messages_create_public_private_chat_description">New Public or Private Group</string>
<string name="relay_groups_title">Relay Groups</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">By relay</string>
<string name="relay_group_create_title">Create a channel</string>
<string name="relay_group_create_name">Channel name</string>
<string name="relay_group_create_title">Create a group</string>
<string name="relay_group_create_name">Group name</string>
<string name="relay_group_create_topic">Topic (optional)</string>
<string name="relay_group_create_private">Private (members-only read)</string>
<string name="relay_group_create_invite_only">Invite only</string>
<string name="relay_group_create_confirm">Create</string>
<string name="relay_group_invite_title">Invite people</string>
<string name="relay_group_invite_description">Share this code so people can join this channel.</string>
<string name="relay_group_invite_description">Share this code so people can join this group.</string>
<string name="relay_group_invite_generating">Creating an invite…</string>
<string name="relay_group_invite_copied">Invite code copied</string>
<string name="relay_group_invite_code_label">Invite code (for this invite-only channel):</string>
<string name="relay_group_join_title">Join channel</string>
<string name="relay_group_invite_code_label">Invite code (for this invite-only group):</string>
<string name="relay_group_join_title">Join group</string>
<string name="relay_group_join_code_label">Invite code</string>
<string name="relay_group_join_code_hint">This channel is invite-only. Enter the code you were given.</string>
<string name="relay_group_join_code_hint">This group is invite-only. Enter the code you were given.</string>
<string name="relay_group_join_confirm">Join</string>
<string name="relay_group_pending">Requested</string>
<string name="relay_group_role_admin">Admin</string>
@@ -1954,18 +1955,18 @@
<string name="relay_group_make_admin">Make admin</string>
<string name="relay_group_make_moderator">Make moderator</string>
<string name="relay_group_demote_member">Remove role</string>
<string name="relay_group_remove_user">Remove from channel</string>
<string name="relay_group_remove_user_confirm">Remove %1$s from this channel? They will lose access until re-added or re-invited.</string>
<string name="relay_group_edit_title">Edit channel</string>
<string name="relay_group_edit_name">Channel name</string>
<string name="relay_group_remove_user">Remove from group</string>
<string name="relay_group_remove_user_confirm">Remove %1$s from this group? They will lose access until re-added or re-invited.</string>
<string name="relay_group_edit_title">Edit group</string>
<string name="relay_group_edit_name">Group name</string>
<string name="relay_group_edit_topic">Topic (optional)</string>
<string name="relay_group_edit_private">Private (members-only read)</string>
<string name="relay_group_edit_invite_only">Invite only</string>
<string name="relay_group_edit_confirm">Save</string>
<string name="relay_group_menu_members">Members</string>
<string name="relay_group_menu_edit">Edit channel</string>
<string name="relay_group_browse_title">Find channels</string>
<string name="relay_group_browse_description">Browse the channels hosted on a group relay. Paste a relay address, or pick one below.</string>
<string name="relay_group_menu_edit">Edit group</string>
<string name="relay_group_browse_title">Find groups</string>
<string name="relay_group_browse_description">Browse the groups hosted on a relay. Paste a relay address, or pick one below.</string>
<string name="relay_group_browse_relay_label">Relay address</string>
<string name="relay_group_browse_go">Browse</string>
<string name="relay_group_browse_your_relays">Relays you\'re on</string>