refactor(search): drive the search field's people picker from the composer's own

The search field shipped with its own people lookup — a `mentionQuery` flow over
`LocalCache.search.findUsersStartingWith` and a hand-rolled row list. Amethyst
already had a better one: `UserSuggestionState` + `ShowUserSuggestionList`, the
@-mention picker behind the post composer, the channel and Concord composers,
badge awards and the group member screens.

That duplicate was not merely redundant, it was worse. It resolved no NIP-05, so
`from:vitor@nostr.com` found nobody while `@vitor@nostr.com` in a post found the
right person; it never asked the search or indexer relays, only the local cache;
and it ranked nothing, so follows did not come first.

`commons` cannot call that stack — it is built on `Account` and
`AccountViewModel` — so `TokenizedSearchField` takes a `peoplePicker` slot and
keeps only what is genuinely shared: when the picker opens, and what a pick
splices into the text. Android fills the slot with the composer's list. Desktop
keeps its `UserSearchEngine` path through the built-in, keyboard-walkable list,
which the slot deliberately does not replace: a slot owns its own selection
affordance, so the arrow keys stay with the caret while one is up.

Also fixes an inconsistency in the desktop advanced panel: its "Until" box
resolved to the *start* of the named day, so a day typed there excluded almost
all of itself while the same day written as an `until:` token included it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
This commit is contained in:
Claude
2026-09-07 21:43:01 +00:00
parent 47db7e242f
commit 3be8bbea2a
4 changed files with 49 additions and 41 deletions
@@ -74,7 +74,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
@@ -392,22 +391,6 @@ class SearchBarViewModel(
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
/**
* What a half-written `from:`/`to:` token is asking about — the partial after the colon, not
* the whole box. The two are different questions: the query is "notes matching this", the
* picker is "who is this the start of", and feeding the picker the whole query would offer
* people who match `from:ali #bitcoin` rather than people called `ali`.
*/
val mentionQuery = MutableStateFlow("")
val mentionResults =
mentionQuery
.debounce(150)
.map { partial ->
if (partial.isBlank()) emptyList() else LocalCache.search.findUsersStartingWith(partial, account).take(8)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
override val isRefreshing = derivedStateOf { searchValue.isNotBlank() }
override fun invalidateData(ignoreIfDoing: Boolean) {
@@ -26,6 +26,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
@@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.commons.search.SearchScope
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
import com.vitorpamplona.amethyst.commons.search.SearchSource
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.commons.ui.search.PersonCandidate
import com.vitorpamplona.amethyst.commons.ui.search.SearchFieldState
import com.vitorpamplona.amethyst.commons.ui.search.TokenizedSearchField
import com.vitorpamplona.amethyst.model.LocalCache
@@ -116,6 +116,8 @@ import com.vitorpamplona.amethyst.ui.note.ClearTextIcon
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
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.screen.loggedIn.chats.rooms.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow
@@ -125,7 +127,6 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.filterNotNull
@@ -228,7 +229,7 @@ private fun SearchBar(
// window (Waydroid/DeX freeform) is respected too.
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
) {
SearchTextField(searchBarViewModel, Modifier)
SearchTextField(searchBarViewModel, accountViewModel, Modifier)
// Inline Namecoin lookup feedback for the global search field.
// Mirrors the wiring in OnchainZapSendDialog: the local prefix
// search can race ahead of the on-chain resolution and show a
@@ -487,6 +488,7 @@ private fun sortLabel(opt: SearchSortOrder): String =
@Composable
private fun SearchTextField(
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
modifier: Modifier,
) {
Row(
@@ -500,19 +502,15 @@ private fun SearchTextField(
// be copied out and pasted back.
val fieldState = remember { SearchFieldState(searchBarViewModel.searchValue) }
val interactionSource = remember { MutableInteractionSource() }
val mentionHits by searchBarViewModel.mentionResults.collectAsStateWithLifecycle()
val people =
remember(mentionHits) {
mentionHits
.map {
PersonCandidate(
pubkeyHex = it.pubkeyHex,
name = it.toBestDisplayName(),
pictureUrl = it.profilePicture(),
)
}.toImmutableList()
// The composer's own mention picker, reused verbatim: it already resolves NIP-05, asks
// the search and indexer relays through SearchQueryState, and ranks follows first. A
// second, thinner people search living only in the search field would answer `from:` with
// less than `@` answers in a post.
val userSuggestions =
remember(accountViewModel) {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
val chipNames = remember(mentionHits) { mentionHits.associate { it.pubkeyHex to it.toBestDisplayName() } }
LaunchedEffect(fieldState.text) { searchBarViewModel.updateSearchValue(fieldState.text) }
// The one direction that is not the field's own: a clear, or a query loaded from elsewhere.
@@ -527,9 +525,18 @@ private fun SearchTextField(
Modifier
.defaultMinSize(minHeight = 20.dp)
.focusRequester(searchBarViewModel.focusRequester),
people = people,
displayName = { chipNames[it] },
onPeopleQuery = { searchBarViewModel.mentionQuery.tryEmit(it) },
// A key already in the cache draws as its owner's name; one that has not arrived yet
// stays a short npub rather than being given an invented name.
displayName = { LocalCache.getUserIfExists(it)?.toBestDisplayName() },
onPeopleQuery = { userSuggestions.processCurrentWord(it) },
peoplePicker = { _, onPick ->
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { onPick(it.pubkeyHex) },
accountViewModel = accountViewModel,
contentPadding = PaddingValues(0.dp),
)
},
textStyle = LocalTextStyle.current,
decorationBox = { innerTextField ->
TextFieldDefaults.DecorationBox(
@@ -64,9 +64,10 @@ import kotlinx.collections.immutable.persistentListOf
* While a picker is up it owns the arrow keys and Enter: those keys are walking the offered rows,
* not the text. When no picker is up they fall through to the field and to [onSubmit].
*
* [people] and [groups] are supplied by the caller because resolving a name to a key is an
* account-scoped, relay-backed question that `commons` has no business answering; this component
* only says *when* to ask, via [onPeopleQuery] and [onGroupQuery].
* The rows are supplied by the caller — as [people]/[groups], or as a whole [peoplePicker] slot —
* because resolving a name to a key is an account-scoped, relay-backed question that `commons`
* has no business answering. This component only says *when* to ask, via [onPeopleQuery] and
* [onGroupQuery], and what a pick splices into the text.
*/
@Composable
fun TokenizedSearchField(
@@ -75,6 +76,17 @@ fun TokenizedSearchField(
placeholder: String = "Search",
people: ImmutableList<PersonCandidate> = persistentListOf(),
groups: ImmutableList<GroupCandidate> = persistentListOf(),
/**
* The people picker's rows, when the caller has a richer list of its own — Android hands in
* the composer's `ShowUserSuggestionList`, which already resolves NIP-05, asks the search
* relays and ranks follows first, and which `commons` cannot reach because it is built on
* app-module types.
*
* A slot owns its own selection affordance, so the arrow keys stay with the caret while one
* is up; the built-in [people] list is the keyboard-walkable path. Either way this component
* still owns *when* the picker opens and what a pick splices into the text.
*/
peoplePicker: (@Composable (ActivePicker.People, onPick: (String) -> Unit) -> Unit)? = null,
displayName: (String) -> String? = { null },
onPeopleQuery: (String) -> Unit = {},
onGroupQuery: (String) -> Unit = {},
@@ -98,7 +110,8 @@ fun TokenizedSearchField(
val rows =
when (picker) {
is ActivePicker.People -> people.size
// A slot's rows are not this component's to walk.
is ActivePicker.People -> if (peoplePicker != null) 0 else people.size
is ActivePicker.Group -> groups.size
else -> 0
}
@@ -152,7 +165,11 @@ fun TokenizedSearchField(
}
is ActivePicker.People ->
if (people.isNotEmpty()) {
if (peoplePicker != null) {
SearchPickerSurface(Modifier.padding(top = 4.dp).fillMaxWidth()) {
peoplePicker(picker) { state.pickPerson(picker, it) }
}
} else if (people.isNotEmpty()) {
SearchPickerSurface(Modifier.padding(top = 4.dp).fillMaxWidth()) {
SearchPeoplePicker(people, highlighted, onPick = { state.pickPerson(picker, it.pubkeyHex) })
}
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.search.DateUtils
import com.vitorpamplona.amethyst.commons.search.KindRegistry
import com.vitorpamplona.amethyst.commons.search.QueryParser
import com.vitorpamplona.amethyst.commons.search.SearchQuery
import com.vitorpamplona.amethyst.commons.search.calendar.DateField
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -307,7 +308,7 @@ private fun DateRangeFields(
value = untilText,
onValueChange = {
untilText = it
val ts = QueryParser.parseDateToTimestamp(it)
val ts = QueryParser.parseDateToTimestamp(it, DateField.UNTIL)
if (ts != null || it.isBlank()) {
lastUntil = ts
onChanged(since, ts)