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.scheduledposts.ScheduledPostsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
|
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.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.BottomBarSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
|
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.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.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.NIP47SetupScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen
|
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.ReactionsSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen
|
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.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.UpdateZapAmountScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
|
||||||
@@ -299,6 +303,10 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.AllSettings> { AllSettingsScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
composableFromEnd<Route.AccountBackup> { AccountBackupScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(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.PrivacyOptions> { PrivacyOptionsScreen(nav) }
|
||||||
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(nav) }
|
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(nav) }
|
||||||
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(nav) }
|
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(nav) }
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object SecurityFilters : 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 PrivacyOptions : Route()
|
||||||
|
|
||||||
@Serializable object NamecoinSettings : 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_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="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="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>
|
<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 RadioButtonUnchecked = MaterialSymbol("\uE836")
|
||||||
val Recommend = MaterialSymbol("\uE9D2")
|
val Recommend = MaterialSymbol("\uE9D2")
|
||||||
val Refresh = MaterialSymbol("\uE5D5")
|
val Refresh = MaterialSymbol("\uE5D5")
|
||||||
|
val Remove = MaterialSymbol("\uE15B")
|
||||||
val RemoveCircleOutline = MaterialSymbol("\uF08F")
|
val RemoveCircleOutline = MaterialSymbol("\uF08F")
|
||||||
val RemoveDone = MaterialSymbol("\uE9D3")
|
val RemoveDone = MaterialSymbol("\uE9D3")
|
||||||
val Replay = MaterialSymbol("\uE042")
|
val Replay = MaterialSymbol("\uE042")
|
||||||
|
|||||||
Reference in New Issue
Block a user