From 376e404c962f6a8609769d73023fe18bd835e8d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:23:15 +0000 Subject: [PATCH] Make bottom navigation bar configurable Users can now pick any subset of drawer items, in any order, to appear in the bottom bar. If zero items are selected the bottom bar is hidden. - NavBarItem enum + NavBarCatalog: every drawer destination gets an id, label, icon, and route resolver so items can be looked up by id. - UiSettings gains bottomBarItems: List; persisted in the existing UiSharedPreferences DataStore as a comma-separated enum list. - AppBottomBar reads the list from UiSettingsFlow and renders a zero- height spacer (preserving nav-bar insets) when the list is empty. - New Navigate section at the top of the drawer exposes Home, Messages, Media, Discovery, Notifications as drawer destinations. - New BottomBarSettingsScreen under All Settings lets users pin/unpin entries and drag to reorder (same UX as ReactionsSettingsScreen). --- .../amethyst/model/UiSettings.kt | 3 + .../amethyst/model/UiSettingsFlow.kt | 12 + .../model/preferences/UISharedPreferences.kt | 18 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/AppBottomBar.kt | 70 +++- .../navigation/bottombars/BottomBarRoutes.kt | 48 --- .../ui/navigation/bottombars/NavBarItem.kt | 260 ++++++++++++ .../ui/navigation/drawer/DrawerContent.kt | 68 +++ .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/settings/AllSettingsScreen.kt | 8 + .../settings/BottomBarSettingsScreen.kt | 393 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 5 + 12 files changed, 826 insertions(+), 63 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt 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 b1e9c9466..3393bd4aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import kotlinx.serialization.Serializable @Stable @@ -40,6 +42,7 @@ data class UiSettings( val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED, val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC, val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS, + val bottomBarItems: List = DefaultBottomBarItems, ) enum class ThemeType( 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 7a4bbeb1b..d4a5144ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -40,6 +42,7 @@ class UiSettingsFlow( val featureSet: MutableStateFlow = MutableStateFlow(FeatureSetType.SIMPLIFIED), val gallerySet: MutableStateFlow = MutableStateFlow(ProfileGalleryType.CLASSIC), val automaticallyProposeAiImprovements: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), + val bottomBarItems: MutableStateFlow> = MutableStateFlow(DefaultBottomBarItems), ) { val listOfFlows: List> = listOf>( @@ -56,9 +59,11 @@ class UiSettingsFlow( featureSet, gallerySet, automaticallyProposeAiImprovements, + bottomBarItems, ) // emits at every change in any of the propertyes. + @Suppress("UNCHECKED_CAST") val propertyWatchFlow: Flow = combine(listOfFlows) { flows: Array -> UiSettings( @@ -75,6 +80,7 @@ class UiSettingsFlow( flows[10] as FeatureSetType, flows[11] as ProfileGalleryType, flows[12] as BooleanType, + flows[13] as List, ) } @@ -93,6 +99,7 @@ class UiSettingsFlow( featureSet.value, gallerySet.value, automaticallyProposeAiImprovements.value, + bottomBarItems.value, ) fun update(torSettings: UiSettings): Boolean { @@ -150,6 +157,10 @@ class UiSettingsFlow( automaticallyProposeAiImprovements.tryEmit(torSettings.automaticallyProposeAiImprovements) any = true } + if (bottomBarItems.value != torSettings.bottomBarItems) { + bottomBarItems.tryEmit(torSettings.bottomBarItems) + any = true + } return any } @@ -182,6 +193,7 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.featureSet), MutableStateFlow(uiSettings.gallerySet), MutableStateFlow(uiSettings.automaticallyProposeAiImprovements), + MutableStateFlow(uiSettings.bottomBarItems), ) } } 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 c899fcbdc..586bacb81 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 @@ -38,6 +38,8 @@ 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.DefaultBottomBarItems +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -104,6 +106,7 @@ class UiSharedPreferences( val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") + val UI_BOTTOM_BAR_ITEMS = stringPreferencesKey("ui.bottom_bar_items") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -124,6 +127,7 @@ class UiSharedPreferences( featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED, gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -161,6 +165,7 @@ class UiSharedPreferences( preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name + preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name } } } catch (e: Exception) { if (e is CancellationException) throw e @@ -168,5 +173,18 @@ class UiSharedPreferences( Log.e("SharedPreferences") { "Error saving DataStore preferences: ${e.message}" } } } + + private fun decodeBottomBarItems(raw: String): List { + if (raw.isEmpty()) return emptyList() + return raw + .split(",") + .mapNotNull { name -> + try { + NavBarItem.valueOf(name) + } catch (_: IllegalArgumentException) { + null + } + } + } } } 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 357426917..12366c932 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 @@ -151,6 +151,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVani import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen @@ -297,6 +298,7 @@ fun BuildNavigation( composableFromEnd { SettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } + composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { 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 f98f1c367..126cf9a5b 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 @@ -25,9 +25,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.BottomAppBarDefaults.windowInsets import androidx.compose.material3.HorizontalDivider @@ -37,6 +39,7 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -49,6 +52,8 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size0dp import com.vitorpamplona.amethyst.ui.theme.Size10Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size23dp @Composable fun AppBottomBar( @@ -56,18 +61,33 @@ fun AppBottomBar( accountViewModel: AccountViewModel, nav: (Route) -> Unit, ) { + val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + if (items.isEmpty()) { + Spacer( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(windowInsets) + .consumeWindowInsets(windowInsets), + ) + return + } val isKeyboardState by keyboardAsState() if (isKeyboardState == KeyboardState.Closed) { - RenderBottomMenu(selectedRoute, accountViewModel, nav) + RenderBottomMenu(items, selectedRoute, accountViewModel, nav) } } @Composable private fun RenderBottomMenu( + items: List, selectedRoute: Route?, accountViewModel: AccountViewModel, nav: (Route) -> Unit, ) { + val defs = remember(items) { items.mapNotNull(NavBarCatalog::get) } + Column( modifier = Modifier @@ -84,8 +104,9 @@ private fun RenderBottomMenu( containerColor = MaterialTheme.colorScheme.background, tonalElevation = Size0dp, ) { - bottomNavigationItems.forEach { item -> - HasNewItemsIcon(item.route == selectedRoute, item, accountViewModel, nav) + defs.forEach { def -> + val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } + HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav) } } } @@ -94,7 +115,8 @@ private fun RenderBottomMenu( @Composable private fun RowScope.HasNewItemsIcon( selected: Boolean, - bottomNav: BottomBarRoute, + def: NavBarItemDef, + destination: Route, accountViewModel: AccountViewModel, nav: (Route) -> Unit, ) { @@ -103,30 +125,48 @@ private fun RowScope.HasNewItemsIcon( icon = { NotifiableIcon( selected, - bottomNav, + def, + destination, accountViewModel, ) }, selected = selected, - onClick = { nav(bottomNav.route) }, + onClick = { nav(destination) }, ) } @Composable private fun NotifiableIcon( selected: Boolean, - route: BottomBarRoute, + def: NavBarItemDef, + destination: Route, accountViewModel: AccountViewModel, ) { - Box(route.notifSize) { - Icon( - painter = painterRes(resourceId = route.icon, 0), - contentDescription = stringRes(route.contentDescriptor), - modifier = route.iconSize, - tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified, - ) + Box(Modifier.size(Size23dp)) { + val tint = if (selected) MaterialTheme.colorScheme.primary else Color.Unspecified + val iconSizeModifier = Modifier.size(Size20dp) + val description = stringRes(def.labelRes) + when (val icon = def.icon) { + is NavBarIcon.Drawable -> { + Icon( + painter = painterRes(resourceId = icon.resId, icon.reference), + contentDescription = description, + modifier = iconSizeModifier, + tint = tint, + ) + } - AddNotifIconIfNeeded(route.route, accountViewModel, Modifier.align(Alignment.TopEnd)) + is NavBarIcon.Vector -> { + Icon( + imageVector = icon.vector, + contentDescription = description, + modifier = iconSizeModifier, + tint = tint, + ) + } + } + + AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt deleted file mode 100644 index 134400932..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/BottomBarRoutes.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.navigation.bottombars - -import androidx.compose.foundation.layout.size -import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.Size23dp -import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import kotlinx.collections.immutable.persistentListOf - -class BottomBarRoute( - val route: Route, - val icon: Int, - val contentDescriptor: Int, - val notifSize: Modifier = Modifier.size(Size23dp), - val iconSize: Modifier = Modifier.size(Size20dp), -) - -val bottomNavigationItems = - persistentListOf( - BottomBarRoute(Route.Home, R.drawable.ic_home, R.string.route_home, Modifier.size(Size25dp), Modifier.size(Size24dp)), - BottomBarRoute(Route.Message, R.drawable.ic_dm, R.string.route_messages), - BottomBarRoute(Route.Video, R.drawable.ic_video, R.string.route_video), - BottomBarRoute(Route.Discover, R.drawable.ic_sensors, R.string.route_discover), - BottomBarRoute(Route.Notification(), R.drawable.ic_notifications, R.string.route_notifications), - ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt new file mode 100644 index 000000000..22fbd6b4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation.bottombars + +import androidx.annotation.DrawableRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.automirrored.outlined.Article +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.outlined.AccountBalanceWallet +import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.Drafts +import androidx.compose.material.icons.outlined.EmojiEmotions +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.MilitaryTech +import androidx.compose.material.icons.outlined.Photo +import androidx.compose.material.icons.outlined.PlayCircle +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SmartDisplay +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material.icons.outlined.Tag +import androidx.compose.ui.graphics.vector.ImageVector +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.serialization.Serializable + +/** + * Stable identifiers for every drawer destination that the user can pin to the bottom bar. + * Order in this enum has no semantic meaning — the user picks a subset and an order at runtime. + */ +@Serializable +enum class NavBarItem { + HOME, + MESSAGES, + VIDEO, + DISCOVER, + NOTIFICATIONS, + PROFILE, + MY_LISTS, + BOOKMARKS, + WEB_BOOKMARKS, + DRAFTS, + INTEREST_SETS, + EMOJI_PACKS, + WALLET, + COMMUNITIES, + ARTICLES, + PICTURES, + SHORTS, + LONGS, + BADGES, + PRODUCTS, + EMOJI_SETS, + SETTINGS, +} + +sealed class NavBarIcon { + data class Drawable( + @DrawableRes val resId: Int, + val reference: Int = 0, + ) : NavBarIcon() + + data class Vector( + val vector: ImageVector, + ) : NavBarIcon() +} + +data class NavBarItemDef( + val id: NavBarItem, + val labelRes: Int, + val icon: NavBarIcon, + val resolveRoute: (AccountViewModel) -> Route, +) + +val NavBarCatalog: Map = + linkedMapOf( + NavBarItem.HOME to + NavBarItemDef( + id = NavBarItem.HOME, + labelRes = R.string.route_home, + icon = NavBarIcon.Drawable(R.drawable.ic_home), + resolveRoute = { Route.Home }, + ), + NavBarItem.MESSAGES to + NavBarItemDef( + id = NavBarItem.MESSAGES, + labelRes = R.string.route_messages, + icon = NavBarIcon.Drawable(R.drawable.ic_dm), + resolveRoute = { Route.Message }, + ), + NavBarItem.VIDEO to + NavBarItemDef( + id = NavBarItem.VIDEO, + labelRes = R.string.route_video, + icon = NavBarIcon.Drawable(R.drawable.ic_video), + resolveRoute = { Route.Video }, + ), + NavBarItem.DISCOVER to + NavBarItemDef( + id = NavBarItem.DISCOVER, + labelRes = R.string.route_discover, + icon = NavBarIcon.Drawable(R.drawable.ic_sensors), + resolveRoute = { Route.Discover }, + ), + NavBarItem.NOTIFICATIONS to + NavBarItemDef( + id = NavBarItem.NOTIFICATIONS, + labelRes = R.string.route_notifications, + icon = NavBarIcon.Drawable(R.drawable.ic_notifications), + resolveRoute = { Route.Notification() }, + ), + NavBarItem.PROFILE to + NavBarItemDef( + id = NavBarItem.PROFILE, + labelRes = R.string.profile, + icon = NavBarIcon.Vector(Icons.Default.AccountCircle), + resolveRoute = { Route.Profile(it.userProfile().pubkeyHex) }, + ), + NavBarItem.MY_LISTS to + NavBarItemDef( + id = NavBarItem.MY_LISTS, + labelRes = R.string.my_lists, + icon = NavBarIcon.Vector(Icons.AutoMirrored.Filled.FormatListBulleted), + resolveRoute = { Route.Lists }, + ), + NavBarItem.BOOKMARKS to + NavBarItemDef( + id = NavBarItem.BOOKMARKS, + labelRes = R.string.bookmarks, + icon = NavBarIcon.Vector(Icons.Outlined.CollectionsBookmark), + resolveRoute = { Route.BookmarkGroups }, + ), + NavBarItem.WEB_BOOKMARKS to + NavBarItemDef( + id = NavBarItem.WEB_BOOKMARKS, + labelRes = R.string.web_bookmarks, + icon = NavBarIcon.Vector(Icons.Outlined.Language), + resolveRoute = { Route.WebBookmarks }, + ), + NavBarItem.DRAFTS to + NavBarItemDef( + id = NavBarItem.DRAFTS, + labelRes = R.string.drafts, + icon = NavBarIcon.Vector(Icons.Outlined.Drafts), + resolveRoute = { Route.Drafts }, + ), + NavBarItem.INTEREST_SETS to + NavBarItemDef( + id = NavBarItem.INTEREST_SETS, + labelRes = R.string.interest_sets_title, + icon = NavBarIcon.Vector(Icons.Outlined.Tag), + resolveRoute = { Route.InterestSets }, + ), + NavBarItem.EMOJI_PACKS to + NavBarItemDef( + id = NavBarItem.EMOJI_PACKS, + labelRes = R.string.manage_emoji_packs, + icon = NavBarIcon.Vector(Icons.Outlined.EmojiEmotions), + resolveRoute = { Route.EmojiPacks }, + ), + NavBarItem.WALLET to + NavBarItemDef( + id = NavBarItem.WALLET, + labelRes = R.string.wallet, + icon = NavBarIcon.Vector(Icons.Outlined.AccountBalanceWallet), + resolveRoute = { Route.Wallet }, + ), + NavBarItem.COMMUNITIES to + NavBarItemDef( + id = NavBarItem.COMMUNITIES, + labelRes = R.string.communities, + icon = NavBarIcon.Vector(Icons.Outlined.Groups), + resolveRoute = { Route.Communities }, + ), + NavBarItem.ARTICLES to + NavBarItemDef( + id = NavBarItem.ARTICLES, + labelRes = R.string.discover_reads, + icon = NavBarIcon.Vector(Icons.AutoMirrored.Outlined.Article), + resolveRoute = { Route.Articles }, + ), + NavBarItem.PICTURES to + NavBarItemDef( + id = NavBarItem.PICTURES, + labelRes = R.string.pictures, + icon = NavBarIcon.Vector(Icons.Outlined.Photo), + resolveRoute = { Route.Pictures }, + ), + NavBarItem.SHORTS to + NavBarItemDef( + id = NavBarItem.SHORTS, + labelRes = R.string.shorts, + icon = NavBarIcon.Vector(Icons.Outlined.PlayCircle), + resolveRoute = { Route.Shorts }, + ), + NavBarItem.LONGS to + NavBarItemDef( + id = NavBarItem.LONGS, + labelRes = R.string.longs, + icon = NavBarIcon.Vector(Icons.Outlined.SmartDisplay), + resolveRoute = { Route.Longs }, + ), + NavBarItem.BADGES to + NavBarItemDef( + id = NavBarItem.BADGES, + labelRes = R.string.badges, + icon = NavBarIcon.Vector(Icons.Outlined.MilitaryTech), + resolveRoute = { Route.Badges }, + ), + NavBarItem.PRODUCTS to + NavBarItemDef( + id = NavBarItem.PRODUCTS, + labelRes = R.string.discover_marketplace, + icon = NavBarIcon.Vector(Icons.Outlined.Storefront), + resolveRoute = { Route.Products }, + ), + NavBarItem.EMOJI_SETS to + NavBarItemDef( + id = NavBarItem.EMOJI_SETS, + labelRes = R.string.emoji_sets, + icon = NavBarIcon.Vector(Icons.Outlined.EmojiEmotions), + resolveRoute = { Route.BrowseEmojiSets }, + ), + NavBarItem.SETTINGS to + NavBarItemDef( + id = NavBarItem.SETTINGS, + labelRes = R.string.settings, + icon = NavBarIcon.Vector(Icons.Outlined.Settings), + resolveRoute = { Route.AllSettings }, + ), + ) + +val DefaultBottomBarItems: List = + listOf( + NavBarItem.HOME, + NavBarItem.MESSAGES, + NavBarItem.VIDEO, + NavBarItem.DISCOVER, + NavBarItem.NOTIFICATIONS, + ) 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 cebcfd271..ee1580e90 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 @@ -547,6 +547,53 @@ fun ListContent( nav: INav, ) { Column(modifier) { + CollapsibleSection(title = R.string.drawer_section_navigate) { + NavigationRow( + title = R.string.route_home, + icon = R.drawable.ic_home, + iconReference = 0, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Home, + ) + + NavigationRow( + title = R.string.route_messages, + icon = R.drawable.ic_dm, + iconReference = 0, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Message, + ) + + NavigationRow( + title = R.string.route_video, + icon = R.drawable.ic_video, + iconReference = 0, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Video, + ) + + NavigationRow( + title = R.string.route_discover, + icon = R.drawable.ic_sensors, + iconReference = 0, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Discover, + ) + + NavigationRow( + title = R.string.route_notifications, + icon = R.drawable.ic_notifications, + iconReference = 0, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + computeRoute = { Route.Notification() }, + ) + } + CollapsibleSection(title = R.string.drawer_section_you) { NavigationRow( title = R.string.profile, @@ -795,6 +842,27 @@ fun NavigationRow( ) } +@Composable +fun NavigationRow( + title: Int, + icon: Int, + iconReference: Int, + tint: Color, + nav: INav, + computeRoute: () -> Route, +) { + IconRow( + title, + icon, + iconReference, + tint, + onClick = { + nav.closeDrawer() + nav.nav(computeRoute) + }, + ) +} + @Composable fun NavigationRow( title: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 279bc10da..b73781fb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -203,6 +203,8 @@ sealed class Route { @Serializable object ReactionsSettings : Route() + @Serializable object BottomBarSettings : Route() + @Serializable object CallSettings : Route() @Serializable object Lists : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index f040d037e..072c26c95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.CloudUpload +import androidx.compose.material.icons.outlined.Dashboard import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.GroupAdd @@ -209,6 +210,13 @@ fun AllSettingsScreen( tint = tint, onClick = { nav.nav(Route.ReactionsSettings) }, ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.bottom_bar_settings, + icon = Icons.Outlined.Dashboard, + tint = tint, + onClick = { nav.nav(Route.BottomBarSettings) }, + ) HorizontalDivider(thickness = 4.dp) SettingsSectionHeader(R.string.danger_zone) accountViewModel.account.settings.keyPair.privKey?.let { 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 new file mode 100644 index 000000000..09b15e84c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DragIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarIcon +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow + +@Composable +@Preview(device = "spec:width=2100px,height=2340px,dpi=440") +fun BottomBarSettingsScreenPreview() { + ThemeComparisonRow { + BottomBarSettingsScreen( + mockAccountViewModel(), + EmptyNav(), + ) + } +} + +@Composable +fun BottomBarSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.bottom_bar_settings), nav::popBack) + }, + ) { padding -> + Column(Modifier.padding(padding)) { + BottomBarSettingsContent(accountViewModel) + } + } +} + +@Composable +fun BottomBarSettingsContent(accountViewModel: AccountViewModel) { + val pinned by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + + var items by remember(pinned) { + mutableStateOf(initialRows(pinned)) + } + + fun save(newItems: List) { + items = newItems + accountViewModel.settings.uiSettingsFlow.bottomBarItems.tryEmit( + newItems.filter { it.pinned }.map { it.item }, + ) + } + + var draggedItemIndex by remember { mutableIntStateOf(-1) } + var dragOffset by remember { mutableFloatStateOf(0f) } + val itemHeights = remember { mutableStateMapOf() } + val isDragging = draggedItemIndex >= 0 + val scrollState = remember { ScrollState(0) } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState, enabled = !isDragging), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringRes(R.string.bottom_bar_settings_description), + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp), + ) + + items.forEachIndexed { index, row -> + val rowIsDragging = draggedItemIndex == index + val targetElevation = if (rowIsDragging) 8f else 0f + val animatedElevation by animateFloatAsState( + targetValue = targetElevation, + label = "dragElevation", + ) + + NavBarItemCard( + row = row, + isDragging = rowIsDragging, + canDrag = row.pinned, + dragOffsetY = if (rowIsDragging) dragOffset else 0f, + elevation = animatedElevation, + onTogglePinned = { + val newItems = items.toMutableList() + val toggled = row.copy(pinned = !row.pinned) + newItems.removeAt(index) + val insertIndex = + if (toggled.pinned) { + newItems.indexOfFirst { !it.pinned }.let { if (it < 0) newItems.size else it } + } else { + val firstUnpinned = newItems.indexOfFirst { !it.pinned } + if (firstUnpinned < 0) newItems.size else firstUnpinned + } + newItems.add(insertIndex, toggled) + save(newItems) + }, + onMeasured = { height -> + itemHeights[index] = height + }, + onDragStart = { + draggedItemIndex = index + dragOffset = 0f + }, + onDrag = { dragAmount -> + dragOffset += dragAmount + + val currentIndex = draggedItemIndex + if (currentIndex < 0) return@NavBarItemCard + + // Can only swap among pinned items (row.pinned == true). + if (dragOffset < 0 && currentIndex > 0 && items[currentIndex - 1].pinned) { + val aboveHeight = itemHeights[currentIndex - 1] ?: 0f + if (-dragOffset > aboveHeight / 2f) { + val newItems = items.toMutableList() + val temp = newItems[currentIndex - 1] + newItems[currentIndex - 1] = newItems[currentIndex] + newItems[currentIndex] = temp + items = newItems + + val h1 = itemHeights[currentIndex] + val h2 = itemHeights[currentIndex - 1] + if (h1 != null) itemHeights[currentIndex - 1] = h1 + if (h2 != null) itemHeights[currentIndex] = h2 + + dragOffset += aboveHeight + draggedItemIndex = currentIndex - 1 + } + } + + if (dragOffset > 0 && + currentIndex < items.lastIndex && + items[currentIndex + 1].pinned + ) { + val belowHeight = itemHeights[currentIndex + 1] ?: 0f + if (dragOffset > belowHeight / 2f) { + val newItems = items.toMutableList() + val temp = newItems[currentIndex + 1] + newItems[currentIndex + 1] = newItems[currentIndex] + newItems[currentIndex] = temp + items = newItems + + val h1 = itemHeights[currentIndex] + val h2 = itemHeights[currentIndex + 1] + if (h1 != null) itemHeights[currentIndex + 1] = h1 + if (h2 != null) itemHeights[currentIndex] = h2 + + dragOffset -= belowHeight + draggedItemIndex = currentIndex + 1 + } + } + }, + onDragEnd = { + draggedItemIndex = -1 + dragOffset = 0f + save(items) + }, + onDragCancel = { + draggedItemIndex = -1 + dragOffset = 0f + }, + modifier = + Modifier + .zIndex(if (rowIsDragging) 1f else 0f), + ) + + val nextIsFirstUnpinned = + index < items.lastIndex && row.pinned && !items[index + 1].pinned + if (nextIsFirstUnpinned) { + SectionDivider(R.string.bottom_bar_settings_available) + } else if (index < items.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +private data class Row( + val item: NavBarItem, + val pinned: Boolean, +) + +private fun initialRows(pinned: List): List { + val pinnedRows = pinned.mapNotNull { id -> NavBarCatalog[id]?.let { Row(id, pinned = true) } } + val unpinnedRows = + NavBarCatalog.keys + .filter { it !in pinned } + .map { Row(it, pinned = false) } + return pinnedRows + unpinnedRows +} + +@Composable +private fun SectionDivider(titleRes: Int) { + Text( + text = stringRes(titleRes), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 20.dp, bottom = 4.dp, start = Size20dp, end = Size20dp), + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) +} + +@Composable +private fun NavBarItemCard( + row: Row, + isDragging: Boolean, + canDrag: Boolean, + dragOffsetY: Float, + elevation: Float, + onTogglePinned: () -> Unit, + onMeasured: (Float) -> Unit, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val def = NavBarCatalog[row.item] ?: return + val label = stringRes(def.labelRes) + + Column( + modifier = + modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + onMeasured(coordinates.size.height.toFloat()) + }.graphicsLayer { + translationY = dragOffsetY + shadowElevation = elevation + if (isDragging) { + scaleX = 1.02f + scaleY = 1.02f + } + }.padding(vertical = 8.dp, horizontal = Size20dp) + .then( + if (canDrag) { + Modifier.pointerInput(Unit) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + } + } else { + Modifier + }, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NavBarIconBox(def) + + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Switch( + checked = row.pinned, + onCheckedChange = { onTogglePinned() }, + ) + + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + if (canDrag) { + Icon( + Icons.Default.DragIndicator, + contentDescription = stringRes(R.string.bottom_bar_settings_reorder), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun NavBarIconBox(def: NavBarItemDef) { + Box( + modifier = Modifier.size(28.dp), + contentAlignment = Alignment.Center, + ) { + val description = stringRes(def.labelRes) + val tint = MaterialTheme.colorScheme.onBackground + when (val icon = def.icon) { + is NavBarIcon.Drawable -> { + Icon( + painter = painterRes(icon.resId, icon.reference), + contentDescription = description, + modifier = Modifier.size(24.dp), + tint = tint, + ) + } + + is NavBarIcon.Vector -> { + Icon( + imageVector = icon.vector, + contentDescription = description, + modifier = Modifier.size(24.dp), + tint = tint, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 963970ed1..b36d4553a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -369,6 +369,7 @@ "<Unable to decrypt private message>\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s." Add New Account Accounts + Navigate You Feeds Create @@ -1551,6 +1552,10 @@ Change Quick Reactions + Bottom Navigation Bar + Drag to reorder. Toggle to add or remove an item from the bottom bar. With zero items the bottom bar is hidden. + Available + Reorder Reaction Row Configure which reaction buttons are shown, their order, and whether to display counters. Enabled