feat: bulk-remove for blocked users and hidden words

Long-press a row in Settings -> Security Filters to enter selection mode,
pick more rows, tap Unblock. The mute list (kind 10000) and block list
are rebuilt and re-broadcast once for all selected entries instead of
once per item.

- Quartz: add `MuteListEvent.removeAll` and `PeopleListEvent.removeAll`
  bulk overloads using the existing `TagArray.removeAny` primitive.
- Account / state classes: add `showUsers(List)` and `showWords(List)`
  that produce a single resigned event per list.
- AccountViewModel: thin wrappers `showUsers` / `showWords`.
- SecurityFiltersScreen: hoist per-tab selection sets, swap top bar
  in selection mode, add a local selectable user list (the shared
  `UserCompose` row was kept untouched).
This commit is contained in:
Claude
2026-05-02 22:38:26 +00:00
committed by greenart7c3
parent 4a789efa63
commit 76b69cd440
7 changed files with 370 additions and 14 deletions
@@ -2768,6 +2768,19 @@ class Account(
hiddenUsers.showUser(pubkeyHex)
}
suspend fun showUsers(pubkeys: List<HexKey>) {
if (pubkeys.isEmpty()) return
sendMyPublicAndPrivateOutbox(blockPeopleList.showUsers(pubkeys))
sendMyPublicAndPrivateOutbox(muteList.showUsers(pubkeys))
pubkeys.forEach { hiddenUsers.showUser(it) }
}
suspend fun showWords(words: List<String>) {
if (words.isEmpty()) return
sendMyPublicAndPrivateOutbox(blockPeopleList.showWords(words))
sendMyPublicAndPrivateOutbox(muteList.showWords(words))
}
suspend fun requestDVMContentDiscovery(
dvmPublicKey: User,
onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
@@ -117,4 +117,24 @@ class BlockPeopleListState(
null
}
}
suspend fun showUsers(pubkeys: List<String>): PeopleListEvent? {
if (pubkeys.isEmpty()) return null
val blockList = getBlockList() ?: return null
return PeopleListEvent.removeAll(
earlierVersion = blockList,
persons = pubkeys.map { UserTag(it) },
signer = signer,
)
}
suspend fun showWords(words: List<String>): PeopleListEvent? {
if (words.isEmpty()) return null
val blockList = getBlockList() ?: return null
return PeopleListEvent.removeAll(
earlierVersion = blockList,
persons = words.map { WordTag(it) },
signer = signer,
)
}
}
@@ -139,6 +139,26 @@ class MuteListState(
}
}
suspend fun showUsers(pubkeys: List<String>): MuteListEvent? {
if (pubkeys.isEmpty()) return null
val muteList = getMuteList() ?: return null
return MuteListEvent.removeAll(
earlierVersion = muteList,
mutes = pubkeys.map { UserTag(it) },
signer = signer,
)
}
suspend fun showWords(words: List<String>): MuteListEvent? {
if (words.isEmpty()) return null
val muteList = getMuteList() ?: return null
return MuteListEvent.removeAll(
earlierVersion = muteList,
mutes = words.map { WordTag(it) },
signer = signer,
)
}
init {
settings.backupMuteList?.let { event ->
Log.d("AccountRegisterObservers") { "Loading saved mute list ${event.toJson()}" }
@@ -1173,6 +1173,10 @@ class AccountViewModel(
fun showUser(pubkeyHex: String) = launchSigner { account.showUser(pubkeyHex) }
fun showUsers(pubkeys: List<HexKey>) = launchSigner { account.showUsers(pubkeys) }
fun showWords(words: List<String>) = launchSigner { account.showWords(words) }
fun createStatus(newStatus: String) = launchSigner { account.createStatus(newStatus) }
fun updateStatus(
@@ -20,20 +20,29 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -42,6 +51,7 @@ import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -60,6 +70,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -70,13 +81,26 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.WarningType
import com.vitorpamplona.amethyst.model.parseWarningType
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenWord
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.ShowUserButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton
import com.vitorpamplona.amethyst.ui.note.elements.AddButton
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedState
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.dal.HiddenWordsFeedViewModel
@@ -85,9 +109,12 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -129,7 +156,7 @@ fun SecurityFiltersScreen(
)
}
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SecurityFiltersScreen(
hiddenFeedViewModel: HiddenAccountsFeedViewModel,
@@ -155,9 +182,48 @@ fun SecurityFiltersScreen(
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
val pagerState = rememberPagerState { 3 }
val coroutineScope = rememberCoroutineScope()
var selectedUsers by remember { mutableStateOf(setOf<String>()) }
var selectedWords by remember { mutableStateOf(setOf<String>()) }
LaunchedEffect(pagerState.currentPage) {
selectedUsers = emptySet()
selectedWords = emptySet()
}
val selectionMode = selectedUsers.isNotEmpty() || selectedWords.isNotEmpty()
Scaffold(
topBar = {
TopBarWithBackButton(stringRes(id = R.string.security_filters), nav)
if (selectionMode) {
SelectionTopBar(
selectedCount = if (pagerState.currentPage == 2) selectedWords.size else selectedUsers.size,
onCancel = {
selectedUsers = emptySet()
selectedWords = emptySet()
},
onRemove = {
if (pagerState.currentPage == 2) {
if (!accountViewModel.isWriteable()) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
R.string.login_with_a_private_key_to_be_able_to_show_word,
)
} else {
accountViewModel.showWords(selectedWords.toList())
selectedWords = emptySet()
}
} else {
accountViewModel.showUsers(selectedUsers.toList())
selectedUsers = emptySet()
}
},
)
} else {
TopBarWithBackButton(stringRes(id = R.string.security_filters), nav)
}
},
) {
Column(
@@ -165,9 +231,6 @@ fun SecurityFiltersScreen(
.padding(it)
.fillMaxHeight(),
) {
val pagerState = rememberPagerState { 3 }
val coroutineScope = rememberCoroutineScope()
HeaderOptions(accountViewModel)
HorizontalDivider(thickness = DividerThickness)
@@ -198,15 +261,78 @@ fun SecurityFiltersScreen(
}
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> RefreshingFeedUserFeedView(hiddenFeedViewModel, accountViewModel, nav)
1 -> RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav)
2 -> HiddenWordsFeed(hiddenWordsViewModel, accountViewModel)
0 -> {
SelectableHiddenUsersFeed(
viewModel = hiddenFeedViewModel,
selected = selectedUsers,
onToggle = { hex ->
selectedUsers =
if (hex in selectedUsers) selectedUsers - hex else selectedUsers + hex
},
accountViewModel = accountViewModel,
nav = nav,
)
}
1 -> {
RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav)
}
2 -> {
HiddenWordsFeed(
hiddenWordsViewModel = hiddenWordsViewModel,
accountViewModel = accountViewModel,
selected = selectedWords,
onToggle = { word ->
selectedWords =
if (word in selectedWords) selectedWords - word else selectedWords + word
},
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectionTopBar(
selectedCount: Int,
onCancel: () -> Unit,
onRemove: () -> Unit,
) {
ShorterTopAppBar(
title = {
Text(
text = stringRes(R.string.num_selected, selectedCount),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
},
navigationIcon = {
CloseButton(
modifier = HalfHorzPadding,
onPress = onCancel,
)
},
actions = {
Button(
modifier = HalfHorzPadding,
onClick = onRemove,
) {
Text(text = stringRes(R.string.unblock))
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}
@Composable
private fun HeaderOptions(accountViewModel: AccountViewModel) {
Column(
@@ -302,14 +428,24 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) {
private fun HiddenWordsFeed(
hiddenWordsViewModel: HiddenWordsFeedViewModel,
accountViewModel: AccountViewModel,
selected: Set<String>,
onToggle: (String) -> Unit,
) {
val selectionMode = selected.isNotEmpty()
RefresheableBox(hiddenWordsViewModel, false) {
StringFeedView(
hiddenWordsViewModel,
accountViewModel,
post = { AddMuteWordTextField(accountViewModel) },
) {
MutedWordHeader(tag = it, account = accountViewModel)
) { word ->
MutedWordHeader(
tag = word,
account = accountViewModel,
isSelected = word in selected,
selectionMode = selectionMode,
onToggle = { onToggle(word) },
onLongClick = { onToggle(word) },
)
}
}
}
@@ -372,15 +508,32 @@ fun WatchAccountAndBlockList(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MutedWordHeader(
tag: String,
modifier: Modifier = StdPadding,
account: AccountViewModel,
isSelected: Boolean = false,
selectionMode: Boolean = false,
onToggle: () -> Unit = {},
onLongClick: () -> Unit = {},
) {
Column(
Modifier.fillMaxWidth(),
) {
val rowModifier =
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { if (selectionMode) onToggle() },
onLongClick = onLongClick,
).let {
if (isSelected) {
it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
} else {
it
}
}
Column(rowModifier) {
Column(modifier = modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -392,7 +545,11 @@ fun MutedWordHeader(
modifier = Modifier.weight(1f),
)
MutedWordActionOptions(tag, account)
if (selectionMode) {
Checkbox(checked = isSelected, onCheckedChange = { onToggle() })
} else {
MutedWordActionOptions(tag, account)
}
}
}
}
@@ -479,3 +636,109 @@ private fun hideIfWritable(
currentWordToAdd.value = ""
}
}
@Composable
private fun SelectableHiddenUsersFeed(
viewModel: UserFeedViewModel,
selected: Set<String>,
onToggle: (String) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val selectionMode = selected.isNotEmpty()
RefresheableBox(viewModel, true) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
CrossfadeIfEnabled(
targetState = feedState,
animationSpec = tween(durationMillis = 100),
accountViewModel = accountViewModel,
) { state ->
when (state) {
is UserFeedState.Empty -> {
FeedEmpty { viewModel.invalidateData() }
}
is UserFeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
}
is UserFeedState.Loading -> {
LoadingFeed()
}
is UserFeedState.Loaded -> {
SelectableHiddenUsersList(
state = state,
selected = selected,
selectionMode = selectionMode,
onToggle = onToggle,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SelectableHiddenUsersList(
state: UserFeedState.Loaded,
selected: Set<String>,
selectionMode: Boolean,
onToggle: (String) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by state.feed.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, user ->
val isSelected = user.pubkeyHex in selected
val rowModifier =
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
if (selectionMode) {
onToggle(user.pubkeyHex)
} else {
nav.nav(routeFor(user))
}
},
onLongClick = { onToggle(user.pubkeyHex) },
).let {
if (isSelected) {
it.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
} else {
it
}
}
Row(
modifier = rowModifier.padding(horizontal = Size15dp, vertical = Size10dp),
verticalAlignment = Alignment.CenterVertically,
) {
UserPicture(user, Size55dp, accountViewModel = accountViewModel, nav = nav)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
UsernameDisplay(user, accountViewModel = accountViewModel)
}
if (selectionMode) {
Checkbox(checked = isSelected, onCheckedChange = { onToggle(user.pubkeyHex) })
} else {
ShowUserButton { accountViewModel.show(user) }
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
}
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.removeAny
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -132,6 +133,23 @@ class MuteListEvent(
)
}
suspend fun removeAll(
earlierVersion: MuteListEvent,
mutes: List<MuteTag>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): MuteListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val ids = mutes.map { it.toTagIdOnly() }
return resign(
privateTags = privateTags.removeAny(ids),
publicTags = earlierVersion.tags.removeAny(ids),
signer = signer,
createdAt = createdAt,
)
}
suspend fun resign(
publicTags: TagArray,
privateTags: TagArray,
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip51Lists.removeAny
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
@@ -195,6 +196,23 @@ class PeopleListEvent(
)
}
suspend fun removeAll(
earlierVersion: PeopleListEvent,
persons: List<MuteTag>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): PeopleListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val ids = persons.map { it.toTagIdOnly() }
return resign(
privateTags = privateTags.removeAny(ids),
publicTags = earlierVersion.tags.removeAny(ids),
signer = signer,
createdAt = createdAt,
)
}
suspend fun remove(
earlierVersion: PeopleListEvent,
person: UserTag,