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.
This commit is contained in:
@@ -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<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BlockedUsers> { BlockedUsersScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.SpammingUsers> { SpammingUsersScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.HiddenWords> { HiddenWordsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.MutedThreads> { MutedThreadsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(nav) }
|
||||
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(nav) }
|
||||
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(nav) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
+179
@@ -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<String>()) }
|
||||
|
||||
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<String>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
@@ -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<String>()) }
|
||||
|
||||
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<String>,
|
||||
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<String>,
|
||||
) {
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
+181
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+420
-847
File diff suppressed because it is too large
Load Diff
+148
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+73
@@ -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<String>()) }
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1254,6 +1254,13 @@
|
||||
<string name="hide_community_rules_violations_title">Hide posts that violate community rules</string>
|
||||
<string name="hide_community_rules_violations_explainer">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.</string>
|
||||
|
||||
<string name="security_section_filtering_preferences">Filtering preferences</string>
|
||||
<string name="security_section_blocked_content">Blocked content</string>
|
||||
<string name="security_unlimited">∞</string>
|
||||
<string name="security_blocked_users_empty">You haven\'t blocked any users yet.</string>
|
||||
<string name="security_spamming_users_empty">No accounts have been flagged as spam in this session.</string>
|
||||
<string name="security_hidden_words_empty">No hidden words. Add a word below to hide posts containing it.</string>
|
||||
|
||||
<string name="new_reaction_symbol">New Reaction Symbol</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">No reaction types pre-selected for this user. Long press on the heart button to change</string>
|
||||
|
||||
|
||||
+1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user