feat(nip29): grouped view mode + view-mode toggle & persistence (C, part 4)

Completes the Messages-tab integration and makes the view mode user-switchable.

- RelayGroupServerList: the GROUPED mode's relay rows (one per host relay of the
  user's joined groups), tap opens the relay's channel list; rendered above the
  DM feed in MessagesSinglePane only in GROUPED mode (MessagesPager gains a
  modifier param so it weights correctly under the section).
- RelayGroupViewModeToggle: a segmented Inline/By-relay control, shown once the
  user has joined a group; flips AccountSettings.relayGroupViewMode.
- Persistence: the view mode is saved/restored via LocalPreferences (mirrors
  defaultRelayAuthPolicy), with an updateRelayGroupViewMode setter that saves.

The full NIP-29 relay-groups feature is now navigable end-to-end: browse a
relay's channels, chat in a group, and see joined groups in the Messages tab in
either inline (default) or grouped view.

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-07 21:59:08 +00:00
parent 34c1e4b6bb
commit d5d4125978
7 changed files with 211 additions and 7 deletions
@@ -26,6 +26,7 @@ import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
@@ -159,6 +160,7 @@ private object PrefKeys {
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode"
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
@@ -512,6 +514,7 @@ object LocalPreferences {
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
// Any account that reaches a save has its notification filter in its
@@ -632,6 +635,7 @@ object LocalPreferences {
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.IF_IN_MY_LIST
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
@@ -839,6 +843,7 @@ object LocalPreferences {
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
@@ -290,6 +290,13 @@ class AccountSettings(
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
fun updateRelayGroupViewMode(mode: RelayGroupViewMode) {
if (relayGroupViewMode.value != mode) {
relayGroupViewMode.tryEmit(mode)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -0,0 +1,97 @@
/*
* 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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* The "grouped" Messages view: one row per host relay of the user's joined NIP-29
* groups. Tapping a relay opens its channel list. Rendered above the DM feed only
* in [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode.GROUPED].
*/
@Composable
fun RelayGroupServerList(
accountViewModel: AccountViewModel,
nav: INav,
) {
val servers by accountViewModel.account.relayGroupList.liveRelayGroupServers
.collectAsStateWithLifecycle()
if (servers.isEmpty()) return
Column(Modifier.fillMaxWidth()) {
servers.sorted().forEach { server ->
RelayServerRow(server) { nav.nav(Route.RelayGroupServer(server)) }
HorizontalDivider(thickness = 0.25.dp)
}
}
}
@Composable
private fun RelayServerRow(
relayUrl: String,
onClick: () -> Unit,
) {
val label = RelayUrlNormalizer.normalizeOrNull(relayUrl)?.displayUrl() ?: relayUrl
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = label,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,77 @@
/*
* 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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
/**
* A compact toggle to switch how the user's NIP-29 groups appear in the Messages
* tab — [RelayGroupViewMode.INLINE] (channels as rows with a relay chip) vs
* [RelayGroupViewMode.GROUPED] (a row per relay, drill in for its channels).
* Only shown once the user has joined at least one group.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RelayGroupViewModeToggle(accountViewModel: AccountViewModel) {
val groups by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
if (groups.isEmpty()) return
val mode by accountViewModel.account.settings.relayGroupViewMode
.collectAsStateWithLifecycle()
SingleChoiceSegmentedButtonRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
SegmentedButton(
selected = mode == RelayGroupViewMode.INLINE,
onClick = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
) {
Text(stringRes(R.string.relay_group_view_inline))
}
SegmentedButton(
selected = mode == RelayGroupViewMode.GROUPED,
onClick = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
) {
Text(stringRes(R.string.relay_group_view_grouped))
}
}
}
@@ -119,12 +119,13 @@ fun MessagesPager(
tabs: List<MessagesTabItem>,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
HorizontalPager(
state = pagerState,
userScrollEnabled = true,
modifier =
Modifier.zonedDrawerSwipe(
modifier.zonedDrawerSwipe(
pagerState = pagerState,
openDrawer = nav::openDrawer,
),
@@ -26,7 +26,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
@@ -38,6 +41,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.AmethystClickableIcon
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.rooms.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager
@@ -98,11 +103,21 @@ fun MessagesSinglePane(
},
accountViewModel = accountViewModel,
) {
MessagesPager(
pagerState,
tabs,
accountViewModel,
nav,
)
val viewMode by accountViewModel.account.settings.relayGroupViewMode
.collectAsStateWithLifecycle()
Column {
RelayGroupViewModeToggle(accountViewModel)
if (viewMode == RelayGroupViewMode.GROUPED) {
RelayGroupServerList(accountViewModel, nav)
}
MessagesPager(
pagerState,
tabs,
accountViewModel,
nav,
modifier = Modifier.weight(1f),
)
}
}
}
+2
View File
@@ -1929,6 +1929,8 @@
<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_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">By relay</string>
<string name="messages_new_message">Private</string>
<string name="messages_new_message_to">To</string>
<string name="messages_new_message_subject">Subject</string>