From 2816ceb479fc5c3b84795d648fe0ddc9a188ca74 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 20:48:53 +0000 Subject: [PATCH] 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")