diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt index 5c7827b58..5c6346fdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -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(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,