From 2816ceb479fc5c3b84795d648fe0ddc9a188ca74 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 20:48:53 +0000 Subject: [PATCH 1/5] feat: split Security Filters into a hub with per-category screens Replaces the cramped single-screen layout (header settings + four tabs) with a hub that uses the same grouped-card pattern as AllSettingsScreen, plus a dedicated screen per blocked-content category. The tile control mix is also modernized: - "Show sensitive content" is now a 3-way SegmentedButton instead of a free-floating spinner sitting awkwardly next to switches. - Numeric prefs (report threshold, max hashtags) use a stepper (- / + ) instead of free OutlinedTextFields with no commit affordance. - The report-threshold tile renders inline under its toggle and dims when the toggle is off, instead of appearing/disappearing and shifting layout. - Each tile is a row with leading icon, title, description, trailing control - so the spinner/switch/stepper visual rhythm stays consistent. The hub's "Blocked content" section shows a count badge per category and routes to its own screen (Routes.BlockedUsers, SpammingUsers, HiddenWords, MutedThreads). The hidden-words screen now docks the "Add word" field as a bottom bar so it doesn't compete with the list for vertical space, and selection mode is scoped per-screen instead of shared across tabs. --- .../amethyst/ui/navigation/AppNavigation.kt | 8 + .../amethyst/ui/navigation/routes/Routes.kt | 8 + .../loggedIn/settings/BlockedUsersScreen.kt | 179 +++ .../loggedIn/settings/HiddenWordsScreen.kt | 249 ++++ .../loggedIn/settings/MutedThreadsScreen.kt | 181 +++ .../settings/SecurityFiltersScreen.kt | 1267 ++++++----------- .../loggedIn/settings/SecurityListsCommon.kt | 148 ++ .../loggedIn/settings/SpammingUsersScreen.kt | 73 + amethyst/src/main/res/values/strings.xml | 7 + .../commons/icons/symbols/MaterialSymbols.kt | 1 + 10 files changed, 1274 insertions(+), 847 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SpammingUsersScreen.kt 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 68f3f630a..083c9f10a 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 @@ -154,10 +154,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsS import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen 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.BlockedUsersScreen 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.ComposeSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MutedThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen @@ -165,6 +168,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsS import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen @@ -299,6 +303,10 @@ fun BuildNavigation( composableFromEnd { AllSettingsScreen(accountViewModel, nav) } composableFromEnd { AccountBackupScreen(accountViewModel, nav) } composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } + composableFromEnd { BlockedUsersScreen(accountViewModel, nav) } + composableFromEnd { SpammingUsersScreen(accountViewModel, nav) } + composableFromEnd { HiddenWordsScreen(accountViewModel, nav) } + composableFromEnd { MutedThreadsScreen(accountViewModel, nav) } composableFromEnd { PrivacyOptionsScreen(nav) } composableFromEnd { NamecoinSettingsScreen(nav) } composableFromEnd { OtsSettingsScreen(nav) } 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 e8f5d506f..11ba83090 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 @@ -122,6 +122,14 @@ sealed class Route { @Serializable object SecurityFilters : Route() + @Serializable object BlockedUsers : Route() + + @Serializable object SpammingUsers : Route() + + @Serializable object HiddenWords : Route() + + @Serializable object MutedThreads : Route() + @Serializable object PrivacyOptions : Route() @Serializable object NamecoinSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.kt new file mode 100644 index 000000000..05ccb6207 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.kt @@ -0,0 +1,179 @@ +/* + * 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.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ShowUserButton +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.UserFeedState +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size15dp +import com.vitorpamplona.amethyst.ui.theme.Size55dp + +@Composable +fun BlockedUsersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: HiddenAccountsFeedViewModel = + viewModel(factory = HiddenAccountsFeedViewModel.Factory(accountViewModel.account)) + + InvalidateOnBlockListChange(accountViewModel) { viewModel.invalidateData() } + + var selected by remember { mutableStateOf(setOf()) } + + Scaffold( + topBar = { + BlockListTopBar( + title = R.string.blocked_users, + selected = selected, + onCancel = { selected = emptySet() }, + onUnblock = { + accountViewModel.showUsers(selected.toList()) + selected = emptySet() + }, + nav = nav, + ) + }, + ) { padding -> + SelectableUserList( + modifier = Modifier.padding(padding), + viewModel = viewModel, + emptyMessage = R.string.security_blocked_users_empty, + selected = selected, + onToggle = { selected = if (it in selected) selected - it else selected + it }, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun SelectableUserList( + modifier: Modifier = Modifier, + viewModel: UserFeedViewModel, + emptyMessage: Int, + selected: Set, + onToggle: ((String) -> Unit)? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val selectionMode = selected.isNotEmpty() + + when (val state = feedState) { + is UserFeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, user -> + val isSelected = user.pubkeyHex in selected + val rowModifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (selectionMode && onToggle != null) { + onToggle(user.pubkeyHex) + } else { + nav.nav(routeFor(user)) + } + }, + onLongClick = { onToggle?.invoke(user.pubkeyHex) }, + ).let { + if (isSelected) { + it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) + } else { + it + } + } + + Row( + modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } + if (selectionMode) { + Checkbox(checked = isSelected, onCheckedChange = { onToggle?.invoke(user.pubkeyHex) }) + } else { + ShowUserButton { accountViewModel.show(user) } + } + } + HorizontalDivider(thickness = DividerThickness) + } + } + } + + else -> { + EmptyOrLoading(modifier = modifier, state = state, emptyMessage = emptyMessage) { + viewModel.invalidateData() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt new file mode 100644 index 000000000..7608d79b3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt @@ -0,0 +1,249 @@ +/* + * 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.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +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.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.AddButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenWordsFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.HorzPadding +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun HiddenWordsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: HiddenWordsFeedViewModel = + viewModel(factory = HiddenWordsFeedViewModel.Factory(accountViewModel.account)) + + InvalidateOnBlockListChange(accountViewModel) { viewModel.invalidateData() } + + var selected by remember { mutableStateOf(setOf()) } + + Scaffold( + topBar = { + BlockListTopBar( + title = R.string.hidden_words, + selected = selected, + onCancel = { selected = emptySet() }, + onUnblock = { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_show_word, + ) + } else { + accountViewModel.showWords(selected.toList()) + selected = emptySet() + } + }, + nav = nav, + ) + }, + bottomBar = { + Surface(tonalElevation = 3.dp) { + AddMuteWordTextField(accountViewModel) + } + }, + ) { padding -> + HiddenWordsList( + modifier = Modifier.padding(padding), + viewModel = viewModel, + selected = selected, + onToggle = { selected = if (it in selected) selected - it else selected + it }, + accountViewModel = accountViewModel, + ) + } +} + +@Composable +private fun HiddenWordsList( + modifier: Modifier = Modifier, + viewModel: HiddenWordsFeedViewModel, + selected: Set, + onToggle: (String) -> Unit, + accountViewModel: AccountViewModel, +) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + + when (val state = feedState) { + is StringFeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + if (items.isEmpty()) { + EmptyState(modifier, R.string.security_hidden_words_empty) + } else { + Column(modifier = modifier.fillMaxSize()) { + items.forEach { word -> + MutedWordRow( + tag = word, + isSelected = word in selected, + selectionMode = selected.isNotEmpty(), + onToggle = { onToggle(word) }, + ) + HorizontalDivider(thickness = DividerThickness) + } + } + } + } + + is StringFeedState.Empty -> { + EmptyState(modifier, R.string.security_hidden_words_empty) + } + + is StringFeedState.Loading -> { + LoadingFeed() + } + + is StringFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun MutedWordRow( + tag: String, + isSelected: Boolean, + selectionMode: Boolean, + onToggle: () -> Unit, +) { + val rowModifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { if (selectionMode) onToggle() }, + onLongClick = onToggle, + ).let { + if (isSelected) { + it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) + } else { + it + } + } + + Row( + modifier = rowModifier.then(StdPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + tag, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + if (selectionMode) { + Checkbox(checked = isSelected, onCheckedChange = { onToggle() }) + } + } +} + +@Composable +private fun AddMuteWordTextField(accountViewModel: AccountViewModel) { + val currentWordToAdd = remember { mutableStateOf("") } + val hasChanged by remember { derivedStateOf { currentWordToAdd.value.isNotBlank() } } + + OutlinedTextField( + value = currentWordToAdd.value, + onValueChange = { currentWordToAdd.value = it }, + label = { Text(text = stringRes(R.string.hide_new_word_label)) }, + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp), + placeholder = { + Text( + text = stringRes(R.string.hide_new_word_label), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + imeAction = ImeAction.Send, + capitalization = KeyboardCapitalization.Sentences, + ), + keyboardActions = + KeyboardActions( + onSend = { if (hasChanged) hideIfWritable(accountViewModel, currentWordToAdd) }, + ), + singleLine = true, + trailingIcon = { + AddButton(isActive = hasChanged, modifier = HorzPadding) { + hideIfWritable(accountViewModel, currentWordToAdd) + } + }, + ) +} + +private fun hideIfWritable( + accountViewModel: AccountViewModel, + currentWordToAdd: MutableState, +) { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_hide_word, + ) + } else { + accountViewModel.hide(currentWordToAdd.value.trim()) + currentWordToAdd.value = "" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt new file mode 100644 index 000000000..e90a7d76c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt @@ -0,0 +1,181 @@ +/* + * 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.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.MutedThreadsFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size15dp + +@Composable +fun MutedThreadsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: MutedThreadsFeedViewModel = + viewModel(factory = MutedThreadsFeedViewModel.Factory(accountViewModel.account)) + + InvalidateOnBlockListChange(accountViewModel) { viewModel.invalidateData() } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.settings_muted_threads_title), nav) }, + ) { padding -> + MutedThreadsList( + modifier = Modifier.padding(padding), + viewModel = viewModel, + accountViewModel = accountViewModel, + ) + } +} + +@Composable +private fun MutedThreadsList( + modifier: Modifier = Modifier, + viewModel: MutedThreadsFeedViewModel, + accountViewModel: AccountViewModel, +) { + val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle() + + when (val state = feedState) { + is FeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, note -> + MutedThreadRow(note = note, accountViewModel = accountViewModel) + HorizontalDivider(thickness = DividerThickness) + } + } + } + + is FeedState.Empty -> { + EmptyState(modifier, R.string.settings_muted_threads_empty) + } + + is FeedState.Loading -> { + LoadingFeed() + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + } +} + +@Composable +private fun MutedThreadRow( + note: Note, + accountViewModel: AccountViewModel, +) { + val event = note.event + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size15dp, vertical = Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + if (event == null) { + Text( + text = stringRes(R.string.settings_muted_threads_unknown, note.idHex.take(12) + "…"), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + val authorName = note.author?.metadataOrNull()?.bestName() + if (authorName != null) { + Text( + text = authorName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = + event.content + .lines() + .firstOrNull() + ?.trim() ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = { accountViewModel.unmuteThread(note) }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.action_unmute), color = Color.White) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 39d772955..932efb399 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -20,921 +20,494 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch -import androidx.compose.material3.Tab import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.draw.clip import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.WarningType import com.vitorpamplona.amethyst.model.parseWarningType -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenWord -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer -import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty -import com.vitorpamplona.amethyst.ui.feeds.FeedError -import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed -import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.ui.note.ShowUserButton -import com.vitorpamplona.amethyst.ui.note.UserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.elements.AddButton -import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView -import com.vitorpamplona.amethyst.ui.screen.UserFeedState -import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenWordsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.MutedThreadsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.SpammerAccountsFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.ButtonPadding -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding -import com.vitorpamplona.amethyst.ui.theme.HorzPadding -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size15dp -import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.TabRowHeight -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.launch @Composable fun SecurityFiltersScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val hiddenFeedViewModel: HiddenAccountsFeedViewModel = - viewModel( - factory = HiddenAccountsFeedViewModel.Factory(accountViewModel.account), - ) - - val hiddenWordsFeedViewModel: HiddenWordsFeedViewModel = - viewModel( - factory = HiddenWordsFeedViewModel.Factory(accountViewModel.account), - ) - - val spammerFeedViewModel: SpammerAccountsFeedViewModel = - viewModel( - factory = SpammerAccountsFeedViewModel.Factory(accountViewModel.account), - ) - - val mutedThreadsFeedViewModel: MutedThreadsFeedViewModel = - viewModel( - factory = MutedThreadsFeedViewModel.Factory(accountViewModel.account), - ) - - WatchAccountAndBlockList(accountViewModel = accountViewModel) { - hiddenFeedViewModel.invalidateData() - spammerFeedViewModel.invalidateData() - hiddenWordsFeedViewModel.invalidateData() - mutedThreadsFeedViewModel.invalidateData() - } - - SecurityFiltersScreen( - hiddenFeedViewModel, - hiddenWordsFeedViewModel, - spammerFeedViewModel, - mutedThreadsFeedViewModel, - accountViewModel, - nav, - ) -} - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) -@Composable -fun SecurityFiltersScreen( - hiddenFeedViewModel: HiddenAccountsFeedViewModel, - hiddenWordsViewModel: HiddenWordsFeedViewModel, - spammerFeedViewModel: SpammerAccountsFeedViewModel, - mutedThreadsFeedViewModel: MutedThreadsFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Hidden Users Start") - hiddenWordsViewModel.invalidateData() - hiddenFeedViewModel.invalidateData() - spammerFeedViewModel.invalidateData() - mutedThreadsFeedViewModel.invalidateData() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - val pagerState = rememberPagerState { 4 } - val coroutineScope = rememberCoroutineScope() - - var selectedUsers by remember { mutableStateOf(setOf()) } - var selectedWords by remember { mutableStateOf(setOf()) } - - LaunchedEffect(pagerState.currentPage) { - selectedUsers = emptySet() - selectedWords = emptySet() - } - - val selectionMode = selectedUsers.isNotEmpty() || selectedWords.isNotEmpty() - Scaffold( - topBar = { - if (selectionMode) { - SelectionTopBar( - selectedCount = if (pagerState.currentPage == 2) selectedWords.size else selectedUsers.size, - onCancel = { - selectedUsers = emptySet() - selectedWords = emptySet() - }, - onRemove = { - if (pagerState.currentPage == 2) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_show_word, - ) - } else { - accountViewModel.showWords(selectedWords.toList()) - selectedWords = emptySet() - } - } else { - accountViewModel.showUsers(selectedUsers.toList()) - selectedUsers = emptySet() - } - }, - ) - } else { - TopBarWithBackButton(stringRes(id = R.string.security_filters), nav) - } - }, - ) { + topBar = { TopBarWithBackButton(stringRes(id = R.string.security_filters), nav) }, + ) { padding -> Column( - Modifier - .padding(it) - .fillMaxHeight(), - ) { - HeaderOptions(accountViewModel) - - HorizontalDivider(thickness = DividerThickness) - - SecondaryScrollableTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - edgePadding = 8.dp, - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - divider = { HorizontalDivider(thickness = DividerThickness) }, - ) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(text = stringRes(R.string.blocked_users)) }, - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(text = stringRes(R.string.spamming_users)) }, - ) - Tab( - selected = pagerState.currentPage == 2, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, - text = { Text(text = stringRes(R.string.hidden_words)) }, - ) - Tab( - selected = pagerState.currentPage == 3, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } }, - text = { Text(text = stringRes(R.string.settings_muted_threads_title)) }, - ) - } - HorizontalPager(state = pagerState) { page -> - when (page) { - 0 -> { - SelectableHiddenUsersFeed( - viewModel = hiddenFeedViewModel, - selected = selectedUsers, - onToggle = { hex -> - selectedUsers = - if (hex in selectedUsers) selectedUsers - hex else selectedUsers + hex - }, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - 1 -> { - RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav) - } - - 2 -> { - HiddenWordsFeed( - hiddenWordsViewModel = hiddenWordsViewModel, - accountViewModel = accountViewModel, - selected = selectedWords, - onToggle = { word -> - selectedWords = - if (word in selectedWords) selectedWords - word else selectedWords + word - }, - ) - } - - 3 -> { - MutedThreadsFeed( - viewModel = mutedThreadsFeedViewModel, - accountViewModel = accountViewModel, - ) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SelectionTopBar( - selectedCount: Int, - onCancel: () -> Unit, - onRemove: () -> Unit, -) { - ShorterTopAppBar( - title = { - Text( - text = stringRes(R.string.num_selected, selectedCount), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Start, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - }, - navigationIcon = { - CloseButton( - modifier = HalfHorzPadding, - onPress = onCancel, - ) - }, - actions = { - Button( - modifier = HalfHorzPadding, - onClick = onRemove, - ) { - Text(text = stringRes(R.string.unblock)) - } - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) -} - -@Composable -private fun HeaderOptions(accountViewModel: AccountViewModel) { - Column( - Modifier - .padding(top = Size10dp, bottom = Size10dp, start = Size15dp, end = Size15dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = spacedBy(10.dp), - ) { - var warnAboutReports by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.warnAboutPostsWithReports.value) } - - SettingsRow( - R.string.warn_when_posts_have_reports_from_your_follows_title, - R.string.warn_when_posts_have_reports_from_your_follows_explainer, - ) { - Switch( - checked = warnAboutReports, - onCheckedChange = { - warnAboutReports = it - accountViewModel.updateWarnReports(warnAboutReports) - }, - ) - } - - if (warnAboutReports) { - SettingsRow( - R.string.report_warning_threshold_title, - R.string.report_warning_threshold_explainer, - ) { - var reportWarningThreshold by remember { - mutableStateOf( - accountViewModel.account.settings.syncedSettings.security.reportWarningThreshold.value - .coerceAtLeast(1) - .toString(), - ) - } - - OutlinedTextField( - value = reportWarningThreshold, - onValueChange = { - val updatedThreshold = (it.toIntOrNull() ?: 1).coerceAtLeast(1) - reportWarningThreshold = updatedThreshold.toString() - accountViewModel.updateReportWarningThreshold(updatedThreshold) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - singleLine = true, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - - SettingsRow( - R.string.filter_spam_from_strangers_title, - R.string.filter_spam_from_strangers_explainer, - ) { - var filterSpam by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers.value) } - - Switch( - checked = filterSpam, - onCheckedChange = { - filterSpam = it - accountViewModel.updateFilterSpam(filterSpam) - }, - ) - } - - SettingsRow( - R.string.disable_client_tag_title, - R.string.disable_client_tag_explainer, - ) { - var disableClientTag by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.disableClientTag.value) } - - Switch( - checked = disableClientTag, - onCheckedChange = { - disableClientTag = it - accountViewModel.updateDisableClientTag(disableClientTag) - }, - ) - } - - SettingsRow( - R.string.show_sensitive_content_title, - R.string.show_sensitive_content_explainer, - ) { - var sensitive by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.showSensitiveContent.value) } - - val selectedItens = - persistentListOf( - TitleExplainer(stringRes(WarningType.WARN.resourceId)), - TitleExplainer(stringRes(WarningType.SHOW.resourceId)), - TitleExplainer(stringRes(WarningType.HIDE.resourceId)), - ) - - TextSpinner( - label = "", - placeholder = selectedItens[parseWarningType(sensitive).screenCode].title, - options = selectedItens, - onSelect = { - accountViewModel.updateShowSensitiveContent(parseWarningType(it).prefCode) - }, - ) - } - - SettingsRow( - R.string.max_hashtag_limit_title, - R.string.max_hashtag_limit_explainer, - ) { - var maxHashtags by remember { - mutableStateOf( - accountViewModel.account.settings.syncedSettings.security.maxHashtagLimit.value.let { - if (it == 0) "" else it.toString() - }, - ) - } - - OutlinedTextField( - value = maxHashtags, - onValueChange = { - maxHashtags = it - accountViewModel.updateMaxHashtagLimit(it.toIntOrNull() ?: 0) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - singleLine = true, - modifier = Modifier.padding(start = 8.dp), - ) - } - - SettingsRow( - R.string.hide_community_rules_violations_title, - R.string.hide_community_rules_violations_explainer, - ) { - val hideViolations by accountViewModel.account.settings.hideCommunityRulesViolations - .collectAsStateWithLifecycle() - - Switch( - checked = hideViolations, - onCheckedChange = { accountViewModel.account.settings.changeHideCommunityRulesViolations(it) }, - ) - } - } -} - -@Composable -private fun HiddenWordsFeed( - hiddenWordsViewModel: HiddenWordsFeedViewModel, - accountViewModel: AccountViewModel, - selected: Set, - onToggle: (String) -> Unit, -) { - val selectionMode = selected.isNotEmpty() - RefresheableBox(hiddenWordsViewModel, false) { - StringFeedView( - hiddenWordsViewModel, - accountViewModel, - post = { AddMuteWordTextField(accountViewModel) }, - ) { word -> - MutedWordHeader( - tag = word, - account = accountViewModel, - isSelected = word in selected, - selectionMode = selectionMode, - onToggle = { onToggle(word) }, - onLongClick = { onToggle(word) }, - ) - } - } -} - -@Composable -private fun AddMuteWordTextField(accountViewModel: AccountViewModel) { - Row { - val currentWordToAdd = remember { mutableStateOf("") } - val hasChanged by remember { derivedStateOf { currentWordToAdd.value != "" } } - - OutlinedTextField( - value = currentWordToAdd.value, - onValueChange = { currentWordToAdd.value = it }, - label = { Text(text = stringRes(R.string.hide_new_word_label)) }, modifier = Modifier - .fillMaxWidth() - .padding(10.dp), - placeholder = { - Text( - text = stringRes(R.string.hide_new_word_label), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - imeAction = ImeAction.Send, - capitalization = KeyboardCapitalization.Sentences, - ), - keyboardActions = - KeyboardActions( - onSend = { - if (hasChanged) { - hideIfWritable(accountViewModel, currentWordToAdd) - } - }, - ), - singleLine = true, - trailingIcon = { - AddButton(isActive = hasChanged, modifier = HorzPadding) { - hideIfWritable(accountViewModel, currentWordToAdd) + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + SecurityPreferencesSection(accountViewModel) + BlockedContentSection(accountViewModel, nav) + } + } +} + +@Composable +private fun SecurityPreferencesSection(accountViewModel: AccountViewModel) { + SettingsSection(R.string.security_section_filtering_preferences) { + SensitiveContentTile(accountViewModel) + SettingsDivider() + FilterSpamTile(accountViewModel) + SettingsDivider() + HideCommunityViolationsTile(accountViewModel) + SettingsDivider() + DisableClientTagTile(accountViewModel) + SettingsDivider() + WarnReportsTile(accountViewModel) + SettingsDivider() + MaxHashtagsTile(accountViewModel) + } +} + +@Composable +private fun SensitiveContentTile(accountViewModel: AccountViewModel) { + val showSensitive by accountViewModel.account.settings.syncedSettings.security.showSensitiveContent + .collectAsStateWithLifecycle() + + SettingsTile( + icon = MaterialSymbols.VisibilityOff, + title = stringRes(R.string.show_sensitive_content_title), + description = stringRes(R.string.show_sensitive_content_explainer), + ) { + val current = parseWarningType(showSensitive) + val options = listOf(WarningType.WARN, WarningType.SHOW, WarningType.HIDE) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, type -> + SegmentedButton( + selected = current == type, + onClick = { accountViewModel.updateShowSensitiveContent(type.prefCode) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), + ) { + Text(stringRes(type.resourceId)) } - }, + } + } + } +} + +@Composable +private fun FilterSpamTile(accountViewModel: AccountViewModel) { + val filterSpam by accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.FilterAlt, + title = stringRes(R.string.filter_spam_from_strangers_title), + description = stringRes(R.string.filter_spam_from_strangers_explainer), + checked = filterSpam, + onCheckedChange = { accountViewModel.updateFilterSpam(it) }, + ) +} + +@Composable +private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { + val hideViolations by accountViewModel.account.settings.hideCommunityRulesViolations + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Shield, + title = stringRes(R.string.hide_community_rules_violations_title), + description = stringRes(R.string.hide_community_rules_violations_explainer), + checked = hideViolations, + onCheckedChange = { accountViewModel.account.settings.changeHideCommunityRulesViolations(it) }, + ) +} + +@Composable +private fun DisableClientTagTile(accountViewModel: AccountViewModel) { + val disableClientTag by accountViewModel.account.settings.syncedSettings.security.disableClientTag + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Tag, + title = stringRes(R.string.disable_client_tag_title), + description = stringRes(R.string.disable_client_tag_explainer), + checked = disableClientTag, + onCheckedChange = { accountViewModel.updateDisableClientTag(it) }, + ) +} + +@Composable +private fun WarnReportsTile(accountViewModel: AccountViewModel) { + val warnReports by accountViewModel.account.settings.syncedSettings.security.warnAboutPostsWithReports + .collectAsStateWithLifecycle() + val threshold by accountViewModel.account.settings.syncedSettings.security.reportWarningThreshold + .collectAsStateWithLifecycle() + + Column { + SwitchTile( + icon = MaterialSymbols.Report, + title = stringRes(R.string.warn_when_posts_have_reports_from_your_follows_title), + description = stringRes(R.string.warn_when_posts_have_reports_from_your_follows_explainer), + checked = warnReports, + onCheckedChange = { accountViewModel.updateWarnReports(it) }, + ) + + SettingsTile( + icon = null, + title = stringRes(R.string.report_warning_threshold_title), + description = stringRes(R.string.report_warning_threshold_explainer), + indented = true, + enabled = warnReports, + ) { + Stepper( + value = threshold.coerceAtLeast(1), + min = 1, + max = 999, + enabled = warnReports, + onValueChange = { accountViewModel.updateReportWarningThreshold(it) }, + ) + } + } +} + +@Composable +private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { + val maxHashtags by accountViewModel.account.settings.syncedSettings.security.maxHashtagLimit + .collectAsStateWithLifecycle() + + SettingsTile( + icon = MaterialSymbols.Tag, + title = stringRes(R.string.max_hashtag_limit_title), + description = stringRes(R.string.max_hashtag_limit_explainer), + ) { + Stepper( + value = maxHashtags, + min = 0, + max = 99, + unsetLabel = stringRes(R.string.security_unlimited), + onValueChange = { accountViewModel.updateMaxHashtagLimit(it) }, ) } } @Composable -fun WatchAccountAndBlockList( +private fun BlockedContentSection( accountViewModel: AccountViewModel, - invalidate: () -> Unit, + nav: INav, ) { - val transientSpammers by accountViewModel.account.hiddenUsers.transientHiddenUsers - .collectAsStateWithLifecycle() - val blockListState by accountViewModel.account.hiddenUsers.flow + val hidden by accountViewModel.account.hiddenUsers.flow .collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, transientSpammers, blockListState) { - invalidate() - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun MutedWordHeader( - tag: String, - modifier: Modifier = StdPadding, - account: AccountViewModel, - isSelected: Boolean = false, - selectionMode: Boolean = false, - onToggle: () -> Unit = {}, - onLongClick: () -> Unit = {}, -) { - val rowModifier = - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { if (selectionMode) onToggle() }, - onLongClick = onLongClick, - ).let { - if (isSelected) { - it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) - } else { - it - } - } - - Column(rowModifier) { - Column(modifier = modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Text( - tag, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - - if (selectionMode) { - Checkbox(checked = isSelected, onCheckedChange = { onToggle() }) - } else { - MutedWordActionOptions(tag, account) - } - } - } - } -} - -@Composable -fun MutedWordActionOptions( - word: String, - accountViewModel: AccountViewModel, -) { - val isMutedWord by observeAccountIsHiddenWord(accountViewModel.account, word) - - if (isMutedWord) { - ShowWordButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_show_word, - ) - } else { - accountViewModel.showWord(word) - } - } - } else { - HideWordButton { - if (!accountViewModel.isWriteable()) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_hide_word, - ) - } else { - accountViewModel.hideWord(word) - } - } - } -} - -@Composable -fun HideWordButton(onClick: () -> Unit) { - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(R.string.block_only), color = Color.White) - } -} - -@Composable -fun ShowWordButton( - text: Int = R.string.unblock, - onClick: () -> Unit, -) { - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = onClick, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(text), color = Color.White, textAlign = TextAlign.Center) - } -} - -private fun hideIfWritable( - accountViewModel: AccountViewModel, - currentWordToAdd: MutableState, -) { - if (!accountViewModel.isWriteable()) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_hide_word, + SettingsSection(R.string.security_section_blocked_content) { + BlockedContentRow( + icon = MaterialSymbols.PersonOff, + title = stringRes(R.string.blocked_users), + count = hidden.hiddenUsers.size, + onClick = { nav.nav(Route.BlockedUsers) }, + ) + SettingsDivider() + BlockedContentRow( + icon = MaterialSymbols.Block, + title = stringRes(R.string.spamming_users), + count = hidden.spammers.size, + onClick = { nav.nav(Route.SpammingUsers) }, + ) + SettingsDivider() + BlockedContentRow( + icon = MaterialSymbols.Tag, + title = stringRes(R.string.hidden_words), + count = hidden.hiddenWords.size, + onClick = { nav.nav(Route.HiddenWords) }, + ) + SettingsDivider() + BlockedContentRow( + icon = MaterialSymbols.Forum, + title = stringRes(R.string.settings_muted_threads_title), + count = hidden.mutedThreads.size, + onClick = { nav.nav(Route.MutedThreads) }, ) - } else { - accountViewModel.hide(currentWordToAdd.value) - currentWordToAdd.value = "" } } +// ────────────────────────────────────────────────────────────────────────────── +// Building blocks +// ────────────────────────────────────────────────────────────────────────────── + @Composable -private fun SelectableHiddenUsersFeed( - viewModel: UserFeedViewModel, - selected: Set, - onToggle: (String) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, +private fun SettingsSection( + title: Int, + content: @Composable ColumnScope.() -> Unit, ) { - val selectionMode = selected.isNotEmpty() - RefresheableBox(viewModel, true) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - - CrossfadeIfEnabled( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - accountViewModel = accountViewModel, - ) { state -> - when (state) { - is UserFeedState.Empty -> { - FeedEmpty { viewModel.invalidateData() } - } - - is UserFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - - is UserFeedState.Loading -> { - LoadingFeed() - } - - is UserFeedState.Loaded -> { - SelectableHiddenUsersList( - state = state, - selected = selected, - selectionMode = selectionMode, - onToggle = onToggle, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun SelectableHiddenUsersList( - state: UserFeedState.Loaded, - selected: Set, - selectionMode: Boolean, - onToggle: (String) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val items by state.feed.collectAsStateWithLifecycle() - val listState = rememberLazyListState() - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, user -> - val isSelected = user.pubkeyHex in selected - - val rowModifier = - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (selectionMode) { - onToggle(user.pubkeyHex) - } else { - nav.nav(routeFor(user)) - } - }, - onLongClick = { onToggle(user.pubkeyHex) }, - ).let { - if (isSelected) { - it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) - } else { - it - } - } - - Row( - modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { - UsernameDisplay(user, accountViewModel = accountViewModel) - } - if (selectionMode) { - Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) }) - } else { - ShowUserButton { accountViewModel.show(user) } - } - } - - HorizontalDivider(thickness = DividerThickness) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 4.dp), + ) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(content = content) } } } @Composable -private fun MutedThreadsFeed( - viewModel: MutedThreadsFeedViewModel, - accountViewModel: AccountViewModel, -) { - RefresheableBox(viewModel, false) { - val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle() - - CrossfadeIfEnabled( - targetState = feedState, - animationSpec = tween(durationMillis = 100), - accountViewModel = accountViewModel, - ) { state -> - when (state) { - is FeedState.Empty -> { - Column( - Modifier - .fillMaxSize() - .padding(10.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(text = stringRes(R.string.settings_muted_threads_empty)) - } - } - - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } - } - - is FeedState.Loading -> { - LoadingFeed() - } - - is FeedState.Loaded -> { - val items by state.feed.collectAsStateWithLifecycle() - val listState = rememberLazyListState() - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, note -> - MutedThreadRow(note = note, accountViewModel = accountViewModel) - HorizontalDivider(thickness = DividerThickness) - } - } - } - } - } - } +private fun SettingsDivider() { + HorizontalDivider( + modifier = Modifier.padding(start = 68.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) } @Composable -private fun MutedThreadRow( - note: Note, - accountViewModel: AccountViewModel, +private fun SettingsTile( + icon: MaterialSymbol?, + title: String, + description: String, + indented: Boolean = false, + enabled: Boolean = true, + trailing: @Composable () -> Unit, ) { - val event = note.event - + val alpha = if (enabled) 1f else 0.5f Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = Size15dp, vertical = Size10dp), + .padding( + start = if (indented) 56.dp else 16.dp, + end = 16.dp, + top = 12.dp, + bottom = 12.dp, + ), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { - if (event == null) { - Text( - text = stringRes(R.string.settings_muted_threads_unknown, note.idHex.take(12) + "…"), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - val authorName = note.author?.metadataOrNull()?.bestName() - if (authorName != null) { - Text( - text = authorName, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Text( - text = - event.content - .lines() - .firstOrNull() - ?.trim() ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + if (icon != null) { + SettingsLeadingIcon(icon) } - - Button( - modifier = Modifier.padding(start = 3.dp), - onClick = { accountViewModel.unmuteThread(note) }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, + Column( + modifier = + Modifier + .weight(1f) + .padding(start = if (icon != null) 16.dp else 0.dp, end = 12.dp), ) { - Text(text = stringRes(R.string.action_unmute), color = Color.White) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + ) + } + Box(contentAlignment = Alignment.Center) { trailing() } + } +} + +@Composable +private fun SwitchTile( + icon: MaterialSymbol, + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsLeadingIcon(icon) + Column( + modifier = + Modifier + .weight(1f) + .padding(start = 16.dp, end = 12.dp), + ) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun SettingsLeadingIcon(icon: MaterialSymbol) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + +@Composable +private fun BlockedContentRow( + icon: MaterialSymbol, + title: String, + count: Int, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsLeadingIcon(icon) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .weight(1f) + .padding(start = 16.dp), + ) + CountBadge(count) + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.padding(start = 8.dp).size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun CountBadge(count: Int) { + if (count <= 0) return + Box( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 10.dp, vertical = 2.dp), + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +@Composable +private fun Stepper( + value: Int, + min: Int, + max: Int, + enabled: Boolean = true, + unsetLabel: String? = null, + onValueChange: (Int) -> Unit, +) { + val alpha = if (enabled) 1f else 0.5f + Row( + modifier = + Modifier + .height(40.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = alpha)), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + enabled = enabled && value > min, + onClick = { onValueChange((value - 1).coerceAtLeast(min)) }, + ) { + Icon( + symbol = MaterialSymbols.Remove, + contentDescription = "−", + modifier = Modifier.size(18.dp), + tint = LocalContentColor.current.copy(alpha = alpha), + ) + } + val display = + if (unsetLabel != null && value <= 0) { + unsetLabel + } else { + value.toString() + } + Text( + text = display, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + modifier = Modifier.padding(horizontal = 4.dp), + ) + IconButton( + enabled = enabled && value < max, + onClick = { onValueChange((value + 1).coerceAtMost(max)) }, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = "+", + modifier = Modifier.size(18.dp), + tint = LocalContentColor.current.copy(alpha = alpha), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt new file mode 100644 index 000000000..87e21ae61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt @@ -0,0 +1,148 @@ +/* + * 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.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton +import com.vitorpamplona.amethyst.ui.screen.UserFeedState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding + +@Composable +internal fun InvalidateOnBlockListChange( + accountViewModel: AccountViewModel, + invalidate: () -> Unit, +) { + val transientSpammers by accountViewModel.account.hiddenUsers.transientHiddenUsers + .collectAsStateWithLifecycle() + val blockListState by accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, transientSpammers, blockListState) { + invalidate() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BlockListTopBar( + title: Int, + selected: Set<*>, + onCancel: () -> Unit, + onUnblock: () -> Unit, + nav: INav, +) { + if (selected.isEmpty()) { + TopBarWithBackButton(stringRes(id = title), nav) + } else { + ShorterTopAppBar( + title = { + Text( + text = stringRes(R.string.num_selected, selected.size), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Start, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, + navigationIcon = { + CloseButton(modifier = HalfHorzPadding, onPress = onCancel) + }, + actions = { + Button(modifier = HalfHorzPadding, onClick = onUnblock) { + Text(text = stringRes(R.string.unblock)) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } +} + +@Composable +internal fun EmptyOrLoading( + modifier: Modifier = Modifier, + state: UserFeedState, + emptyMessage: Int, + onRefresh: () -> Unit, +) { + when (state) { + is UserFeedState.Empty -> EmptyState(modifier, emptyMessage) + is UserFeedState.Loading -> LoadingFeed() + is UserFeedState.FeedError -> FeedError(state.errorMessage, onRefresh) + else -> Unit + } +} + +@Composable +internal fun EmptyState( + modifier: Modifier = Modifier, + message: Int, +) { + Column( + modifier = modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + symbol = MaterialSymbols.Shield, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(48.dp).padding(bottom = 16.dp), + ) + Text( + text = stringRes(message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SpammingUsersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SpammingUsersScreen.kt new file mode 100644 index 000000000..c00c79b6c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SpammingUsersScreen.kt @@ -0,0 +1,73 @@ +/* + * 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.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.SpammerAccountsFeedViewModel + +@Composable +fun SpammingUsersScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: SpammerAccountsFeedViewModel = + viewModel(factory = SpammerAccountsFeedViewModel.Factory(accountViewModel.account)) + + InvalidateOnBlockListChange(accountViewModel) { viewModel.invalidateData() } + + var selected by remember { mutableStateOf(setOf()) } + + Scaffold( + topBar = { + BlockListTopBar( + title = R.string.spamming_users, + selected = selected, + onCancel = { selected = emptySet() }, + onUnblock = { + accountViewModel.showUsers(selected.toList()) + selected = emptySet() + }, + nav = nav, + ) + }, + ) { padding -> + SelectableUserList( + modifier = Modifier.padding(padding), + viewModel = viewModel, + emptyMessage = R.string.security_spamming_users_empty, + selected = selected, + onToggle = { selected = if (it in selected) selected - it else selected + it }, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d9a7ed94e..11f554264 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1254,6 +1254,13 @@ Hide posts that violate community rules Drops posts from community feeds when the community publishes a NIP-9A rules document and an event would fail it. No effect when a community has no structured rules. + Filtering preferences + Blocked content + + You haven\'t blocked any users yet. + No accounts have been flagged as spam in this session. + No hidden words. Add a word below to hide posts containing it. + New Reaction Symbol No reaction types pre-selected for this user. Long press on the heart button to change diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index c2a3b64e0..74bd0fae8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -178,6 +178,7 @@ object MaterialSymbols { val RadioButtonUnchecked = MaterialSymbol("\uE836") val Recommend = MaterialSymbol("\uE9D2") val Refresh = MaterialSymbol("\uE5D5") + val Remove = MaterialSymbol("\uE15B") val RemoveCircleOutline = MaterialSymbol("\uF08F") val RemoveDone = MaterialSymbol("\uE9D3") val Replay = MaterialSymbol("\uE042") From 6feca2cf17bb799fd9d2ef09c3c9446c0b1a02b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 21:34:53 +0000 Subject: [PATCH 2/5] refactor: address Security Filters audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract SettingsSection/SettingsItem/SettingsDivider/SettingsControlRow/ SettingsBlockTile/SettingsSubControlRow into SettingsSectionCard.kt. AllSettingsScreen now consumes the shared components instead of carrying its own private copies, and SecurityFiltersScreen drops the parallel set it used to declare. - BlockedContentRow folds into SettingsItem(trailing = { CountBadge(...) }), removing the bespoke navigation row. - Replace the Spinner/Switch tile mix with WarnReportsTile = SwitchTile + SettingsSubControlRow(stepper), avoiding the nullable-icon code smell. - Move the sensitive-content SegmentedButtonRow out of a cramped trailing slot and into SettingsBlockTile so it has full-width room. - HiddenWordsScreen: replace the eager Column.forEach with LazyColumn, and add Modifier.imePadding() to the Scaffold so the keyboard no longer covers the Add-word field. - Move SelectableUserList from BlockedUsersScreen.kt to SecurityListsCommon and drop the nullable onToggle (both call sites pass one). - BlockListTopBar now takes selectedCount: Int instead of Set<*>; its UserFeedState branches are exhaustive (no else -> Unit fallthrough). - CountBadge reserves min width so 0↔n transitions don't shift the row. - Stepper relies on Material 3's enabled handling instead of computing alpha by hand and tinting LocalContentColor in three places. - Distinct icons per tile/category (Visibility, Code, VisibilityOff, etc.) so the same MaterialSymbols.Tag isn't reused for unrelated rows. --- .../loggedIn/settings/AllSettingsScreen.kt | 168 --------- .../loggedIn/settings/BlockedUsersScreen.kt | 108 +----- .../loggedIn/settings/HiddenWordsScreen.kt | 18 +- .../settings/SecurityFiltersScreen.kt | 340 +++++------------ .../loggedIn/settings/SecurityListsCommon.kt | 111 +++++- .../loggedIn/settings/SettingsSectionCard.kt | 350 ++++++++++++++++++ .../loggedIn/settings/SpammingUsersScreen.kt | 2 +- 7 files changed, 549 insertions(+), 548 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt 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 4c3e8160c..bf15f6160 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 @@ -21,26 +21,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -51,26 +40,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route 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 @@ -371,153 +353,3 @@ private fun ResetMarmotStateDialog( }, ) } - -@Composable -private fun SettingsSection( - title: Int, - isDanger: Boolean = false, - content: @Composable ColumnScope.() -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringRes(title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = - if (isDanger) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - }, - modifier = Modifier.padding(horizontal = 4.dp), - ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column(content = content) - } - } -} - -@Composable -private fun SettingsDivider() { - HorizontalDivider( - modifier = Modifier.padding(start = 68.dp), - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant, - ) -} - -@Composable -private fun SettingsItem( - title: Int, - icon: MaterialSymbol, - isDanger: Boolean = false, - onClick: () -> Unit, -) { - SettingsItemRow( - title = title, - isDanger = isDanger, - onClick = onClick, - leadingIcon = { tint -> - Icon( - symbol = icon, - contentDescription = stringRes(title), - modifier = Modifier.size(20.dp), - tint = tint, - ) - }, - ) -} - -@Composable -private fun SettingsItem( - title: Int, - iconPainter: Int, - iconPainterRef: Int, - isDanger: Boolean = false, - onClick: () -> Unit, -) { - val painter: Painter = painterRes(iconPainter, iconPainterRef) - SettingsItemRow( - title = title, - isDanger = isDanger, - onClick = onClick, - leadingIcon = { tint -> - Icon( - painter = painter, - contentDescription = stringRes(title), - modifier = Modifier.size(20.dp), - tint = tint, - ) - }, - ) -} - -@Composable -private fun SettingsItemRow( - title: Int, - isDanger: Boolean, - onClick: () -> Unit, - leadingIcon: @Composable (tint: Color) -> Unit, -) { - val containerColor = - if (isDanger) { - MaterialTheme.colorScheme.errorContainer - } else { - MaterialTheme.colorScheme.primaryContainer - } - val iconTint = - if (isDanger) { - MaterialTheme.colorScheme.onErrorContainer - } else { - MaterialTheme.colorScheme.onPrimaryContainer - } - val textColor = - if (isDanger) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurface - } - - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = - Modifier - .size(36.dp) - .clip(RoundedCornerShape(10.dp)) - .background(containerColor), - contentAlignment = Alignment.Center, - ) { - leadingIcon(iconTint) - } - Text( - text = stringRes(title), - style = MaterialTheme.typography.bodyLarge, - color = textColor, - modifier = - Modifier - .weight(1f) - .padding(start = 16.dp), - ) - Icon( - symbol = MaterialSymbols.ChevronRight, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.kt index 05ccb6207..b9f2d672c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.kt @@ -20,47 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Checkbox -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue 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.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor -import com.vitorpamplona.amethyst.ui.note.ShowUserButton -import com.vitorpamplona.amethyst.ui.note.UserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.screen.UserFeedState -import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size15dp -import com.vitorpamplona.amethyst.ui.theme.Size55dp @Composable fun BlockedUsersScreen( @@ -78,7 +50,7 @@ fun BlockedUsersScreen( topBar = { BlockListTopBar( title = R.string.blocked_users, - selected = selected, + selectedCount = selected.size, onCancel = { selected = emptySet() }, onUnblock = { accountViewModel.showUsers(selected.toList()) @@ -99,81 +71,3 @@ fun BlockedUsersScreen( ) } } - -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal fun SelectableUserList( - modifier: Modifier = Modifier, - viewModel: UserFeedViewModel, - emptyMessage: Int, - selected: Set, - onToggle: ((String) -> Unit)? = null, - accountViewModel: AccountViewModel, - nav: INav, -) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - val selectionMode = selected.isNotEmpty() - - when (val state = feedState) { - is UserFeedState.Loaded -> { - val items by state.feed.collectAsStateWithLifecycle() - val listState = rememberLazyListState() - - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, user -> - val isSelected = user.pubkeyHex in selected - val rowModifier = - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (selectionMode && onToggle != null) { - onToggle(user.pubkeyHex) - } else { - nav.nav(routeFor(user)) - } - }, - onLongClick = { onToggle?.invoke(user.pubkeyHex) }, - ).let { - if (isSelected) { - it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) - } else { - it - } - } - - Row( - modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column( - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), - ) { - UsernameDisplay(user, accountViewModel = accountViewModel) - } - if (selectionMode) { - Checkbox(checked = isSelected, onCheckedChange = { onToggle?.invoke(user.pubkeyHex) }) - } else { - ShowUserButton { accountViewModel.show(user) } - } - } - HorizontalDivider(thickness = DividerThickness) - } - } - } - - else -> { - EmptyOrLoading(modifier = modifier, state = state, emptyMessage = emptyMessage) { - viewModel.invalidateData() - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt index 7608d79b3..ff64b1022 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt @@ -24,11 +24,14 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Checkbox @@ -79,10 +82,11 @@ fun HiddenWordsScreen( var selected by remember { mutableStateOf(setOf()) } Scaffold( + modifier = Modifier.imePadding(), topBar = { BlockListTopBar( title = R.string.hidden_words, - selected = selected, + selectedCount = selected.size, onCancel = { selected = emptySet() }, onUnblock = { if (!accountViewModel.isWriteable()) { @@ -109,7 +113,6 @@ fun HiddenWordsScreen( viewModel = viewModel, selected = selected, onToggle = { selected = if (it in selected) selected - it else selected + it }, - accountViewModel = accountViewModel, ) } } @@ -120,7 +123,6 @@ private fun HiddenWordsList( viewModel: HiddenWordsFeedViewModel, selected: Set, onToggle: (String) -> Unit, - accountViewModel: AccountViewModel, ) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() @@ -130,8 +132,12 @@ private fun HiddenWordsList( if (items.isEmpty()) { EmptyState(modifier, R.string.security_hidden_words_empty) } else { - Column(modifier = modifier.fillMaxSize()) { - items.forEach { word -> + val listState = rememberLazyListState() + LazyColumn( + modifier = modifier.fillMaxSize(), + state = listState, + ) { + items(items, key = { it }) { word -> MutedWordRow( tag = word, isSelected = word in selected, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 932efb399..a175ccf50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -21,24 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row 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.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton @@ -51,7 +45,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -110,19 +104,18 @@ private fun SensitiveContentTile(accountViewModel: AccountViewModel) { val showSensitive by accountViewModel.account.settings.syncedSettings.security.showSensitiveContent .collectAsStateWithLifecycle() - SettingsTile( - icon = MaterialSymbols.VisibilityOff, + SettingsBlockTile( + icon = MaterialSymbols.Visibility, title = stringRes(R.string.show_sensitive_content_title), description = stringRes(R.string.show_sensitive_content_explainer), ) { val current = parseWarningType(showSensitive) - val options = listOf(WarningType.WARN, WarningType.SHOW, WarningType.HIDE) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - options.forEachIndexed { index, type -> + WarningType.entries.forEachIndexed { index, type -> SegmentedButton( selected = current == type, onClick = { accountViewModel.updateShowSensitiveContent(type.prefCode) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), + shape = SegmentedButtonDefaults.itemShape(index = index, count = WarningType.entries.size), ) { Text(stringRes(type.resourceId)) } @@ -138,8 +131,8 @@ private fun FilterSpamTile(accountViewModel: AccountViewModel) { SwitchTile( icon = MaterialSymbols.FilterAlt, - title = stringRes(R.string.filter_spam_from_strangers_title), - description = stringRes(R.string.filter_spam_from_strangers_explainer), + titleRes = R.string.filter_spam_from_strangers_title, + descriptionRes = R.string.filter_spam_from_strangers_explainer, checked = filterSpam, onCheckedChange = { accountViewModel.updateFilterSpam(it) }, ) @@ -152,8 +145,8 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { SwitchTile( icon = MaterialSymbols.Shield, - title = stringRes(R.string.hide_community_rules_violations_title), - description = stringRes(R.string.hide_community_rules_violations_explainer), + titleRes = R.string.hide_community_rules_violations_title, + descriptionRes = R.string.hide_community_rules_violations_explainer, checked = hideViolations, onCheckedChange = { accountViewModel.account.settings.changeHideCommunityRulesViolations(it) }, ) @@ -165,9 +158,9 @@ private fun DisableClientTagTile(accountViewModel: AccountViewModel) { .collectAsStateWithLifecycle() SwitchTile( - icon = MaterialSymbols.Tag, - title = stringRes(R.string.disable_client_tag_title), - description = stringRes(R.string.disable_client_tag_explainer), + icon = MaterialSymbols.Code, + titleRes = R.string.disable_client_tag_title, + descriptionRes = R.string.disable_client_tag_explainer, checked = disableClientTag, onCheckedChange = { accountViewModel.updateDisableClientTag(it) }, ) @@ -180,30 +173,25 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) { val threshold by accountViewModel.account.settings.syncedSettings.security.reportWarningThreshold .collectAsStateWithLifecycle() - Column { - SwitchTile( - icon = MaterialSymbols.Report, - title = stringRes(R.string.warn_when_posts_have_reports_from_your_follows_title), - description = stringRes(R.string.warn_when_posts_have_reports_from_your_follows_explainer), - checked = warnReports, - onCheckedChange = { accountViewModel.updateWarnReports(it) }, - ) - - SettingsTile( - icon = null, - title = stringRes(R.string.report_warning_threshold_title), - description = stringRes(R.string.report_warning_threshold_explainer), - indented = true, + SwitchTile( + icon = MaterialSymbols.Report, + titleRes = R.string.warn_when_posts_have_reports_from_your_follows_title, + descriptionRes = R.string.warn_when_posts_have_reports_from_your_follows_explainer, + checked = warnReports, + onCheckedChange = { accountViewModel.updateWarnReports(it) }, + ) + SettingsSubControlRow( + title = stringRes(R.string.report_warning_threshold_title), + description = stringRes(R.string.report_warning_threshold_explainer), + enabled = warnReports, + ) { + Stepper( + value = threshold.coerceAtLeast(1), + min = 1, + max = 999, enabled = warnReports, - ) { - Stepper( - value = threshold.coerceAtLeast(1), - min = 1, - max = 999, - enabled = warnReports, - onValueChange = { accountViewModel.updateReportWarningThreshold(it) }, - ) - } + onValueChange = { accountViewModel.updateReportWarningThreshold(it) }, + ) } } @@ -212,7 +200,7 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { val maxHashtags by accountViewModel.account.settings.syncedSettings.security.maxHashtagLimit .collectAsStateWithLifecycle() - SettingsTile( + SettingsControlRow( icon = MaterialSymbols.Tag, title = stringRes(R.string.max_hashtag_limit_title), description = stringRes(R.string.max_hashtag_limit_explainer), @@ -227,6 +215,24 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { } } +@Composable +private fun SwitchTile( + icon: MaterialSymbol, + titleRes: Int, + descriptionRes: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(titleRes), + description = stringRes(descriptionRes), + onClick = { onCheckedChange(!checked) }, + ) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + @Composable private fun BlockedContentSection( accountViewModel: AccountViewModel, @@ -236,224 +242,58 @@ private fun BlockedContentSection( .collectAsStateWithLifecycle() SettingsSection(R.string.security_section_blocked_content) { - BlockedContentRow( + SettingsItem( + title = R.string.blocked_users, icon = MaterialSymbols.PersonOff, - title = stringRes(R.string.blocked_users), - count = hidden.hiddenUsers.size, + trailing = { CountBadge(hidden.hiddenUsers.size) }, onClick = { nav.nav(Route.BlockedUsers) }, ) SettingsDivider() - BlockedContentRow( + SettingsItem( + title = R.string.spamming_users, icon = MaterialSymbols.Block, - title = stringRes(R.string.spamming_users), - count = hidden.spammers.size, + trailing = { CountBadge(hidden.spammers.size) }, onClick = { nav.nav(Route.SpammingUsers) }, ) SettingsDivider() - BlockedContentRow( - icon = MaterialSymbols.Tag, - title = stringRes(R.string.hidden_words), - count = hidden.hiddenWords.size, + SettingsItem( + title = R.string.hidden_words, + icon = MaterialSymbols.VisibilityOff, + trailing = { CountBadge(hidden.hiddenWords.size) }, onClick = { nav.nav(Route.HiddenWords) }, ) SettingsDivider() - BlockedContentRow( + SettingsItem( + title = R.string.settings_muted_threads_title, icon = MaterialSymbols.Forum, - title = stringRes(R.string.settings_muted_threads_title), - count = hidden.mutedThreads.size, + trailing = { CountBadge(hidden.mutedThreads.size) }, onClick = { nav.nav(Route.MutedThreads) }, ) } } -// ────────────────────────────────────────────────────────────────────────────── -// Building blocks -// ────────────────────────────────────────────────────────────────────────────── - -@Composable -private fun SettingsSection( - title: Int, - content: @Composable ColumnScope.() -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringRes(title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 4.dp), - ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column(content = content) - } - } -} - -@Composable -private fun SettingsDivider() { - HorizontalDivider( - modifier = Modifier.padding(start = 68.dp), - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant, - ) -} - -@Composable -private fun SettingsTile( - icon: MaterialSymbol?, - title: String, - description: String, - indented: Boolean = false, - enabled: Boolean = true, - trailing: @Composable () -> Unit, -) { - val alpha = if (enabled) 1f else 0.5f - Row( - modifier = - Modifier - .fillMaxWidth() - .padding( - start = if (indented) 56.dp else 16.dp, - end = 16.dp, - top = 12.dp, - bottom = 12.dp, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - if (icon != null) { - SettingsLeadingIcon(icon) - } - Column( - modifier = - Modifier - .weight(1f) - .padding(start = if (icon != null) 16.dp else 0.dp, end = 12.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), - ) - } - Box(contentAlignment = Alignment.Center) { trailing() } - } -} - -@Composable -private fun SwitchTile( - icon: MaterialSymbol, - title: String, - description: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!checked) } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - SettingsLeadingIcon(icon) - Column( - modifier = - Modifier - .weight(1f) - .padding(start = 16.dp, end = 12.dp), - ) { - Text(text = title, style = MaterialTheme.typography.bodyLarge) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} - -@Composable -private fun SettingsLeadingIcon(icon: MaterialSymbol) { - Box( - modifier = - Modifier - .size(36.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } -} - -@Composable -private fun BlockedContentRow( - icon: MaterialSymbol, - title: String, - count: Int, - onClick: () -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - SettingsLeadingIcon(icon) - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - modifier = - Modifier - .weight(1f) - .padding(start = 16.dp), - ) - CountBadge(count) - Icon( - symbol = MaterialSymbols.ChevronRight, - contentDescription = null, - modifier = Modifier.padding(start = 8.dp).size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - @Composable private fun CountBadge(count: Int) { - if (count <= 0) return + // Reserve space so transitions to/from 0 don't shift the row. Box( - modifier = - Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 10.dp, vertical = 2.dp), + modifier = Modifier.widthIn(min = 28.dp), + contentAlignment = Alignment.Center, ) { - Text( - text = count.toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) + if (count > 0) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 10.dp, vertical = 2.dp), + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } } } @@ -466,13 +306,11 @@ private fun Stepper( unsetLabel: String? = null, onValueChange: (Int) -> Unit, ) { - val alpha = if (enabled) 1f else 0.5f Row( modifier = Modifier - .height(40.dp) .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = alpha)), + .background(MaterialTheme.colorScheme.surfaceContainerHigh), verticalAlignment = Alignment.CenterVertically, ) { IconButton( @@ -483,20 +321,13 @@ private fun Stepper( symbol = MaterialSymbols.Remove, contentDescription = "−", modifier = Modifier.size(18.dp), - tint = LocalContentColor.current.copy(alpha = alpha), ) } - val display = - if (unsetLabel != null && value <= 0) { - unsetLabel - } else { - value.toString() - } Text( - text = display, + text = if (unsetLabel != null && value <= 0) unsetLabel else value.toString(), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), - modifier = Modifier.padding(horizontal = 4.dp), + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(min = 28.dp), ) IconButton( enabled = enabled && value < max, @@ -506,7 +337,6 @@ private fun Stepper( symbol = MaterialSymbols.Add, contentDescription = "+", modifier = Modifier.size(18.dp), - tint = LocalContentColor.current.copy(alpha = alpha), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt index 87e21ae61..2fba72afd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt @@ -20,14 +20,23 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -45,14 +54,25 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.ShowUserButton +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.screen.UserFeedState +import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size15dp +import com.vitorpamplona.amethyst.ui.theme.Size55dp @Composable internal fun InvalidateOnBlockListChange( @@ -73,18 +93,18 @@ internal fun InvalidateOnBlockListChange( @Composable internal fun BlockListTopBar( title: Int, - selected: Set<*>, + selectedCount: Int, onCancel: () -> Unit, onUnblock: () -> Unit, nav: INav, ) { - if (selected.isEmpty()) { + if (selectedCount == 0) { TopBarWithBackButton(stringRes(id = title), nav) } else { ShorterTopAppBar( title = { Text( - text = stringRes(R.string.num_selected, selected.size), + text = stringRes(R.string.num_selected, selectedCount), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, @@ -107,18 +127,87 @@ internal fun BlockListTopBar( } } +@OptIn(ExperimentalFoundationApi::class) @Composable -internal fun EmptyOrLoading( +internal fun SelectableUserList( modifier: Modifier = Modifier, - state: UserFeedState, + viewModel: UserFeedViewModel, emptyMessage: Int, - onRefresh: () -> Unit, + selected: Set, + onToggle: (String) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, ) { - when (state) { - is UserFeedState.Empty -> EmptyState(modifier, emptyMessage) - is UserFeedState.Loading -> LoadingFeed() - is UserFeedState.FeedError -> FeedError(state.errorMessage, onRefresh) - else -> Unit + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val selectionMode = selected.isNotEmpty() + + when (val state = feedState) { + is UserFeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + items(items, key = { it.pubkeyHex }) { user -> + val isSelected = user.pubkeyHex in selected + val rowModifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (selectionMode) { + onToggle(user.pubkeyHex) + } else { + nav.nav(routeFor(user)) + } + }, + onLongClick = { onToggle(user.pubkeyHex) }, + ).let { + if (isSelected) { + it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) + } else { + it + } + } + + Row( + modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } + if (selectionMode) { + Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) }) + } else { + ShowUserButton { accountViewModel.show(user) } + } + } + HorizontalDivider(thickness = DividerThickness) + } + } + } + + is UserFeedState.Empty -> { + EmptyState(modifier, emptyMessage) + } + + is UserFeedState.Loading -> { + LoadingFeed() + } + + is UserFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt new file mode 100644 index 000000000..7da0f5410 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -0,0 +1,350 @@ +/* + * 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.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.stringRes +import androidx.compose.material3.Icon as Material3Icon + +@Composable +internal fun SettingsSection( + title: Int, + isDanger: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = + if (isDanger) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.padding(horizontal = 4.dp), + ) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column(content = content) + } + } +} + +@Composable +internal fun SettingsDivider() { + HorizontalDivider( + modifier = Modifier.padding(start = 68.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant, + ) +} + +/** Navigation row: icon + title + optional `trailing` (count badge, etc.) + chevron. */ +@Composable +internal fun SettingsItem( + title: Int, + icon: MaterialSymbol, + isDanger: Boolean = false, + trailing: @Composable () -> Unit = {}, + onClick: () -> Unit, +) { + SettingsItemRow( + title = title, + isDanger = isDanger, + trailing = trailing, + onClick = onClick, + ) { tint -> + Icon( + symbol = icon, + contentDescription = stringRes(title), + modifier = Modifier.size(20.dp), + tint = tint, + ) + } +} + +/** Navigation row variant taking a drawable painter as the leading icon. */ +@Composable +internal fun SettingsItem( + title: Int, + iconPainter: Int, + iconPainterRef: Int, + isDanger: Boolean = false, + trailing: @Composable () -> Unit = {}, + onClick: () -> Unit, +) { + val painter: Painter = painterRes(iconPainter, iconPainterRef) + SettingsItemRow( + title = title, + isDanger = isDanger, + trailing = trailing, + onClick = onClick, + ) { tint -> + Material3Icon( + painter = painter, + contentDescription = stringRes(title), + modifier = Modifier.size(20.dp), + tint = tint, + ) + } +} + +@Composable +private fun SettingsItemRow( + title: Int, + isDanger: Boolean, + trailing: @Composable () -> Unit, + onClick: () -> Unit, + leadingIcon: @Composable (tint: Color) -> Unit, +) { + val containerColor = + if (isDanger) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + val iconTint = + if (isDanger) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + val textColor = + if (isDanger) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsIconBox(containerColor) { leadingIcon(iconTint) } + Text( + text = stringRes(title), + style = MaterialTheme.typography.bodyLarge, + color = textColor, + modifier = + Modifier + .weight(1f) + .padding(start = 16.dp), + ) + trailing() + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.padding(start = 8.dp).size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Control row: icon + title + description, with an inline `trailing` control (switch, stepper). + * Clicking anywhere on the row invokes [onClick] — pass `null` to disable row clicks + * (useful when the control alone should toggle state). + */ +@Composable +internal fun SettingsControlRow( + icon: MaterialSymbol, + title: String, + description: String, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + trailing: @Composable () -> Unit, +) { + val rowModifier = + Modifier + .fillMaxWidth() + .let { if (onClick != null && enabled) it.clickable(onClick = onClick) else it } + .padding(horizontal = 16.dp, vertical = 12.dp) + val alpha = if (enabled) 1f else 0.5f + + Row( + modifier = rowModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsIconBox(MaterialTheme.colorScheme.primaryContainer) { + Icon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = alpha), + ) + } + Column( + modifier = + Modifier + .weight(1f) + .padding(start = 16.dp, end = 12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + ) + } + trailing() + } +} + +/** + * Sub-row variant of [SettingsControlRow]: indented in place of a leading icon, + * used for controls hierarchically grouped under the row above (e.g. a threshold + * that only matters when the parent toggle is on). + */ +@Composable +internal fun SettingsSubControlRow( + title: String, + description: String, + enabled: Boolean = true, + trailing: @Composable () -> Unit, +) { + val alpha = if (enabled) 1f else 0.5f + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 68.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f).padding(end = 12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + ) + } + trailing() + } +} + +/** + * Tile where the control sits below the title + description (e.g. a full-width + * SegmentedButtonRow). Use when the control would crowd a trailing slot. + */ +@Composable +internal fun SettingsBlockTile( + icon: MaterialSymbol, + title: String, + description: String, + content: @Composable () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + SettingsIconBox(MaterialTheme.colorScheme.primaryContainer) { + Icon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Column( + modifier = + Modifier + .weight(1f) + .padding(start = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + content() + } + } +} + +@Composable +private fun SettingsIconBox( + containerColor: Color, + content: @Composable () -> Unit, +) { + Box( + modifier = + Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(containerColor), + contentAlignment = Alignment.Center, + content = { content() }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SpammingUsersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SpammingUsersScreen.kt index c00c79b6c..fc4095298 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SpammingUsersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SpammingUsersScreen.kt @@ -50,7 +50,7 @@ fun SpammingUsersScreen( topBar = { BlockListTopBar( title = R.string.spamming_users, - selected = selected, + selectedCount = selected.size, onCancel = { selected = emptySet() }, onUnblock = { accountViewModel.showUsers(selected.toList()) From 06e88cd30ea60d28f63ef493a58eff72b560c096 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 22:00:38 +0000 Subject: [PATCH 3/5] refactor: address second-pass Security Filters audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore pull-to-refresh on BlockedUsers and SpammingUsers by wrapping SelectableUserList in RefresheableBox (regression from the first refactor; HiddenWords and MutedThreads remain non-pull-refresh to match the original behavior). - Drop the redundant transientHiddenUsers subscription in InvalidateOnBlockListChange — hiddenUsers.flow already combines it. Also drop the meaningless accountViewModel key on LaunchedEffect. - Move CountBadge and Stepper into SettingsSectionCard as SettingsCountBadge and SettingsStepper so other settings screens can reuse them. Widen the stepper value cell to min 40.dp so three-digit values like 999 fit comfortably. - Drop the unused `enabled` flag from SettingsControlRow (sub-controls use SettingsSubControlRow for dimming). Tighten the docstring. - Unify SwitchTile to take @StringRes Int params, matching the @StringRes annotations now applied to SettingsItem, SettingsSection, BlockListTopBar, SelectableUserList, EmptyState. - Shorten property chains by binding `val security = …syncedSettings.security` per tile when more than one field is read; pass method references (accountViewModel::updateFilterSpam etc.) where possible. - Hoist WarningType.entries to a local val so the segmented button row reads `count = options.size` instead of evaluating twice. - MutedThreadsScreen: replace itemsIndexed with items (unused index) and use MaterialTheme.colorScheme.onPrimary instead of hardcoded Color.White for the "Unmute" button text. - Add a @Preview for SecurityFiltersScreen, mirroring AllSettingsScreen. --- .../loggedIn/settings/MutedThreadsScreen.kt | 10 +- .../settings/SecurityFiltersScreen.kt | 160 +++++------------- .../loggedIn/settings/SecurityListsCommon.kt | 131 +++++++------- .../loggedIn/settings/SettingsSectionCard.kt | 121 ++++++++++--- 4 files changed, 214 insertions(+), 208 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt index e90a7d76c..de6e5eac6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -100,7 +99,7 @@ private fun MutedThreadsList( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { - itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, note -> + items(items.list, key = { it.idHex }) { note -> MutedThreadRow(note = note, accountViewModel = accountViewModel) HorizontalDivider(thickness = DividerThickness) } @@ -175,7 +174,10 @@ private fun MutedThreadRow( ), contentPadding = ButtonPadding, ) { - Text(text = stringRes(R.string.action_unmute), color = Color.White) + Text( + text = stringRes(R.string.action_unmute), + color = MaterialTheme.colorScheme.onPrimary, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index a175ccf50..e66f171c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -20,20 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings -import androidx.compose.foundation.background +import androidx.annotation.StringRes 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.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults @@ -42,23 +35,23 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.WarningType import com.vitorpamplona.amethyst.model.parseWarningType +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton 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.ThemeComparisonColumn @Composable fun SecurityFiltersScreen( @@ -101,21 +94,22 @@ private fun SecurityPreferencesSection(accountViewModel: AccountViewModel) { @Composable private fun SensitiveContentTile(accountViewModel: AccountViewModel) { - val showSensitive by accountViewModel.account.settings.syncedSettings.security.showSensitiveContent - .collectAsStateWithLifecycle() + val security = accountViewModel.account.settings.syncedSettings.security + val showSensitive by security.showSensitiveContent.collectAsStateWithLifecycle() + val current = parseWarningType(showSensitive) + val options = WarningType.entries SettingsBlockTile( icon = MaterialSymbols.Visibility, title = stringRes(R.string.show_sensitive_content_title), description = stringRes(R.string.show_sensitive_content_explainer), ) { - val current = parseWarningType(showSensitive) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - WarningType.entries.forEachIndexed { index, type -> + options.forEachIndexed { index, type -> SegmentedButton( selected = current == type, onClick = { accountViewModel.updateShowSensitiveContent(type.prefCode) }, - shape = SegmentedButtonDefaults.itemShape(index = index, count = WarningType.entries.size), + shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size), ) { Text(stringRes(type.resourceId)) } @@ -126,15 +120,16 @@ private fun SensitiveContentTile(accountViewModel: AccountViewModel) { @Composable private fun FilterSpamTile(accountViewModel: AccountViewModel) { - val filterSpam by accountViewModel.account.settings.syncedSettings.security.filterSpamFromStrangers + val filterSpam by accountViewModel.account.settings.syncedSettings.security + .filterSpamFromStrangers .collectAsStateWithLifecycle() SwitchTile( icon = MaterialSymbols.FilterAlt, - titleRes = R.string.filter_spam_from_strangers_title, - descriptionRes = R.string.filter_spam_from_strangers_explainer, + title = R.string.filter_spam_from_strangers_title, + description = R.string.filter_spam_from_strangers_explainer, checked = filterSpam, - onCheckedChange = { accountViewModel.updateFilterSpam(it) }, + onCheckedChange = accountViewModel::updateFilterSpam, ) } @@ -145,8 +140,8 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { SwitchTile( icon = MaterialSymbols.Shield, - titleRes = R.string.hide_community_rules_violations_title, - descriptionRes = R.string.hide_community_rules_violations_explainer, + title = R.string.hide_community_rules_violations_title, + description = R.string.hide_community_rules_violations_explainer, checked = hideViolations, onCheckedChange = { accountViewModel.account.settings.changeHideCommunityRulesViolations(it) }, ) @@ -154,50 +149,51 @@ private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { @Composable private fun DisableClientTagTile(accountViewModel: AccountViewModel) { - val disableClientTag by accountViewModel.account.settings.syncedSettings.security.disableClientTag + val disableClientTag by accountViewModel.account.settings.syncedSettings.security + .disableClientTag .collectAsStateWithLifecycle() SwitchTile( icon = MaterialSymbols.Code, - titleRes = R.string.disable_client_tag_title, - descriptionRes = R.string.disable_client_tag_explainer, + title = R.string.disable_client_tag_title, + description = R.string.disable_client_tag_explainer, checked = disableClientTag, - onCheckedChange = { accountViewModel.updateDisableClientTag(it) }, + onCheckedChange = accountViewModel::updateDisableClientTag, ) } @Composable private fun WarnReportsTile(accountViewModel: AccountViewModel) { - val warnReports by accountViewModel.account.settings.syncedSettings.security.warnAboutPostsWithReports - .collectAsStateWithLifecycle() - val threshold by accountViewModel.account.settings.syncedSettings.security.reportWarningThreshold - .collectAsStateWithLifecycle() + val security = accountViewModel.account.settings.syncedSettings.security + val warnReports by security.warnAboutPostsWithReports.collectAsStateWithLifecycle() + val threshold by security.reportWarningThreshold.collectAsStateWithLifecycle() SwitchTile( icon = MaterialSymbols.Report, - titleRes = R.string.warn_when_posts_have_reports_from_your_follows_title, - descriptionRes = R.string.warn_when_posts_have_reports_from_your_follows_explainer, + title = R.string.warn_when_posts_have_reports_from_your_follows_title, + description = R.string.warn_when_posts_have_reports_from_your_follows_explainer, checked = warnReports, - onCheckedChange = { accountViewModel.updateWarnReports(it) }, + onCheckedChange = accountViewModel::updateWarnReports, ) SettingsSubControlRow( title = stringRes(R.string.report_warning_threshold_title), description = stringRes(R.string.report_warning_threshold_explainer), enabled = warnReports, ) { - Stepper( + SettingsStepper( value = threshold.coerceAtLeast(1), min = 1, max = 999, enabled = warnReports, - onValueChange = { accountViewModel.updateReportWarningThreshold(it) }, + onValueChange = accountViewModel::updateReportWarningThreshold, ) } } @Composable private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { - val maxHashtags by accountViewModel.account.settings.syncedSettings.security.maxHashtagLimit + val maxHashtags by accountViewModel.account.settings.syncedSettings.security + .maxHashtagLimit .collectAsStateWithLifecycle() SettingsControlRow( @@ -205,12 +201,12 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { title = stringRes(R.string.max_hashtag_limit_title), description = stringRes(R.string.max_hashtag_limit_explainer), ) { - Stepper( + SettingsStepper( value = maxHashtags, min = 0, max = 99, unsetLabel = stringRes(R.string.security_unlimited), - onValueChange = { accountViewModel.updateMaxHashtagLimit(it) }, + onValueChange = accountViewModel::updateMaxHashtagLimit, ) } } @@ -218,15 +214,15 @@ private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { @Composable private fun SwitchTile( icon: MaterialSymbol, - titleRes: Int, - descriptionRes: Int, + @StringRes title: Int, + @StringRes description: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit, ) { SettingsControlRow( icon = icon, - title = stringRes(titleRes), - description = stringRes(descriptionRes), + title = stringRes(title), + description = stringRes(description), onClick = { onCheckedChange(!checked) }, ) { Switch(checked = checked, onCheckedChange = onCheckedChange) @@ -245,99 +241,37 @@ private fun BlockedContentSection( SettingsItem( title = R.string.blocked_users, icon = MaterialSymbols.PersonOff, - trailing = { CountBadge(hidden.hiddenUsers.size) }, + trailing = { SettingsCountBadge(hidden.hiddenUsers.size) }, onClick = { nav.nav(Route.BlockedUsers) }, ) SettingsDivider() SettingsItem( title = R.string.spamming_users, icon = MaterialSymbols.Block, - trailing = { CountBadge(hidden.spammers.size) }, + trailing = { SettingsCountBadge(hidden.spammers.size) }, onClick = { nav.nav(Route.SpammingUsers) }, ) SettingsDivider() SettingsItem( title = R.string.hidden_words, icon = MaterialSymbols.VisibilityOff, - trailing = { CountBadge(hidden.hiddenWords.size) }, + trailing = { SettingsCountBadge(hidden.hiddenWords.size) }, onClick = { nav.nav(Route.HiddenWords) }, ) SettingsDivider() SettingsItem( title = R.string.settings_muted_threads_title, icon = MaterialSymbols.Forum, - trailing = { CountBadge(hidden.mutedThreads.size) }, + trailing = { SettingsCountBadge(hidden.mutedThreads.size) }, onClick = { nav.nav(Route.MutedThreads) }, ) } } +@Preview @Composable -private fun CountBadge(count: Int) { - // Reserve space so transitions to/from 0 don't shift the row. - Box( - modifier = Modifier.widthIn(min = 28.dp), - contentAlignment = Alignment.Center, - ) { - if (count > 0) { - Box( - modifier = - Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 10.dp, vertical = 2.dp), - ) { - Text( - text = count.toString(), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } -} - -@Composable -private fun Stepper( - value: Int, - min: Int, - max: Int, - enabled: Boolean = true, - unsetLabel: String? = null, - onValueChange: (Int) -> Unit, -) { - Row( - modifier = - Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - enabled = enabled && value > min, - onClick = { onValueChange((value - 1).coerceAtLeast(min)) }, - ) { - Icon( - symbol = MaterialSymbols.Remove, - contentDescription = "−", - modifier = Modifier.size(18.dp), - ) - } - Text( - text = if (unsetLabel != null && value <= 0) unsetLabel else value.toString(), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - modifier = Modifier.widthIn(min = 28.dp), - ) - IconButton( - enabled = enabled && value < max, - onClick = { onValueChange((value + 1).coerceAtMost(max)) }, - ) { - Icon( - symbol = MaterialSymbols.Add, - contentDescription = "+", - modifier = Modifier.size(18.dp), - ) - } +fun SecurityFiltersScreenPreview() { + ThemeComparisonColumn { + SecurityFiltersScreen(mockAccountViewModel(), EmptyNav()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt index 2fba72afd..8463f3386 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -54,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -74,25 +76,25 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size15dp import com.vitorpamplona.amethyst.ui.theme.Size55dp +/** + * Re-runs [invalidate] whenever the user's block/mute state changes. `hiddenUsers.flow` + * already combines transient spammers, so subscribing to that alone covers both. + */ @Composable internal fun InvalidateOnBlockListChange( accountViewModel: AccountViewModel, invalidate: () -> Unit, ) { - val transientSpammers by accountViewModel.account.hiddenUsers.transientHiddenUsers - .collectAsStateWithLifecycle() val blockListState by accountViewModel.account.hiddenUsers.flow .collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, transientSpammers, blockListState) { - invalidate() - } + LaunchedEffect(blockListState) { invalidate() } } @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun BlockListTopBar( - title: Int, + @StringRes title: Int, selectedCount: Int, onCancel: () -> Unit, onUnblock: () -> Unit, @@ -132,81 +134,84 @@ internal fun BlockListTopBar( internal fun SelectableUserList( modifier: Modifier = Modifier, viewModel: UserFeedViewModel, - emptyMessage: Int, + @StringRes emptyMessage: Int, selected: Set, onToggle: (String) -> Unit, accountViewModel: AccountViewModel, nav: INav, + enablePullRefresh: Boolean = true, ) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - val selectionMode = selected.isNotEmpty() + RefresheableBox(viewModel, enablePullRefresh) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val selectionMode = selected.isNotEmpty() - when (val state = feedState) { - is UserFeedState.Loaded -> { - val items by state.feed.collectAsStateWithLifecycle() - val listState = rememberLazyListState() + when (val state = feedState) { + is UserFeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - items(items, key = { it.pubkeyHex }) { user -> - val isSelected = user.pubkeyHex in selected - val rowModifier = - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (selectionMode) { - onToggle(user.pubkeyHex) + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + items(items, key = { it.pubkeyHex }) { user -> + val isSelected = user.pubkeyHex in selected + val rowModifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (selectionMode) { + onToggle(user.pubkeyHex) + } else { + nav.nav(routeFor(user)) + } + }, + onLongClick = { onToggle(user.pubkeyHex) }, + ).let { + if (isSelected) { + it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) } else { - nav.nav(routeFor(user)) + it } - }, - onLongClick = { onToggle(user.pubkeyHex) }, - ).let { - if (isSelected) { - it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) - } else { - it } - } - Row( - modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column( - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), + Row( + modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), + verticalAlignment = Alignment.CenterVertically, ) { - UsernameDisplay(user, accountViewModel = accountViewModel) - } - if (selectionMode) { - Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) }) - } else { - ShowUserButton { accountViewModel.show(user) } + UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } + if (selectionMode) { + Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) }) + } else { + ShowUserButton { accountViewModel.show(user) } + } } + HorizontalDivider(thickness = DividerThickness) } - HorizontalDivider(thickness = DividerThickness) } } - } - is UserFeedState.Empty -> { - EmptyState(modifier, emptyMessage) - } + is UserFeedState.Empty -> { + EmptyState(modifier, emptyMessage) + } - is UserFeedState.Loading -> { - LoadingFeed() - } + is UserFeedState.Loading -> { + LoadingFeed() + } - is UserFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } + is UserFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } } } } @@ -214,7 +219,7 @@ internal fun SelectableUserList( @Composable internal fun EmptyState( modifier: Modifier = Modifier, - message: Int, + @StringRes message: Int, ) { Column( modifier = modifier.fillMaxSize().padding(32.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt index 7da0f5410..10f7edd6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -30,10 +31,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -43,6 +46,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol @@ -53,7 +57,7 @@ import androidx.compose.material3.Icon as Material3Icon @Composable internal fun SettingsSection( - title: Int, + @StringRes title: Int, isDanger: Boolean = false, content: @Composable ColumnScope.() -> Unit, ) { @@ -93,10 +97,10 @@ internal fun SettingsDivider() { ) } -/** Navigation row: icon + title + optional `trailing` (count badge, etc.) + chevron. */ +/** Navigation row: icon + title + optional [trailing] (count badge, etc.) + chevron. */ @Composable internal fun SettingsItem( - title: Int, + @StringRes title: Int, icon: MaterialSymbol, isDanger: Boolean = false, trailing: @Composable () -> Unit = {}, @@ -120,7 +124,7 @@ internal fun SettingsItem( /** Navigation row variant taking a drawable painter as the leading icon. */ @Composable internal fun SettingsItem( - title: Int, + @StringRes title: Int, iconPainter: Int, iconPainterRef: Int, isDanger: Boolean = false, @@ -145,7 +149,7 @@ internal fun SettingsItem( @Composable private fun SettingsItemRow( - title: Int, + @StringRes title: Int, isDanger: Boolean, trailing: @Composable () -> Unit, onClick: () -> Unit, @@ -199,28 +203,24 @@ private fun SettingsItemRow( } /** - * Control row: icon + title + description, with an inline `trailing` control (switch, stepper). - * Clicking anywhere on the row invokes [onClick] — pass `null` to disable row clicks - * (useful when the control alone should toggle state). + * Control row: icon + title + description, with an inline [trailing] control (switch, stepper). + * When [onClick] is non-null the whole row becomes clickable — useful for `Switch` rows where + * tapping anywhere should toggle the state. */ @Composable internal fun SettingsControlRow( icon: MaterialSymbol, title: String, description: String, - enabled: Boolean = true, onClick: (() -> Unit)? = null, trailing: @Composable () -> Unit, ) { - val rowModifier = - Modifier - .fillMaxWidth() - .let { if (onClick != null && enabled) it.clickable(onClick = onClick) else it } - .padding(horizontal = 16.dp, vertical = 12.dp) - val alpha = if (enabled) 1f else 0.5f - Row( - modifier = rowModifier, + modifier = + Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { SettingsIconBox(MaterialTheme.colorScheme.primaryContainer) { @@ -228,7 +228,7 @@ internal fun SettingsControlRow( symbol = icon, contentDescription = null, modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = alpha), + tint = MaterialTheme.colorScheme.onPrimaryContainer, ) } Column( @@ -237,15 +237,11 @@ internal fun SettingsControlRow( .weight(1f) .padding(start = 16.dp, end = 12.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), - ) + Text(text = title, style = MaterialTheme.typography.bodyLarge) Text( text = description, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } trailing() @@ -255,7 +251,7 @@ internal fun SettingsControlRow( /** * Sub-row variant of [SettingsControlRow]: indented in place of a leading icon, * used for controls hierarchically grouped under the row above (e.g. a threshold - * that only matters when the parent toggle is on). + * that only matters when the parent toggle is on). Text dims when [enabled] is false. */ @Composable internal fun SettingsSubControlRow( @@ -272,9 +268,7 @@ internal fun SettingsSubControlRow( .padding(start = 68.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column( - modifier = Modifier.weight(1f).padding(end = 12.dp), - ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( text = title, style = MaterialTheme.typography.bodyMedium, @@ -333,6 +327,77 @@ internal fun SettingsBlockTile( } } +/** Pill-shaped count for [SettingsItem.trailing]. Renders a space placeholder when zero. */ +@Composable +internal fun SettingsCountBadge(count: Int) { + Box( + modifier = Modifier.widthIn(min = 28.dp), + contentAlignment = Alignment.Center, + ) { + if (count > 0) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 10.dp, vertical = 2.dp), + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + +/** Numeric stepper: `-` value `+`. [unsetLabel] is shown when value <= 0 (e.g. "∞"). */ +@Composable +internal fun SettingsStepper( + value: Int, + min: Int, + max: Int, + enabled: Boolean = true, + unsetLabel: String? = null, + onValueChange: (Int) -> Unit, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + enabled = enabled && value > min, + onClick = { onValueChange((value - 1).coerceAtLeast(min)) }, + ) { + Icon( + symbol = MaterialSymbols.Remove, + contentDescription = "−", + modifier = Modifier.size(18.dp), + ) + } + Text( + text = if (unsetLabel != null && value <= 0) unsetLabel else value.toString(), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(min = 40.dp), + ) + IconButton( + enabled = enabled && value < max, + onClick = { onValueChange((value + 1).coerceAtMost(max)) }, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = "+", + modifier = Modifier.size(18.dp), + ) + } + } +} + @Composable private fun SettingsIconBox( containerColor: Color, From 0b7399feb0c66a8af7296e6f18940c4c34784d73 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 23:09:06 +0000 Subject: [PATCH 4/5] fix: pad Loading/Error states and tighten Stepper resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap the when-branches in SelectableUserList, HiddenWordsList, and MutedThreadsList in Box(modifier.fillMaxSize()) so the Scaffold padding is applied to every state — Loading/Error/Empty/Loaded — not just the LazyColumn. Fixes a regression where the loading spinner and error UI rendered under the top bar. - SettingsStepper now clamps `value` once into `[min, max]` and uses the raw `value` (re-clamped) in the +/- handlers. If the model starts below `min`, the first tap of `+` resyncs it to `min` instead of jumping `min+1` (skipping a step). Display still falls back to `unsetLabel` when `value <= 0`. - WarnReportsTile no longer pre-coerces threshold to >= 1 at the call site; the stepper handles it. - EmptyState drops its now-unused modifier parameter. --- .../loggedIn/settings/HiddenWordsScreen.kt | 59 +++++---- .../loggedIn/settings/MutedThreadsScreen.kt | 45 ++++--- .../settings/SecurityFiltersScreen.kt | 2 +- .../loggedIn/settings/SecurityListsCommon.kt | 120 +++++++++--------- .../loggedIn/settings/SettingsSectionCard.kt | 19 ++- 5 files changed, 131 insertions(+), 114 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt index ff64b1022..2500e8078 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -126,40 +127,42 @@ private fun HiddenWordsList( ) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - when (val state = feedState) { - is StringFeedState.Loaded -> { - val items by state.feed.collectAsStateWithLifecycle() - if (items.isEmpty()) { - EmptyState(modifier, R.string.security_hidden_words_empty) - } else { - val listState = rememberLazyListState() - LazyColumn( - modifier = modifier.fillMaxSize(), - state = listState, - ) { - items(items, key = { it }) { word -> - MutedWordRow( - tag = word, - isSelected = word in selected, - selectionMode = selected.isNotEmpty(), - onToggle = { onToggle(word) }, - ) - HorizontalDivider(thickness = DividerThickness) + Box(modifier.fillMaxSize()) { + when (val state = feedState) { + is StringFeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + if (items.isEmpty()) { + EmptyState(R.string.security_hidden_words_empty) + } else { + val listState = rememberLazyListState() + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { + items(items, key = { it }) { word -> + MutedWordRow( + tag = word, + isSelected = word in selected, + selectionMode = selected.isNotEmpty(), + onToggle = { onToggle(word) }, + ) + HorizontalDivider(thickness = DividerThickness) + } } } } - } - is StringFeedState.Empty -> { - EmptyState(modifier, R.string.security_hidden_words_empty) - } + is StringFeedState.Empty -> { + EmptyState(R.string.security_hidden_words_empty) + } - is StringFeedState.Loading -> { - LoadingFeed() - } + is StringFeedState.Loading -> { + LoadingFeed() + } - is StringFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } + is StringFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt index de6e5eac6..677be4dd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -90,32 +91,34 @@ private fun MutedThreadsList( ) { val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle() - when (val state = feedState) { - is FeedState.Loaded -> { - val items by state.feed.collectAsStateWithLifecycle() - val listState = rememberLazyListState() - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - items(items.list, key = { it.idHex }) { note -> - MutedThreadRow(note = note, accountViewModel = accountViewModel) - HorizontalDivider(thickness = DividerThickness) + Box(modifier.fillMaxSize()) { + when (val state = feedState) { + is FeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + items(items.list, key = { it.idHex }) { note -> + MutedThreadRow(note = note, accountViewModel = accountViewModel) + HorizontalDivider(thickness = DividerThickness) + } } } - } - is FeedState.Empty -> { - EmptyState(modifier, R.string.settings_muted_threads_empty) - } + is FeedState.Empty -> { + EmptyState(R.string.settings_muted_threads_empty) + } - is FeedState.Loading -> { - LoadingFeed() - } + is FeedState.Loading -> { + LoadingFeed() + } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index e66f171c3..c040be314 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -181,7 +181,7 @@ private fun WarnReportsTile(accountViewModel: AccountViewModel) { enabled = warnReports, ) { SettingsStepper( - value = threshold.coerceAtLeast(1), + value = threshold, min = 1, max = 999, enabled = warnReports, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt index 8463f3386..c2043f54e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable 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.fillMaxSize @@ -141,76 +142,80 @@ internal fun SelectableUserList( nav: INav, enablePullRefresh: Boolean = true, ) { - RefresheableBox(viewModel, enablePullRefresh) { - val feedState by viewModel.feedContent.collectAsStateWithLifecycle() - val selectionMode = selected.isNotEmpty() + // Outer Box applies caller padding (Scaffold insets) so the Loading/Error + // states inside RefresheableBox don't render under the top bar. + Box(modifier.fillMaxSize()) { + RefresheableBox(viewModel, enablePullRefresh) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + val selectionMode = selected.isNotEmpty() - when (val state = feedState) { - is UserFeedState.Loaded -> { - val items by state.feed.collectAsStateWithLifecycle() - val listState = rememberLazyListState() + when (val state = feedState) { + is UserFeedState.Loaded -> { + val items by state.feed.collectAsStateWithLifecycle() + val listState = rememberLazyListState() - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = rememberFeedContentPadding(FeedPadding), - state = listState, - ) { - items(items, key = { it.pubkeyHex }) { user -> - val isSelected = user.pubkeyHex in selected - val rowModifier = - Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (selectionMode) { - onToggle(user.pubkeyHex) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + items(items, key = { it.pubkeyHex }) { user -> + val isSelected = user.pubkeyHex in selected + val rowModifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (selectionMode) { + onToggle(user.pubkeyHex) + } else { + nav.nav(routeFor(user)) + } + }, + onLongClick = { onToggle(user.pubkeyHex) }, + ).let { + if (isSelected) { + it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) } else { - nav.nav(routeFor(user)) + it } - }, - onLongClick = { onToggle(user.pubkeyHex) }, - ).let { - if (isSelected) { - it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)) - } else { - it } - } - Row( - modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) - Column( - modifier = - Modifier - .padding(start = 10.dp) - .weight(1f), + Row( + modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp), + verticalAlignment = Alignment.CenterVertically, ) { - UsernameDisplay(user, accountViewModel = accountViewModel) - } - if (selectionMode) { - Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) }) - } else { - ShowUserButton { accountViewModel.show(user) } + UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav) + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } + if (selectionMode) { + Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) }) + } else { + ShowUserButton { accountViewModel.show(user) } + } } + HorizontalDivider(thickness = DividerThickness) } - HorizontalDivider(thickness = DividerThickness) } } - } - is UserFeedState.Empty -> { - EmptyState(modifier, emptyMessage) - } + is UserFeedState.Empty -> { + EmptyState(emptyMessage) + } - is UserFeedState.Loading -> { - LoadingFeed() - } + is UserFeedState.Loading -> { + LoadingFeed() + } - is UserFeedState.FeedError -> { - FeedError(state.errorMessage) { viewModel.invalidateData() } + is UserFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } } } } @@ -218,11 +223,10 @@ internal fun SelectableUserList( @Composable internal fun EmptyState( - modifier: Modifier = Modifier, @StringRes message: Int, ) { Column( - modifier = modifier.fillMaxSize().padding(32.dp), + modifier = Modifier.fillMaxSize().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt index 10f7edd6e..d190d1880 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -352,7 +352,13 @@ internal fun SettingsCountBadge(count: Int) { } } -/** Numeric stepper: `-` value `+`. [unsetLabel] is shown when value <= 0 (e.g. "∞"). */ +/** + * Numeric stepper: `-` value `+`. Display is clamped to `[min, max]`, and `-`/`+` + * operate on the raw `value` then re-clamp, so a model that starts out below `min` + * lifts to `min` on the first tap of `+` (no skipped step). [unsetLabel] is shown + * when `value <= 0` (typically the model uses `0` to mean "unlimited"); pass + * `null` to always show the number. + */ @Composable internal fun SettingsStepper( value: Int, @@ -362,6 +368,7 @@ internal fun SettingsStepper( unsetLabel: String? = null, onValueChange: (Int) -> Unit, ) { + val clamped = value.coerceIn(min, max) Row( modifier = Modifier @@ -370,8 +377,8 @@ internal fun SettingsStepper( verticalAlignment = Alignment.CenterVertically, ) { IconButton( - enabled = enabled && value > min, - onClick = { onValueChange((value - 1).coerceAtLeast(min)) }, + enabled = enabled && clamped > min, + onClick = { onValueChange((value - 1).coerceIn(min, max)) }, ) { Icon( symbol = MaterialSymbols.Remove, @@ -380,14 +387,14 @@ internal fun SettingsStepper( ) } Text( - text = if (unsetLabel != null && value <= 0) unsetLabel else value.toString(), + text = if (unsetLabel != null && value <= 0) unsetLabel else clamped.toString(), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, modifier = Modifier.widthIn(min = 40.dp), ) IconButton( - enabled = enabled && value < max, - onClick = { onValueChange((value + 1).coerceAtMost(max)) }, + enabled = enabled && clamped < max, + onClick = { onValueChange((value + 1).coerceIn(min, max)) }, ) { Icon( symbol = MaterialSymbols.Add, From a12d5d147d35d526cd7c6c601a121795ce0378ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 23:36:29 +0000 Subject: [PATCH 5/5] chore: regenerate MaterialSymbols font subset for Remove glyph The Stepper introduced in this branch references MaterialSymbols.Remove (U+E15B). Regenerated via tools/material-symbols-subset/subset.sh so the shipped TTF actually contains the glyph. --- .../font/material_symbols_outlined.ttf | Bin 425000 -> 425192 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 679414e7085a236808be26b926b6ff6c4594b627..f57dc675b2af2e76b82f9e0d7f499424104d7263 100644 GIT binary patch delta 1828 zcmW+$4^)%a75}}w_ksM81SR>B5WYmTo*r8fPze7`2oR8nfs~$_IweJcvhYV_EmLP) zEjqW^Ga8GXILDZqjImC0{q^Paf+U;1S6s_F>MZmw?!|$B?-FJWYzI*Sx z@4nkUs~Nwq@qhpb=RXF8)lr<;bo`xhU{DQ^-MKHCi(c=l|08hzT|jnyonuA3xpI>U zc<%(?RpNNT{KCG8`a{6*PA>PkUUStU^d{F`e4fkp(kfT&@Fis<(4_zZqRY7cS9h!4 z11@kMxLxkr=Becit{miqdFAe(mc>oye#nFCfaC4ud_(st?QQOGCxnj5iY>0q#;9j@ z0DnEg?Gr0F5O^a{$rJiDmzPvj)i)G&*)?4MIZybMyQb6?oi$qzw2#ioRj!6w@-woT z%YEG5Q04{M+Qul4nw^ zDOD*SrF@$jl=^(CIkh8oGA%BxB<)bznRG?EHN8Fki;Vb;l8nO{|5zQoI(_x0CZ)+{ zDmS&6hRxCD3iHRALS|g%p3MHtDa(AzVoR*WX4z!fZaHA-wG4VJ-&v-zg0t3V4Q9t= z*JuAB`$kT9&Iv25)z-_on%q6Pm-CkA73cZ$X4e$2*|TOmU&=q4|6kiK+vT;DYcCa8 z3U(BnDfnSs`MT3~W{;?e_u&F(}qbyn&FP& zrs0_WA-{!h=6vYK^w$jvy-k1GuvLFdzfd2nU!i|Se?T9kH%s43Ez*AJRs9>%NqiNAN)P3qC4FlBgt2c^A#mPBq^-l4KI-c{~ zIZ6CX91usuy`n?CUF{Yhs$Un+h>;wh5Y6gks;^X+#P#AXF-`1IeJN&&dO$U-daRmK zjjG!CI=M_WsrsAhj;d63Mzuuw6QwO;EMinyr(7PfKYYI8eE6pD5ru0m5Fr0XKBEYg z_sQSlucX@hd4-EWuEd8KcCX+dR zS57yD1O(J=sj7L+S8>)Q$N=xa;NzDkFokK%;sGAve}oW91QJMsNXXOl!-0~ue$wIP;buLA+maHR%#6gO^hN0ph=hHH(c_zDt zx@C;4=W$~%eBxcs#=ZA0ID>W*=Ui6@=FM>8Cf$r!A-FXFc z7oQJ}Hqc)j0Xj1q`1Uk+@Z|w!0Yz6g9{S)-b6EN9Qu+T&>c@Ee@PRatX$U9)t*I>)S zz+&3KV#mPZJHd=xpTg(2$q$wt0G7WB%-#;>r~`9yY~yvXS3UwOy$H7D&tPSlU=?Lx zuNHy*m~;0Au$pGD+70lqt^Hti@nG9*U=3<8j}xr173{4CU`=m=y{iB_%$;@wfOYXe zr)t6c5?Egn*pLbA61Tb90QT=uu5Nv)3FLCfkF%UNQLMYz=q53}%wsCAnKZM;9gvN6a_Gdw8 znt;&EbuCj6T9-gL7QM$mvd=$4ILUhaBh*!M=yy5HIBzC|Tr-fcsY74qFeyTKNE8#~ begK&cCVU-no6H363$rp#LQj}AK%4M?e02=M delta 1763 zcmX9;3sh9s72S8#u@UFB*+K~RZuV#G0+T2 z8hGBU1}`;8&O&660>@+Z@qKRJ)e8`x%+;u zJWy}HuMU6#h@plaa(L?sYukXM9zb!vz&9<)v3R~6=v+gi zN_>Tm!qvBGKLU$u-dEV}$)#?BtvU=GYwPyy7Qfwaw#&?I3_dX#69mj8k^ek8z>Z^Ka38!58=Y{&V z-(-Q;%`42?&1X^~QsPr=Da|QesbZ=tbxCSd+LW~QX@}CTSuBu=el$2a?j0)nbR`oLS9W??+dTLa3tTHzcjxy|9nAFL3_bHPl9Kq z=j*xVxohYC=rwqQ-kZL3-zwj?j}M6AR{VI^{>$^Dt;1s!!a?E(gA>Xc5ZbrnHVf|B z_3z;O?X3}E#7i-e6OQ6w((dO%{@o=kbhsml1%^$xOjgs7>5Azy<3l3;{OAnELE}Y} z(wJx5Z>l!78OIyP8K)T&jbn`t!*#=E!&<|yjBgue0){l^>$*zaTHQ8%l*k zhcz=aZuO9Qv)ZfbR;8*1K-Hltmbb`5qm!!|{Z1{OI z_v^U4*umIKaW!#MV%Nqzp&W>rA9GUa9|Z!UFGSx{Mn-o>H`BGK3Qh^B6DLQ%N=t^eBHt6tArpFH~6?@T&-vdGWU*d&%r2D?>Xp7ya2ub@}B0}v)O2lAH|H!L9 z;|_*#4-fDVzc4Tb<1CDgVdEZGtYooNwVG*}o*5afge9`4*>h|fo57NqnWZudvoaf1 zk-;*VYqTns$MT5;FVRpj@ZwlPrcXsz^vFP`BG`DWH#pW; z@JDwcvJ!}q6%b>ZA?hbVoNyoF)CCZeKZa