feat: replace hex input with user search in relay manager allow/ban dialogs

Users can now search by name, npub, or NIP-05 identifier when adding
users to relay allow/ban lists, using the same UserSuggestionState
mechanism as the My List screens. The hex pubkey input was not
user-friendly since most users don't know their hex representation.

https://claude.ai/code/session_01SWReoziLKJKxrbwDWprQYs
This commit is contained in:
Claude
2026-03-30 03:43:51 +00:00
parent 35d677f531
commit 4d8583eed9
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -29,10 +31,13 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Block
@@ -67,28 +72,39 @@ import androidx.compose.runtime.remember
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.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -96,6 +112,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun RelayManagementScreen(
@@ -398,26 +416,26 @@ private fun PubkeysTab(
}
if (showBanDialog) {
HexInputDialog(
UserSearchDialog(
title = stringResource(R.string.relay_management_ban_pubkey),
label = stringResource(R.string.relay_management_pubkey_hex),
onConfirm = { hex, reason ->
viewModel.banPubkey(hex, reason.ifBlank { null })
onConfirm = { user, reason ->
viewModel.banPubkey(user.pubkeyHex, reason.ifBlank { null })
showBanDialog = false
},
onDismiss = { showBanDialog = false },
accountViewModel = accountViewModel,
)
}
if (showAllowDialog) {
HexInputDialog(
UserSearchDialog(
title = stringResource(R.string.relay_management_allow_pubkey),
label = stringResource(R.string.relay_management_pubkey_hex),
onConfirm = { hex, reason ->
viewModel.allowPubkey(hex, reason.ifBlank { null })
onConfirm = { user, reason ->
viewModel.allowPubkey(user.pubkeyHex, reason.ifBlank { null })
showAllowDialog = false
},
onDismiss = { showAllowDialog = false },
accountViewModel = accountViewModel,
)
}
}
@@ -973,6 +991,187 @@ private fun SettingsField(
}
}
@Composable
private fun UserSearchDialog(
title: String,
onConfirm: (User, String) -> Unit,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
var selectedUser by remember { mutableStateOf<User?>(null) }
var reasonValue by remember { mutableStateOf("") }
UserSearchDataSourceSubscription(userSuggestions, accountViewModel)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
userSuggestions.invalidateData()
}
}
launch(Dispatchers.IO) {
LocalCache.live.deletedEventBundles.collect {
userSuggestions.invalidateData()
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
val selected = selectedUser
if (selected != null) {
SelectedUserRow(selected, accountViewModel) {
selectedUser = null
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = reasonValue,
onValueChange = { reasonValue = it },
label = { Text(stringResource(R.string.relay_management_reason_optional)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
} else {
var userName by remember { mutableStateOf(TextFieldValue("")) }
OutlinedTextField(
label = { Text(text = stringRes(R.string.search_and_add_a_user)) },
modifier = Modifier.fillMaxWidth(),
value = userName,
onValueChange = {
userName = it
userSuggestions.processCurrentWord(it.text)
},
singleLine = true,
trailingIcon = {
if (userName.text.isNotEmpty()) {
IconButton(
onClick = {
userName = TextFieldValue("")
userSuggestions.processCurrentWord("")
},
) {
ClearTextIcon()
}
}
},
)
ShowUserSuggestions(
userSuggestions = userSuggestions,
onSelect = { user ->
selectedUser = user
userName = TextFieldValue("")
userSuggestions.processCurrentWord("")
},
accountViewModel = accountViewModel,
)
}
}
},
confirmButton = {
TextButton(
onClick = { selectedUser?.let { onConfirm(it, reasonValue.trim()) } },
enabled = selectedUser != null,
) {
Text(stringResource(R.string.relay_management_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.relay_management_cancel))
}
},
)
}
@Composable
private fun SelectedUserRow(
user: User,
accountViewModel: AccountViewModel,
onClear: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(SmallBorder)
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(user, 40.dp, accountViewModel = accountViewModel, onClick = null)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
UsernameDisplay(user, accountViewModel = accountViewModel)
}
IconButton(onClick = onClear) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.relay_management_remove),
modifier = Modifier.size(20.dp),
)
}
}
}
@Composable
private fun ShowUserSuggestions(
userSuggestions: UserSuggestionState,
onSelect: (User) -> Unit,
accountViewModel: AccountViewModel,
) {
val listState = rememberLazyListState()
AnimateOnNewSearch(userSuggestions, listState)
val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList())
if (suggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(top = 10.dp),
modifier = Modifier.heightIn(0.dp, 200.dp),
state = listState,
) {
itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = { onSelect(baseUser) })
.padding(vertical = 8.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableUserPicture(baseUser, 40.dp, accountViewModel, Modifier, null)
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
UsernameDisplay(baseUser, accountViewModel = accountViewModel)
AboutDisplay(baseUser, accountViewModel)
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
@Composable
private fun HexInputDialog(
title: String,