Merge pull request #2878 from vitorpamplona/claude/modernize-security-filters-ui-IPUjW
Refactor SecurityFiltersScreen into modular settings screens
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()
|
||||
|
||||
-168
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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.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<String>()) }
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+258
@@ -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<String>()) }
|
||||
|
||||
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<String>,
|
||||
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<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 = ""
|
||||
}
|
||||
}
|
||||
+186
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+222
-885
File diff suppressed because it is too large
Load Diff
+246
@@ -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<String>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+422
@@ -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() },
|
||||
)
|
||||
}
|
||||
+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,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Binary file not shown.
+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