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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJiPHArXZ7P5EvZa7GN9fP
This commit is contained in:
Claude
2026-07-27 23:30:17 +00:00
parent 2beb75b59e
commit 7a41e21272
19 changed files with 96 additions and 98 deletions
@@ -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<BottomBarEntry>) {
if (settings.changeBottomBarItems(items)) {
sendNewAppSpecificData()
}
}
suspend fun toggleChatroomPin(room: ChatroomKey) {
settings.toggleChatroomPin(room)
sendNewAppSpecificData()
@@ -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<BottomBarEntry>): 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)
@@ -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<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -308,6 +319,11 @@ class AccountMediaPreferences(
val audioVisualizer: MutableStateFlow<VisualizerStyle>,
)
@Stable
class AccountNavigationPreferences(
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
)
@Stable
class AccountChatPreferences(
val pinnedChatrooms: MutableStateFlow<Set<ChatroomKey>>,
@@ -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<BottomBarEntry> = DefaultBottomBarEntries,
)
@Serializable
@@ -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<BottomBarEntry> = DefaultBottomBarEntries,
val showHomeNewThreadsTab: Boolean = true,
val showHomeConversationsTab: Boolean = true,
val showHomeEverythingTab: Boolean = false,
@@ -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<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val useTrackedBroadcasts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val automaticallyCreateDrafts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>> = MutableStateFlow(DefaultBottomBarEntries),
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeEverythingTab: MutableStateFlow<Boolean> = 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<BottomBarEntry>,
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),
@@ -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<BottomBarEntry>): String = if (items == DefaultBottomBarEntries) "" else JsonMapper.toJson(items)
internal fun decodeBottomBarItems(raw: String): List<BottomBarEntry>? {
if (raw.isBlank()) return DefaultBottomBarEntries
// Current format: a JSON list of BottomBarEntry (built-ins + favorites).
runCatching { return JsonMapper.fromJson<List<BottomBarEntry>>(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<List<BottomBarEntry>>(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"
}
}
@@ -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
@@ -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(
@@ -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 } }
@@ -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.
@@ -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<List<BottomBarEntry>> = account.settings.syncedSettings.navigation.bottomBarItems
fun changeBottomBarItems(items: List<BottomBarEntry>) =
launchSigner {
account.changeBottomBarItems(items)
}
fun pinnedChatroomsFlow(): StateFlow<Set<ChatroomKey>> = account.settings.syncedSettings.chats.pinnedChatrooms
fun toggleChatroomPin(room: ChatroomKey) =
@@ -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.
@@ -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 {
@@ -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()
@@ -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()
@@ -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 {
@@ -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
@@ -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<AccountNavigationPreferencesInternal>(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<AccountNavigationPreferencesInternal>("{}")
assertEquals(DefaultBottomBarEntries, decoded.bottomBarItems)
}
@Test
fun customizedBarIsStoredVerbatimAndRoundTrips() {
fun customizedBarRoundTripsThroughSyncedSettingsBlob() {
val custom =
listOf(
listOf<BottomBarEntry>(
BottomBarEntry.BuiltIn(NavBarItem.HOME),
BottomBarEntry.Favorite("url:https://example.com"),
)
val encoded = UiSharedPreferences.encodeBottomBarItems(custom)
assertEquals(custom, UiSharedPreferences.decodeBottomBarItems(encoded))
val decoded = JsonMapper.fromJson<AccountNavigationPreferencesInternal>(JsonMapper.toJson(AccountNavigationPreferencesInternal(custom)))
assertEquals(custom, decoded.bottomBarItems)
}
}