refactor(buzz): give "add people" the app's ordinary user search

Adding someone to a workspace or channel offered a square button and a dialog
whose own hint told you to paste a hex key. Both are now what the rest of the
app does.

The dialog searched **only** LocalCache, so anyone this device had never seen
simply had no result — pasting a raw key was the only way through, which is why
the field said "Add someone (npub or hex)". It now runs on UserSuggestionState,
the same engine behind the @-mention typeahead: local cache, relay search
(NIP-50) and NIP-05 resolution, plus a pasted npub/nprofile. The hint asks for a
name; results show avatar, display name and a verified NIP-05 address.

The members-screen button was the app's only FloatingActionButton without
`shape = CircleShape`, so it rendered as Material3's rounded square next to
circular FABs everywhere else.

Two shared components needed to bend for this, both additive:

- SlimListItem took a `colors` parameter and then painted its container with a
  hardcoded `MaterialTheme.colorScheme.background` regardless — so a row asked
  to be transparent still drew an opaque block. It honours `containerColor` now,
  defaulting to the same `background` it always painted, so every existing
  caller is byte-identical.
- ShowUserSuggestionList's row colours, dividers and top padding are parameters.
  Their defaults are the dropdown's existing look — opaque rows and a divider
  each, which is what separates it from a composer it floats over. Inside a
  dialog that chrome reads as a black box with a gap above it, so this one caller
  passes transparent rows, no dividers and no padding.

Verified on emulator-5554 against nosfabrica.communities.buzz.xyz: searching
"cloudfodder" returns relay results with verified NIP-05s; pasting an npub
resolves to the person; adding them to a channel published the kind-9000, the
relay narrated "Vitor was added by Vitor Pamplona", the member count went 1 → 2,
and that member then posted from Buzz and the message rendered here. The mention
dropdown is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-27 10:30:51 -04:00
co-authored by Claude Opus 5
parent fa6b272213
commit 98bda49abb
6 changed files with 112 additions and 94 deletions
@@ -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,
@@ -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)
@@ -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<List<HexKey>>(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,
)
}
}
}
@@ -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 },
@@ -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 },
+2
View File
@@ -3539,6 +3539,8 @@
<string name="buzz_dm_workspace">Workspace</string>
<string name="buzz_dm_recipients">To</string>
<string name="buzz_dm_add_hint">Add someone (npub or hex)</string>
<string name="buzz_add_people_hint">Search by name, NIP-05 or npub</string>
<string name="buzz_add_people_empty">No one found. Try a different name, a NIP-05 address, or paste an npub.</string>
<string name="buzz_dm_start">Start conversation</string>
<string name="buzz_dm_opening">Opening…</string>
<string name="buzz_dm_remove">Remove</string>