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/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 new file mode 100644 index 000000000..b9f2d672c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BlockedUsersScreen.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.HiddenAccountsFeedViewModel + +@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, + selectedCount = selected.size, + 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, + ) + } +} 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..2500e8078 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt @@ -0,0 +1,258 @@ +/* + * 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.Box +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 +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( + modifier = Modifier.imePadding(), + topBar = { + BlockListTopBar( + title = R.string.hidden_words, + selectedCount = selected.size, + 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 }, + ) + } +} + +@Composable +private fun HiddenWordsList( + modifier: Modifier = Modifier, + viewModel: HiddenWordsFeedViewModel, + selected: Set, + onToggle: (String) -> Unit, +) { + val feedState by viewModel.feedContent.collectAsStateWithLifecycle() + + 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(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..677be4dd3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MutedThreadsScreen.kt @@ -0,0 +1,186 @@ +/* + * 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.Box +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.items +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.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() + + 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(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 = 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 39d772955..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 @@ -20,921 +20,258 @@ */ 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.annotation.StringRes import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Column -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.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.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll 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.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.tooling.preview.Preview 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.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.EmptyNav 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.screen.loggedIn.mockAccountViewModel 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 +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @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) - } - }, - ) - } -} - -@Composable -fun WatchAccountAndBlockList( - 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(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, - ) - } else { - accountViewModel.hide(currentWordToAdd.value) - currentWordToAdd.value = "" - } -} - -@Composable -private fun SelectableHiddenUsersFeed( - viewModel: UserFeedViewModel, - selected: Set, - onToggle: (String) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - 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) - } - } -} - -@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) - } - } - } - } - } - } -} - -@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, + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Text(text = stringRes(R.string.action_unmute), color = Color.White) + 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 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), + ) { + 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 = R.string.filter_spam_from_strangers_title, + description = R.string.filter_spam_from_strangers_explainer, + checked = filterSpam, + onCheckedChange = accountViewModel::updateFilterSpam, + ) +} + +@Composable +private fun HideCommunityViolationsTile(accountViewModel: AccountViewModel) { + val hideViolations by accountViewModel.account.settings.hideCommunityRulesViolations + .collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Shield, + title = R.string.hide_community_rules_violations_title, + description = 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.Code, + title = R.string.disable_client_tag_title, + description = R.string.disable_client_tag_explainer, + checked = disableClientTag, + onCheckedChange = accountViewModel::updateDisableClientTag, + ) +} + +@Composable +private fun WarnReportsTile(accountViewModel: AccountViewModel) { + val security = accountViewModel.account.settings.syncedSettings.security + val warnReports by security.warnAboutPostsWithReports.collectAsStateWithLifecycle() + val threshold by security.reportWarningThreshold.collectAsStateWithLifecycle() + + SwitchTile( + icon = MaterialSymbols.Report, + 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, + ) + SettingsSubControlRow( + title = stringRes(R.string.report_warning_threshold_title), + description = stringRes(R.string.report_warning_threshold_explainer), + enabled = warnReports, + ) { + SettingsStepper( + value = threshold, + min = 1, + max = 999, + enabled = warnReports, + onValueChange = accountViewModel::updateReportWarningThreshold, + ) + } +} + +@Composable +private fun MaxHashtagsTile(accountViewModel: AccountViewModel) { + val maxHashtags by accountViewModel.account.settings.syncedSettings.security + .maxHashtagLimit + .collectAsStateWithLifecycle() + + SettingsControlRow( + icon = MaterialSymbols.Tag, + title = stringRes(R.string.max_hashtag_limit_title), + description = stringRes(R.string.max_hashtag_limit_explainer), + ) { + SettingsStepper( + value = maxHashtags, + min = 0, + max = 99, + unsetLabel = stringRes(R.string.security_unlimited), + onValueChange = accountViewModel::updateMaxHashtagLimit, + ) + } +} + +@Composable +private fun SwitchTile( + icon: MaterialSymbol, + @StringRes title: Int, + @StringRes description: Int, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + SettingsControlRow( + icon = icon, + title = stringRes(title), + description = stringRes(description), + onClick = { onCheckedChange(!checked) }, + ) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun BlockedContentSection( + accountViewModel: AccountViewModel, + nav: INav, +) { + val hidden by accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + SettingsSection(R.string.security_section_blocked_content) { + SettingsItem( + title = R.string.blocked_users, + icon = MaterialSymbols.PersonOff, + trailing = { SettingsCountBadge(hidden.hiddenUsers.size) }, + onClick = { nav.nav(Route.BlockedUsers) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.spamming_users, + icon = MaterialSymbols.Block, + trailing = { SettingsCountBadge(hidden.spammers.size) }, + onClick = { nav.nav(Route.SpammingUsers) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.hidden_words, + icon = MaterialSymbols.VisibilityOff, + trailing = { SettingsCountBadge(hidden.hiddenWords.size) }, + onClick = { nav.nav(Route.HiddenWords) }, + ) + SettingsDivider() + SettingsItem( + title = R.string.settings_muted_threads_title, + icon = MaterialSymbols.Forum, + trailing = { SettingsCountBadge(hidden.mutedThreads.size) }, + onClick = { nav.nav(Route.MutedThreads) }, + ) + } +} + +@Preview +@Composable +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 new file mode 100644 index 000000000..c2043f54e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityListsCommon.kt @@ -0,0 +1,246 @@ +/* + * 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.annotation.StringRes +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 +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 +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.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.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 + +/** + * 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 blockListState by accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(blockListState) { invalidate() } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BlockListTopBar( + @StringRes title: Int, + selectedCount: Int, + onCancel: () -> Unit, + onUnblock: () -> Unit, + nav: INav, +) { + if (selectedCount == 0) { + TopBarWithBackButton(stringRes(id = title), nav) + } else { + 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 = onUnblock) { + Text(text = stringRes(R.string.unblock)) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun SelectableUserList( + modifier: Modifier = Modifier, + viewModel: UserFeedViewModel, + @StringRes emptyMessage: Int, + selected: Set, + onToggle: (String) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, + enablePullRefresh: Boolean = true, +) { + // 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() + + 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(emptyMessage) + } + + is UserFeedState.Loading -> { + LoadingFeed() + } + + is UserFeedState.FeedError -> { + FeedError(state.errorMessage) { viewModel.invalidateData() } + } + } + } + } +} + +@Composable +internal fun EmptyState( + @StringRes 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/SettingsSectionCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt new file mode 100644 index 000000000..d190d1880 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsSectionCard.kt @@ -0,0 +1,422 @@ +/* + * 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.annotation.StringRes +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.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 +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.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 +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( + @StringRes 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( + @StringRes 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( + @StringRes 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( + @StringRes 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). + * 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, + onClick: (() -> Unit)? = null, + trailing: @Composable () -> Unit, +) { + Row( + 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) { + Icon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + 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, + ) + } + 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). Text dims when [enabled] is false. + */ +@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() + } + } +} + +/** 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 `+`. 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, + min: Int, + max: Int, + enabled: Boolean = true, + unsetLabel: String? = null, + onValueChange: (Int) -> Unit, +) { + val clamped = value.coerceIn(min, max) + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + enabled = enabled && clamped > min, + onClick = { onValueChange((value - 1).coerceIn(min, max)) }, + ) { + Icon( + symbol = MaterialSymbols.Remove, + contentDescription = "−", + modifier = Modifier.size(18.dp), + ) + } + Text( + 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 && clamped < max, + onClick = { onValueChange((value + 1).coerceIn(min, max)) }, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = "+", + modifier = Modifier.size(18.dp), + ) + } + } +} + +@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 new file mode 100644 index 000000000..fc4095298 --- /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, + selectedCount = selected.size, + 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 928332460..fa2bc1d90 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/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 679414e70..f57dc675b 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ 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")