diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index b327657ac2..70345a203d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -50,11 +50,13 @@ import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences +import com.vitorpamplona.amethyst.model.preferences.DrawerSectionCollapsePreferences import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences +import com.vitorpamplona.amethyst.model.preferences.sharedPreferencesDataStore import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState @@ -304,6 +306,11 @@ class AppModules( // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope) + // Restore + persist which drawer section headings the user has folded away, so the side menu + // opens the way they left it (device-global: a collapsed heading is a per-device view choice, + // not an account setting worth syncing, unlike the hidden rows beside it in the drawer). + val drawerSectionCollapsePrefs = DrawerSectionCollapsePreferences(appContext.sharedPreferencesDataStore, applicationIOScope) + // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt new file mode 100644 index 0000000000..acca11936f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferences.kt @@ -0,0 +1,101 @@ +/* + * 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.model.preferences + +import androidx.compose.runtime.Stable +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId +import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames +import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Device-global persistence for the drawer section headings the user has collapsed, so the side menu + * comes back folded the way they left it instead of springing fully open on every launch. Which + * headings are folded is a per-device view choice, so unlike the hidden rows beside it in the drawer + * it is never published to relays. + * + * Mirrors [RelayGroupDeletionPreferences]: app-wide (not per-account), loads the saved names on + * construction, then writes every later change back. Takes the [DataStore] rather than a `Context` + * so the whole cycle is exercised by a plain unit test against a temp file. + * + * Construct once, eagerly. The restore is a fire-and-forget coroutine, not a barrier, so the flow + * reads as "nothing collapsed" until it lands; building this at app startup rather than on first use + * puts that read many frames ahead of the drawer's first composition (and the store's file has + * already been parsed by then, for `UiSharedPreferences`). Worst case if it ever lost that race is + * cosmetic — a heading renders open and then folds — which is why no one waits on it. + */ +@Stable +class DrawerSectionCollapsePreferences( + private val store: DataStore, + scope: CoroutineScope, +) { + private val collapsed = MutableStateFlow>(emptySet()) + + /** The collapsed headings; the drawer collects this to decide which sections render their rows. */ + val flow: StateFlow> = collapsed.asStateFlow() + + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + collapsed.drop(1).collect { persist(it) } + } + } + + /** Collapses [section] if expanded, expands it if collapsed. Safe to call from the main thread. */ + fun toggle(section: DrawerSectionId) = collapsed.update { if (section in it) it - section else it + section } + + private suspend fun restoreFromDisk() { + try { + val raw = store.data.first()[KEY] ?: return + collapsed.value = drawerSectionIdsFromNames(raw) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("DrawerSectionCollapsePrefs") { "Error reading collapsed drawer sections: ${e.message}" } + } + } + + private suspend fun persist(sections: Set) { + try { + store.edit { prefs -> prefs[KEY] = sections.toNames() } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("DrawerSectionCollapsePrefs") { "Error writing collapsed drawer sections: ${e.message}" } + } + } + + companion object { + private val KEY = stringSetPreferencesKey("ui.drawer.collapsedSections") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 6009583b38..7cc0ed944b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -586,13 +586,26 @@ fun ListContent( // Side Menu settings screen. Empty (the default) means the full stock drawer. val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle() + // Device-global and never synced: which headings the user folded away, restored from disk at + // startup so the menu opens the way they left it. Collected once here rather than per section — + // one collector feeds every heading. See DrawerSectionCollapsePreferences. + val collapsePrefs = Amethyst.instance.drawerSectionCollapsePrefs + val collapsed by collapsePrefs.flow.collectAsStateWithLifecycle() + Column(modifier) { DrawerSections.forEach { section -> // Keyed by section: hiding the last row of a section removes it from the drawer // entirely, and without a key the sections below would slide up into its slots and - // inherit its CollapsibleSection expanded/collapsed state. + // inherit its animateContentSize state, animating a height they never had. key(section.id) { - CatalogSection(section, hidden, accountViewModel, nav) + CatalogSection( + section = section, + hidden = hidden, + expanded = section.id !in collapsed, + onToggleExpand = { collapsePrefs.toggle(section.id) }, + accountViewModel = accountViewModel, + nav = nav, + ) } } @@ -641,6 +654,8 @@ private fun CreateRows(nav: INav) { fun CatalogSection( section: DrawerSection, hidden: Set, + expanded: Boolean, + onToggleExpand: () -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { @@ -650,7 +665,7 @@ fun CatalogSection( val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) } if (visible.isEmpty() && !section.hasFixedRows) return - CollapsibleSection(title = section.titleRes) { + CollapsibleSection(title = section.titleRes, expanded = expanded, onToggleExpand = onToggleExpand) { when (section.id) { DrawerSectionId.CREATE -> CreateRows(nav) DrawerSectionId.SYSTEM -> @@ -764,16 +779,17 @@ fun CatalogNavigationRow( } @Composable -fun CollapsibleSection( +private fun CollapsibleSection( title: Int, + expanded: Boolean, + onToggleExpand: () -> Unit, content: @Composable () -> Unit, ) { - var expanded by remember { mutableStateOf(true) } val sectionTitle = stringRes(title) Column(modifier = Modifier.animateContentSize()) { Row( - modifier = DrawerSectionHeaderModifier.clickable { expanded = !expanded }, + modifier = DrawerSectionHeaderModifier.clickable(onClick = onToggleExpand), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -1071,7 +1087,7 @@ fun BottomContent( private fun CollapsibleSectionPreview() { ThemeComparisonColumn { Column { - CollapsibleSection(title = R.string.drawer_section_you) { + CollapsibleSection(title = R.string.drawer_section_you, expanded = true, onToggleExpand = {}) { IconRow( title = R.string.profile, icon = MaterialSymbols.AccountCircle, @@ -1091,7 +1107,9 @@ private fun CollapsibleSectionPreview() { onClick = {}, ) } - CollapsibleSection(title = R.string.drawer_section_feeds) { + // Collapsed, to preview the other half of the heading: its rows are hidden and the + // chevron points down. Real collapse state lives in DrawerSectionCollapsePreferences. + CollapsibleSection(title = R.string.drawer_section_feeds, expanded = false, onToggleExpand = {}) { IconRow( title = R.string.pictures, icon = MaterialSymbols.Photo, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt index 2cf323c39b..17014d4dd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -73,6 +73,27 @@ enum class DrawerSectionId { SYSTEM, } +private val DrawerSectionIdsByName = DrawerSectionId.entries.associateBy { it.name } + +/** + * Parses the persisted names of the headings the user has collapsed, silently dropping any this + * build doesn't know. Mirrors [com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames]: + * names rather than ordinals, so reordering this enum renames nothing by accident, and a value left + * by a build with one more section costs that heading rather than the whole read. + * + * The stored set holds the **collapsed** headings rather than the expanded ones, for the same reason + * [DrawerItemVisibility] stores the hidden rows: a heading nobody has ever collapsed simply isn't in + * the set, so a section added in a later release opens expanded for everyone with no migration. + */ +fun drawerSectionIdsFromNames(names: Collection): Set = names.mapNotNullTo(mutableSetOf()) { DrawerSectionIdsByName[it] } + +/** + * The inverse of [drawerSectionIdsFromNames]. Unlike the NavBarItem codec this returns an unsorted + * Set rather than a sorted List: the destination is a DataStore string set, whose equality is + * already order-independent, so there is no serialized form to keep deterministic. + */ +fun Set.toNames(): Set = mapTo(mutableSetOf()) { it.name } + private val DrawerNavigateItems: List = listOf( NavBarItem.HOME, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt new file mode 100644 index 0000000000..d7c4aa0d4f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/DrawerSectionCollapsePreferencesTest.kt @@ -0,0 +1,120 @@ +/* + * 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.model.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** + * The whole point of the feature is that a collapsed heading survives a restart, and nothing about + * that is visible within one process: the write happens in a collector on the app scope, and the read + * happens in the *next* process's constructor. [session] drives both halves against a real DataStore + * on a temp file, so a test reads as "one launch, then another". + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DrawerSectionCollapsePreferencesTest { + @get:Rule val folder = TemporaryFolder() + + private fun store(scope: CoroutineScope): DataStore = PreferenceDataStoreFactory.create(scope = scope) { folder.root.resolve("shared_settings.preferences_pb") } + + /** + * One app launch over the shared temp file: restores, runs [taps], flushes the writes, then shuts + * down as a process death would. Returns what that launch ended up holding. + */ + private fun TestScope.session(taps: DrawerSectionCollapsePreferences.() -> Unit = {}): Set { + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val prefs = DrawerSectionCollapsePreferences(store(scope), scope) + advanceUntilIdle() + prefs.taps() + advanceUntilIdle() + scope.cancel() + return prefs.flow.value + } + + @Test + fun aCollapsedHeadingComesBackCollapsedInTheNextLaunch() = + runTest(StandardTestDispatcher()) { + assertEquals("nothing stored yet, so the stock drawer", emptySet(), session()) + + session { toggle(DrawerSectionId.FEEDS) } + + assertEquals(setOf(DrawerSectionId.FEEDS), session()) + } + + @Test + fun expandingAgainClearsItFromDisk() = + runTest(StandardTestDispatcher()) { + session { + toggle(DrawerSectionId.FEEDS) + toggle(DrawerSectionId.FEEDS) + } + + assertEquals(emptySet(), session()) + } + + @Test + fun eachHeadingIsRememberedIndependently() = + runTest(StandardTestDispatcher()) { + session { + toggle(DrawerSectionId.FEEDS) + toggle(DrawerSectionId.SYSTEM) + toggle(DrawerSectionId.YOU) + toggle(DrawerSectionId.YOU) + } + + assertEquals(setOf(DrawerSectionId.FEEDS, DrawerSectionId.SYSTEM), session()) + } + + @Test + fun theStoredKeyIsTheOneTheDrawerReadsBack() = + runTest(StandardTestDispatcher()) { + // Pins the on-disk key name: renaming it silently resets everyone's folded headings. + val scope = CoroutineScope(StandardTestDispatcher(testScheduler)) + val store = store(scope) + val prefs = DrawerSectionCollapsePreferences(store, scope) + advanceUntilIdle() + + prefs.toggle(DrawerSectionId.NAVIGATE) + advanceUntilIdle() + + val written = + store.data + .first() + .asMap() + .mapKeys { it.key.name } + assertEquals(setOf("NAVIGATE"), written["ui.drawer.collapsedSections"]) + scope.cancel() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt index f77c3d7523..e53efd71cf 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/navigation/DrawerSectionsTest.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSectionId import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerSections import com.vitorpamplona.amethyst.ui.navigation.drawer.MandatoryDrawerItems import com.vitorpamplona.amethyst.ui.navigation.drawer.SdkGatedDrawerItems +import com.vitorpamplona.amethyst.ui.navigation.drawer.drawerSectionIdsFromNames +import com.vitorpamplona.amethyst.ui.navigation.drawer.toNames import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -106,6 +108,26 @@ class DrawerSectionsTest { ) } + @Test + fun everySectionSurvivesTheCollapsedHeadingNameRoundTrip() { + // The collapsed-heading preference stores DrawerSectionIds by name, and CollapsibleSection is + // driven purely by that id — so a heading whose name didn't round trip would collapse on tap + // and spring open again on the next launch, with nothing failing anywhere in between. + val all = DrawerSections.map { it.id }.toSet() + + assertEquals(all, drawerSectionIdsFromNames(all.toNames())) + } + + @Test + fun aCollapsedHeadingNameFromAnotherBuildIsDroppedInsteadOfFailingTheWholeRead() { + // Stored as names precisely for this: a section that exists in a newer build (or one deleted + // since) must cost that one heading, not every other heading the user collapsed. + assertEquals( + setOf(DrawerSectionId.FEEDS), + drawerSectionIdsFromNames(setOf("FEEDS", "SOME_SECTION_FROM_THE_FUTURE")), + ) + } + @Test fun mandatoryItemsAreActuallyRenderedByASection() { // A mandatory item that no section renders would be unhideable *and* invisible — the worst