mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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>>,
|
||||
|
||||
+11
@@ -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),
|
||||
|
||||
-44
@@ -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
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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 } }
|
||||
|
||||
+5
-4
@@ -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.
|
||||
|
||||
+8
-1
@@ -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) =
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
+19
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user