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 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Block
@@ -67,28 +72,39 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State 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.User
import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever 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.layouts.listItem.SlimListItem
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindDisplayName 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.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size55dp 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.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable @Composable
fun RelayManagementScreen( fun RelayManagementScreen(
@@ -398,26 +416,26 @@ private fun PubkeysTab(
} }
if (showBanDialog) { if (showBanDialog) {
HexInputDialog( UserSearchDialog(
title = stringResource(R.string.relay_management_ban_pubkey), title = stringResource(R.string.relay_management_ban_pubkey),
label = stringResource(R.string.relay_management_pubkey_hex), onConfirm = { user, reason ->
onConfirm = { hex, reason -> viewModel.banPubkey(user.pubkeyHex, reason.ifBlank { null })
viewModel.banPubkey(hex, reason.ifBlank { null })
showBanDialog = false showBanDialog = false
}, },
onDismiss = { showBanDialog = false }, onDismiss = { showBanDialog = false },
accountViewModel = accountViewModel,
) )
} }
if (showAllowDialog) { if (showAllowDialog) {
HexInputDialog( UserSearchDialog(
title = stringResource(R.string.relay_management_allow_pubkey), title = stringResource(R.string.relay_management_allow_pubkey),
label = stringResource(R.string.relay_management_pubkey_hex), onConfirm = { user, reason ->
onConfirm = { hex, reason -> viewModel.allowPubkey(user.pubkeyHex, reason.ifBlank { null })
viewModel.allowPubkey(hex, reason.ifBlank { null })
showAllowDialog = false showAllowDialog = false
}, },
onDismiss = { 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 @Composable
private fun HexInputDialog( private fun HexInputDialog(
title: String, title: String,