diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt index d6d8925c93..269095c8c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -172,7 +172,11 @@ fun SlimListItem( supportingContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, - colors: ListItemColors = ListItemDefaults.colors(), + // The container default stays `background` — what this layout has always painted — rather than + // ListItemDefaults' `surface`, so existing callers are unchanged. It is a parameter now because + // the row was painting its own opaque background even when the caller asked for another colour: + // inside a container that already has a surface (a dialog) that reads as a black block. + colors: ListItemColors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.background), tonalElevation: Dp = ListItemContainerElevation, shadowElevation: Dp = ListItemContainerElevation, ) { @@ -232,7 +236,7 @@ fun SlimListItem( Surface( modifier = Modifier.semantics(mergeDescendants = true) {}.then(modifier), shape = ListItemDefaults.shape, - color = MaterialTheme.colorScheme.background, + color = colors.containerColor, contentColor = MaterialTheme.colorScheme.onBackground, tonalElevation = tonalElevation, shadowElevation = shadowElevation, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index dc8a448dce..ccd20a5bf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -32,6 +32,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -65,6 +67,9 @@ import com.vitorpamplona.amethyst.ui.theme.nip05 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +/** The dropdown's breathing room under the composer it floats over. */ +private val SuggestionListPadding = PaddingValues(top = 10.dp) + @Composable fun ShowUserSuggestionList( userSuggestions: UserSuggestionState, @@ -73,6 +78,13 @@ fun ShowUserSuggestionList( modifier: Modifier = Modifier, onEmpty: @Composable () -> Unit = {}, trailingContent: (@Composable (User) -> Unit)? = null, + // Defaults suit this list's usual home: a dropdown floating over a composer, where an opaque + // row and a divider per entry are what separate it from the text underneath. Inside a container + // that already provides its own surface — a dialog — that chrome reads as a black box bolted on, + // so those callers pass a transparent row and drop the dividers. + itemColors: ListItemColors = ListItemDefaults.colors(), + showDividers: Boolean = true, + contentPadding: PaddingValues = SuggestionListPadding, ) { UserSearchDataSourceSubscription(userSuggestions, accountViewModel) @@ -93,7 +105,7 @@ fun ShowUserSuggestionList( } } - WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty, trailingContent) + WatchResponses(userSuggestions, listState, onSelect, accountViewModel, modifier, onEmpty, trailingContent, itemColors, showDividers, contentPadding) } @Composable @@ -117,6 +129,9 @@ fun WatchResponses( modifier: Modifier = Modifier, onEmpty: @Composable () -> Unit = {}, trailingContent: (@Composable (User) -> Unit)? = null, + itemColors: ListItemColors = ListItemDefaults.colors(), + showDividers: Boolean = true, + contentPadding: PaddingValues = SuggestionListPadding, ) { val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) @@ -125,7 +140,7 @@ fun WatchResponses( val priority = remember(suggestions) { userSuggestions.priorityPubkeys() } LazyColumn( - contentPadding = PaddingValues(top = 10.dp), + contentPadding = contentPadding, modifier = modifier, state = listState, ) { @@ -137,10 +152,12 @@ fun WatchResponses( } else { null } - UserLine(item, accountViewModel, trailing) { onSelect(item) } - HorizontalDivider( - thickness = DividerThickness, - ) + UserLine(item, accountViewModel, trailing, itemColors) { onSelect(item) } + if (showDividers) { + HorizontalDivider( + thickness = DividerThickness, + ) + } } } } else { @@ -169,9 +186,11 @@ fun UserLine( baseUser: User, accountViewModel: AccountViewModel, trailingContent: (@Composable (User) -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), onClick: () -> Unit, ) { SlimListItem( + colors = colors, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), leadingContent = { ClickableUserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, onClick = null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt index c07df3d3d6..bcaf03c8d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzAddPeopleDialog.kt @@ -20,80 +20,71 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues 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.material3.AlertDialog +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect 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.style.TextOverflow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst 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.model.LocalCache -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.UserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import androidx.compose.runtime.LaunchedEffect as ComposeLaunchedEffect /** - * A reusable "add a person" dialog: a typeahead over the local user cache (name / NIP-05 / npub - * prefix, or a pasted npub/hex). Tapping a result that isn't already in the target invokes [onAdd]; - * members already present are shown with an "Added" hint and aren't tappable. + * A reusable "add a person" dialog for Buzz: the app's ordinary user search — the same + * [UserSuggestionState] engine the @-mention typeahead uses — over the local cache, the relays + * (NIP-50) and NIP-05 identifiers, plus a pasted npub/nprofile. + * + * It used to search only [com.vitorpamplona.amethyst.model.LocalCache], so anyone the device had + * never seen simply had no result and the only way through was to paste a raw hex key — which is + * what the field's own hint told you to do. Searching the relays is what makes finding a person by + * name work at all here. * * Context-agnostic — the caller supplies [isAlreadyIn] (membership predicate) and [onAdd] (the * actual add, e.g. a channel kind-9000 put-user or a community kind-9030 admin-add). Used by both - * the channel members screen and the Buzz community view. + * the channel members screen and the Buzz community view. Members already present render an "Added" + * hint instead of the add affordance and do nothing when tapped. */ @Composable fun BuzzAddPeopleDialog( title: String, accountViewModel: AccountViewModel, - nav: INav, isAlreadyIn: (HexKey) -> Boolean, onAdd: (HexKey) -> Unit, onDismiss: () -> Unit, ) { var query by remember { mutableStateOf("") } - var results by remember { mutableStateOf>(emptyList()) } - - LaunchedEffect(query) { - if (query.isBlank()) { - results = emptyList() - return@LaunchedEffect + val userSuggestions = + remember(accountViewModel) { + UserSuggestionState(accountViewModel.account, Amethyst.instance.nip05Client) } - delay(150) - results = - withContext(Dispatchers.IO) { - LocalCache - .findUsersStartingWith(query.trim(), accountViewModel.account) - .map { it.pubkeyHex } - .take(15) - } - } + val focusRequester = remember { FocusRequester() } + + ComposeLaunchedEffect(query) { userSuggestions.processCurrentWord(query) } + ComposeLaunchedEffect(Unit) { focusRequester.requestFocus() } AlertDialog( onDismissRequest = onDismiss, @@ -103,21 +94,61 @@ fun BuzzAddPeopleDialog( OutlinedTextField( value = query, onValueChange = { query = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), singleLine = true, - leadingIcon = { Icon(symbol = MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp)) }, - label = { Text(stringRes(R.string.buzz_dm_add_hint)) }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + }, + label = { Text(stringRes(R.string.buzz_add_people_hint)) }, ) - LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { - items(results, key = { it }) { hex -> - val alreadyIn = isAlreadyIn(hex) - AddPersonRow(hex, alreadyIn, accountViewModel, nav) { - if (!alreadyIn) { - onAdd(hex) + + // The typeahead needs a couple of characters before a relay search is worth firing; + // below that the list would flash every match in the cache. + if (query.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (!isAlreadyIn(user.pubkeyHex)) { + onAdd(user.pubkeyHex) onDismiss() } - } - } + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightChat, + // The dialog already supplies the surface and the spacing: drop the + // dropdown's opaque rows, per-row dividers and top gap, which are there for + // floating over a composer. + itemColors = ListItemDefaults.colors(containerColor = Color.Transparent), + showDividers = false, + contentPadding = PaddingValues(0.dp), + onEmpty = { + Text( + text = stringRes(R.string.buzz_add_people_empty), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = { user -> + if (isAlreadyIn(user.pubkeyHex)) { + Text( + text = stringRes(R.string.buzz_import_added), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + symbol = MaterialSymbols.PersonAdd, + contentDescription = stringRes(R.string.relay_group_add_member), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) } } }, @@ -125,39 +156,3 @@ fun BuzzAddPeopleDialog( dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } }, ) } - -@Composable -private fun AddPersonRow( - hex: HexKey, - alreadyIn: Boolean, - accountViewModel: AccountViewModel, - nav: INav, - onClick: () -> Unit, -) { - val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable(enabled = !alreadyIn, onClick = onClick) - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - UserPicture(hex, Size35dp, accountViewModel = accountViewModel, nav = nav) - Column(Modifier.weight(1f)) { - if (user != null) { - UsernameDisplay(user, accountViewModel = accountViewModel) - } else { - Text(hex.take(8), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - if (alreadyIn) { - Text( - text = stringRes(R.string.buzz_import_added), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index a7a8671434..fed4acca93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -488,7 +488,6 @@ fun RelayGroupChannelListScreen( BuzzAddPeopleDialog( title = stringRes(R.string.buzz_community_add_people), accountViewModel = accountViewModel, - nav = nav, isAlreadyIn = { BuzzCommunityMembership.isMember(relay, it) }, onAdd = { accountViewModel.addCommunityMember(relay, it) }, onDismiss = { showAddPeople = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt index 175b2e71e8..e73909607d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMembersScreen.kt @@ -193,7 +193,7 @@ private fun RelayGroupMembers( // Only a moderator can add a member (the relay rejects a kind-9000 from anyone else). floatingActionButton = { if (iCanModerate) { - FloatingActionButton(onClick = { showAddMember = true }) { + FloatingActionButton(onClick = { showAddMember = true }, shape = CircleShape) { Icon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(R.string.relay_group_add_member)) } } @@ -229,7 +229,6 @@ private fun RelayGroupMembers( BuzzAddPeopleDialog( title = stringRes(R.string.relay_group_add_member), accountViewModel = accountViewModel, - nav = nav, isAlreadyIn = { channel.membershipOf(it) != RelayGroupMembership.NONE }, onAdd = { accountViewModel.putRelayGroupUser(channel, it, emptyList()) }, onDismiss = { showAddMember = false }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2e10ffed3a..d0f92a98bf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3539,6 +3539,8 @@ Workspace To Add someone (npub or hex) + Search by name, NIP-05 or npub + No one found. Try a different name, a NIP-05 address, or paste an npub. Start conversation Opening… Remove