From 7a41e21272d54cbfb0075c8d6a5a2cc75c34bda6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 23:30:17 +0000 Subject: [PATCH] feat: make bottom navigation bar per-account via NIP-78 The bottom nav row configuration was an app-global setting stored in the shared DataStore, so every account shared one bar. Move it into the per-user NIP-78 app-specific data event (AppSpecificDataEvent) so each account keeps its own bar and it syncs across the user's devices. - Add `navigation.bottomBarItems` to AccountSyncedSettingsInternal (the serialized/encrypted synced-settings blob) and mirror it as a StateFlow in AccountSyncedSettings (seed / toInternal / updateFrom). - Add AccountSettings.changeBottomBarItems, Account.changeBottomBarItems (republishes the NIP-78 event), and AccountViewModel.changeBottomBarItems / bottomBarItemsFlow(). - Remove bottomBarItems from the app-global UiSettings / UiSettingsFlow / UiSharedPreferences (including the now-unused encode/decode migration helpers). - Point the live bar, navigation rail, preloaders, subscriptions and the Bottom Bar settings screen at the per-account flow. No migration from the previous app-global setting: accounts start from the default bar, matching the requested behavior. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MJiPHArXZ7P5EvZa7GN9fP --- .../vitorpamplona/amethyst/model/Account.kt | 7 +++ .../amethyst/model/AccountSettings.kt | 10 +++++ .../amethyst/model/AccountSyncedSettings.kt | 16 +++++++ .../model/AccountSyncedSettingsInternal.kt | 11 +++++ .../amethyst/model/UiSettings.kt | 3 -- .../amethyst/model/UiSettingsFlow.kt | 25 +++-------- .../model/preferences/UISharedPreferences.kt | 44 ------------------- .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../ui/navigation/bottombars/AppBottomBar.kt | 2 +- .../bottombars/AppNavigationRail.kt | 2 +- .../navigation/bottombars/BottomBarEntry.kt | 9 ++-- .../ui/screen/loggedIn/AccountViewModel.kt | 9 +++- .../loggedIn/BottomBarFeedPreloaders.kt | 2 +- .../screen/loggedIn/browser/WebAppScreen.kt | 2 +- .../datasource/ConcordChannelSubscription.kt | 2 +- .../loggedIn/embed/EmbeddedTabPreloader.kt | 2 +- .../loggedIn/favorites/NostrAppScreen.kt | 2 +- .../settings/BottomBarSettingsScreen.kt | 8 ++-- .../preferences/BottomBarPersistenceTest.kt | 36 ++++++++------- 19 files changed, 96 insertions(+), 98 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 35e7442fb0..f8e7233486 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -160,6 +160,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent @@ -1024,6 +1025,12 @@ class Account( } } + suspend fun changeBottomBarItems(items: List) { + if (settings.changeBottomBarItems(items)) { + sendNewAppSpecificData() + } + } + suspend fun toggleChatroomPin(room: ChatroomKey) { settings.toggleChatroomPin(room) sendNewAppSpecificData() 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 2b11fb7039..c7c4956a72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -473,6 +474,15 @@ class AccountSettings( return false } + fun changeBottomBarItems(newItems: List): Boolean { + if (syncedSettings.navigation.bottomBarItems.value != newItems) { + syncedSettings.navigation.bottomBarItems.tryEmit(newItems) + saveAccountSettings() + return true + } + return false + } + /** The selected default spend rail across both NWC wallets and CLINK debits. */ fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 45310e9073..a33799dd40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -79,6 +80,10 @@ class AccountSyncedSettings( MutableStateFlow(internalSettings.proofOfWork.difficulty), MutableStateFlow(PoWCategory.fromIds(internalSettings.proofOfWork.enabledCategories)), ) + val navigation = + AccountNavigationPreferences( + MutableStateFlow(internalSettings.navigation.bottomBarItems), + ) fun toInternal(): AccountSyncedSettingsInternal = AccountSyncedSettingsInternal( @@ -119,6 +124,7 @@ class AccountSyncedSettings( .map { it.id } .sorted(), ), + navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value), ) fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) { @@ -210,6 +216,11 @@ class AccountSyncedSettings( if (proofOfWork.enabledCategories.value != newPoWCategories) { proofOfWork.enabledCategories.tryEmit(newPoWCategories) } + + val newBottomBarItems = syncedSettingsInternal.navigation.bottomBarItems + if (navigation.bottomBarItems.value != newBottomBarItems) { + navigation.bottomBarItems.tryEmit(newBottomBarItems) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -308,6 +319,11 @@ class AccountMediaPreferences( val audioVisualizer: MutableStateFlow, ) +@Stable +class AccountNavigationPreferences( + val bottomBarItems: MutableStateFlow>, +) + @Stable class AccountChatPreferences( val pinnedChatrooms: MutableStateFlow>, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 3de1f9b27c..4f3f51ae95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model import android.content.res.Resources import androidx.core.os.ConfigurationCompat import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.serialization.Serializable import java.util.Locale @@ -159,6 +161,15 @@ class AccountSyncedSettingsInternal( val media: AccountMediaPreferencesInternal = AccountMediaPreferencesInternal(), val chats: AccountChatPreferencesInternal = AccountChatPreferencesInternal(), val proofOfWork: AccountPoWPreferencesInternal = AccountPoWPreferencesInternal(), + val navigation: AccountNavigationPreferencesInternal = AccountNavigationPreferencesInternal(), +) + +@Serializable +class AccountNavigationPreferencesInternal( + // The ordered list of tabs pinned to the bottom navigation bar (built-ins, + // favorite apps, and individual joined chats/groups). Defaulted so blobs + // written before this field existed decode to the app's current defaults. + var bottomBarItems: List = DefaultBottomBarEntries, ) @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index 35761beaea..c3b9d7a1ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries import kotlinx.serialization.Serializable @Stable @@ -44,7 +42,6 @@ data class UiSettings( val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS, val useTrackedBroadcasts: BooleanType = BooleanType.ALWAYS, val automaticallyCreateDrafts: BooleanType = BooleanType.ALWAYS, - val bottomBarItems: List = DefaultBottomBarEntries, val showHomeNewThreadsTab: Boolean = true, val showHomeConversationsTab: Boolean = true, val showHomeEverythingTab: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 947270eb6e..8faab9450d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -44,7 +42,6 @@ class UiSettingsFlow( val automaticallyProposeAiImprovements: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val useTrackedBroadcasts: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val automaticallyCreateDrafts: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), - val bottomBarItems: MutableStateFlow> = MutableStateFlow(DefaultBottomBarEntries), val showHomeNewThreadsTab: MutableStateFlow = MutableStateFlow(true), val showHomeConversationsTab: MutableStateFlow = MutableStateFlow(true), val showHomeEverythingTab: MutableStateFlow = MutableStateFlow(false), @@ -77,7 +74,6 @@ class UiSettingsFlow( automaticallyProposeAiImprovements, useTrackedBroadcasts, automaticallyCreateDrafts, - bottomBarItems, showHomeNewThreadsTab, showHomeConversationsTab, showHomeEverythingTab, @@ -114,7 +110,7 @@ class UiSettingsFlow( flows[12] as BooleanType, flows[13] as BooleanType, flows[14] as BooleanType, - flows[15] as List, + flows[15] as Boolean, flows[16] as Boolean, flows[17] as Boolean, flows[18] as Boolean, @@ -122,13 +118,12 @@ class UiSettingsFlow( flows[20] as Boolean, flows[21] as Boolean, flows[22] as Boolean, - flows[23] as Boolean, - flows[24] as BooleanType, - flows[25] as AccentColorType, - flows[26] as FontFamilyType, - flows[27] as FontSizeType, - flows[28] as String, - flows[29] as Boolean, + flows[23] as BooleanType, + flows[24] as AccentColorType, + flows[25] as FontFamilyType, + flows[26] as FontSizeType, + flows[27] as String, + flows[28] as Boolean, ) } @@ -149,7 +144,6 @@ class UiSettingsFlow( automaticallyProposeAiImprovements.value, useTrackedBroadcasts.value, automaticallyCreateDrafts.value, - bottomBarItems.value, showHomeNewThreadsTab.value, showHomeConversationsTab.value, showHomeEverythingTab.value, @@ -229,10 +223,6 @@ class UiSettingsFlow( automaticallyCreateDrafts.tryEmit(torSettings.automaticallyCreateDrafts) any = true } - if (bottomBarItems.value != torSettings.bottomBarItems) { - bottomBarItems.tryEmit(torSettings.bottomBarItems) - any = true - } if (showHomeNewThreadsTab.value != torSettings.showHomeNewThreadsTab) { showHomeNewThreadsTab.tryEmit(torSettings.showHomeNewThreadsTab) any = true @@ -329,7 +319,6 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.automaticallyProposeAiImprovements), MutableStateFlow(uiSettings.useTrackedBroadcasts), MutableStateFlow(uiSettings.automaticallyCreateDrafts), - MutableStateFlow(uiSettings.bottomBarItems), MutableStateFlow(uiSettings.showHomeNewThreadsTab), MutableStateFlow(uiSettings.showHomeConversationsTab), MutableStateFlow(uiSettings.showHomeEverythingTab), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index f35b3124ce..fd84b6ec30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -41,10 +41,6 @@ import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.UiSettingsFlow -import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry -import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries -import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem -import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -113,7 +109,6 @@ class UiSharedPreferences( val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") val UI_USE_TRACKED_BROADCASTS = stringPreferencesKey("ui.use_tracked_broadcasts") val UI_AUTOMATICALLY_CREATE_DRAFTS = stringPreferencesKey("ui.automatically_create_drafts") - val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items") val UI_SHOW_HOME_NEW_THREADS_TAB = booleanPreferencesKey("ui.show_home_new_threads_tab") val UI_SHOW_HOME_CONVERSATIONS_TAB = booleanPreferencesKey("ui.show_home_conversations_tab") val UI_SHOW_HOME_EVERYTHING_TAB = booleanPreferencesKey("ui.show_home_everything_tab") @@ -154,7 +149,6 @@ class UiSharedPreferences( preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) } ?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER, automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, - bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarEntries, showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true, showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true, showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false, @@ -209,7 +203,6 @@ class UiSharedPreferences( preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name - preferences[UI_BOTTOM_BAR_ITEMS] = encodeBottomBarItems(sharedSettings.bottomBarItems) preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab @@ -231,42 +224,5 @@ class UiSharedPreferences( Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" } } } - - /** - * Persists "follow the defaults" as a blank sentinel instead of the concrete default list. - * - * A user who resets the bottom bar (or who never customized it) should track whatever - * [DefaultBottomBarEntries] is in the *installed* app version. Storing the concrete list would - * pin them to today's default, so a future version that changes the default would never reach - * them. Storing a blank value instead makes [decodeBottomBarItems] resolve it back to the - * current [DefaultBottomBarEntries] on every load — i.e. the user is automatically migrated to - * the new default. Any genuinely customized bar is still stored as JSON. - */ - internal fun encodeBottomBarItems(items: List): String = if (items == DefaultBottomBarEntries) "" else JsonMapper.toJson(items) - - internal fun decodeBottomBarItems(raw: String): List? { - if (raw.isBlank()) return DefaultBottomBarEntries - // Current format: a JSON list of BottomBarEntry (built-ins + favorites). - runCatching { return JsonMapper.fromJson>(raw) } - // Configs written before the stable @SerialName discriminators used the fully-qualified - // class name as the polymorphic "type" value. Rewrite it to the short name and retry, so a - // customized bar survives the upgrade instead of silently resetting to defaults. - runCatching { - val migrated = - raw - .replace(LEGACY_BUILTIN_DISCRIMINATOR, "builtIn") - .replace(LEGACY_FAVORITE_DISCRIMINATOR, "favorite") - return JsonMapper.fromJson>(migrated) - } - // Oldest format: comma-joined NavBarItem enum names (before favorites/unified entries). - val legacy = raw.split(",").mapNotNull { name -> runCatching { NavBarItem.valueOf(name) }.getOrNull() } - if (legacy.isNotEmpty()) return legacy.map { BottomBarEntry.BuiltIn(it) } - // Unrecognizable — fall back to the defaults rather than leaving the bar empty. - return DefaultBottomBarEntries - } - - // The pre-@SerialName polymorphic discriminators (fully-qualified class names) for migration. - private const val LEGACY_BUILTIN_DISCRIMINATOR = "com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.BuiltIn" - private const val LEGACY_FAVORITE_DISCRIMINATOR = "com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.Favorite" } } 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 c4a31489c3..213fe2279e 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 @@ -348,7 +348,7 @@ fun AppNavigation( // holding their surfaces attached. Below the drawer (drawn by the layout above) and below // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems + val bottomBarItems by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() // Move every embedded app to the new account on a switch. Mounted before the layer and // the preloader so the previous account's sessions are dropped ahead of the first sweep diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index eee854286f..e6b95a4ab5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -95,7 +95,7 @@ fun AppBottomBar( // pushes). Mirrors the back-arrow rule in canPop(). if (nav.canPop()) return - val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() if (items.isEmpty()) { Spacer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt index 90ad79490a..651f47edaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -55,7 +55,7 @@ fun AppNavigationRail( nav: Nav, accountViewModel: AccountViewModel, ) { - val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() val favoritesById = remember(favorites) { favorites.associateBy { it.id } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt index 67efd64822..81fffdc9c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarEntry.kt @@ -24,10 +24,11 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * One slot in the bottom navigation bar. A single ordered list of these (persisted in - * [com.vitorpamplona.amethyst.model.UiSettings.bottomBarItems]) holds built-in destinations, - * favorite apps, and individual joined chats/groups, so the user can pin and drag-reorder them - * together in one list. + * One slot in the bottom navigation bar. A single ordered list of these (persisted per-account in + * the NIP-78 app-specific data event via + * [com.vitorpamplona.amethyst.model.AccountNavigationPreferencesInternal.bottomBarItems]) holds + * built-in destinations, favorite apps, and individual joined chats/groups, so the user can pin and + * drag-reorder them together in one list. * * - [BuiltIn] resolves its [Route][com.vitorpamplona.amethyst.ui.navigation.routes.Route] (and its * icon/label/notification badge) through [NavBarCatalog], like before. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1af2874405..dec68a0ded 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -675,7 +675,7 @@ class AccountViewModel( fun importConcordCommunities() = viewModelScope.launch(Dispatchers.IO) { val pinnedRelays = - settings.uiSettingsFlow.bottomBarItems.value + account.settings.syncedSettings.navigation.bottomBarItems.value .flatMap { when (it) { is BottomBarEntry.Concord -> it.relays @@ -1935,6 +1935,13 @@ class AccountViewModel( account.changeAudioVisualizer(style) } + fun bottomBarItemsFlow(): StateFlow> = account.settings.syncedSettings.navigation.bottomBarItems + + fun changeBottomBarItems(items: List) = + launchSigner { + account.changeBottomBarItems(items) + } + fun pinnedChatroomsFlow(): StateFlow> = account.settings.syncedSettings.chats.pinnedChatrooms fun toggleChatroomPin(room: ChatroomKey) = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 856adf38c8..e46493a16e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -64,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.datasource.Workout */ @Composable fun BottomBarFeedPreloaders(accountViewModel: AccountViewModel) { - val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() // Only built-in destinations have feeds to preload; favorite-app entries embed their own content. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index de2beb64aa..0a1f8b14f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -163,7 +163,7 @@ private fun EmbeddedWebAppTab( // SideEffect so it runs after [setActive]; the host short-circuits the identical remembered instance. SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) } - val bottomBarFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems + val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems DisposableEffect(id) { val token = EmbeddedTabHost.setActive(id) onDispose { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 89f58f6fe1..71f86fb4f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -194,7 +194,7 @@ private const val RECONNECT_RESWEEP_MIN_INTERVAL_MS = 60_000L @Composable private fun bootstrapPinnedCommunities(accountViewModel: AccountViewModel) { val account = accountViewModel.account - val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt index 7bed247a60..64f2699260 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt @@ -53,7 +53,7 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) { val context = LocalContext.current val backgroundColor = MaterialTheme.colorScheme.background.toArgb() - val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems + val bottomBarItems by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() val favoriteIds = bottomBarItems.favoriteIds() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index 05b27d3a1c..3d5d95ab97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -175,7 +175,7 @@ private fun EmbeddedNostrAppTab( // so it runs after [setActive]; the host short-circuits the identical remembered instance. SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) } - val bottomBarFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems + val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems DisposableEffect(id) { val token = EmbeddedTabHost.setActive(id) onDispose { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index c2b52e3499..7131c5c891 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -150,12 +150,14 @@ fun BottomBarSettingsScreen( @Composable fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { - val bottomBarItemsFlow = accountViewModel.settings.uiSettingsFlow.bottomBarItems + // Per-account bottom bar, synced through the NIP-78 app-specific data event. + val bottomBarItemsFlow = accountViewModel.bottomBarItemsFlow() val savedItems by bottomBarItemsFlow.collectAsStateWithLifecycle() // All pin/unpin/reorder logic lives in the holder (unit-tested); the composable only renders and - // forwards events. syncFrom re-seeds when the saved list changes elsewhere without clobbering a drag. - val state = remember { BottomBarSettingsState(savedItems) { bottomBarItemsFlow.tryEmit(it) } } + // forwards events. Each persist republishes the account's NIP-78 settings event. syncFrom re-seeds + // when the saved list changes elsewhere without clobbering a drag. + val state = remember { BottomBarSettingsState(savedItems) { accountViewModel.changeBottomBarItems(it) } } LaunchedEffect(savedItems) { state.syncFrom(savedItems) } val pinned = state.pinned diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt index 2ea2f21a23..80280c2c1c 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt @@ -20,42 +20,44 @@ */ package com.vitorpamplona.amethyst.model.preferences +import com.vitorpamplona.amethyst.model.AccountNavigationPreferencesInternal import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import org.junit.Assert.assertEquals import org.junit.Test /** - * Locks the "reset to defaults migrates automatically" behavior. - * - * Resetting the bottom bar (or never customizing it) is persisted as a blank sentinel rather than the - * concrete default list, so that a user on the defaults tracks whatever [DefaultBottomBarEntries] the - * *installed* app version ships. If a future version changes the default, that blank value resolves to - * the new default on load — the user is migrated instead of being pinned to the old default. + * Locks the per-account bottom-bar persistence: the pinned list now lives inside the NIP-78 + * app-specific data blob ([AccountNavigationPreferencesInternal], one field of + * [com.vitorpamplona.amethyst.model.AccountSyncedSettingsInternal]) rather than the app-global + * DataStore, so every account keeps its own bar and it syncs across the user's devices. */ class BottomBarPersistenceTest { @Test - fun defaultsAreStoredAsBlankSentinel() { - assertEquals("", UiSharedPreferences.encodeBottomBarItems(DefaultBottomBarEntries)) + fun defaultsRoundTripThroughSyncedSettingsBlob() { + val decoded = JsonMapper.fromJson(JsonMapper.toJson(AccountNavigationPreferencesInternal())) + assertEquals(DefaultBottomBarEntries, decoded.bottomBarItems) } @Test - fun blankSentinelDecodesToCurrentDefaults() { - // The blank sentinel resolves to whatever DefaultBottomBarEntries this build ships. Because it - // returns the current constant (not a value frozen at reset time), a future version that changes - // the default automatically migrates every user who is on the defaults. - assertEquals(DefaultBottomBarEntries, UiSharedPreferences.decodeBottomBarItems("")) + fun blobWrittenBeforeTheNavigationFieldExistedDecodesToCurrentDefaults() { + // Older clients (and older Amethyst builds) never wrote the `bottomBarItems` field. The default + // means such a blob decodes to whatever DefaultBottomBarEntries the installed build ships — no + // migration from the old app-global setting is attempted, matching the intended behavior. + val decoded = JsonMapper.fromJson("{}") + assertEquals(DefaultBottomBarEntries, decoded.bottomBarItems) } @Test - fun customizedBarIsStoredVerbatimAndRoundTrips() { + fun customizedBarRoundTripsThroughSyncedSettingsBlob() { val custom = - listOf( + listOf( BottomBarEntry.BuiltIn(NavBarItem.HOME), BottomBarEntry.Favorite("url:https://example.com"), ) - val encoded = UiSharedPreferences.encodeBottomBarItems(custom) - assertEquals(custom, UiSharedPreferences.decodeBottomBarItems(encoded)) + val decoded = JsonMapper.fromJson(JsonMapper.toJson(AccountNavigationPreferencesInternal(custom))) + assertEquals(custom, decoded.bottomBarItems) } }