mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
co-authored by
Claude Opus 5
parent
fa6b272213
commit
98bda49abb
+6
-2
@@ -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,
|
||||
|
||||
+25
-6
@@ -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)
|
||||
|
||||
+78
-83
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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 },
|
||||
|
||||
+1
-2
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user