mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
-17
@@ -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) {
|
||||
|
||||
+25
-18
@@ -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(
|
||||
|
||||
+22
-5
@@ -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) })
|
||||
}
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user