feat(search): add scope, source, follows, sort toggles + bech32 auto-resolve (Android)

Android search previously dumped users, notes, hashtags, relays, and 3 channel
types into a single untoggled list and silently relied on NIP-50 relay search.
This adds a second-row control strip so users can pick what they're looking for
and how results are sourced/ranked.

Commons:
- New SearchScope { ALL, PEOPLE, NOTES } and SearchSource { LOCAL, RELAYS }
- SearchSortOrder: add POPULAR; EVENT_OPTIONS now [RELEVANCE, NEWEST, POPULAR]

Android SearchBarViewModel:
- scope / source / followsOnly / sortOrder StateFlows
- searchResultsUsers + searchResultsNotes respect scope, follows, and sort
- POPULAR sorts notes by Note.zapsAmount (descending)
- Follows-only filters authors (includes replies since filter is by author)
- directEventResolver: bech32 nevent/note/naddr -> Route.Note (auto-navigate)
- updateDataSource skips relay emit when source == LOCAL

Android SearchScreen:
- Second row: [All | People | Notes] SegmentedButton + Sort dropdown
- Third row: [Local | Relays] SegmentedButton + Follows-only chip
- bech32 hit triggers nav.nav(route) and clears the search

Desktop parity deferred - Desktop already ships an advanced panel that covers
most of this (kinds/authors/dates/hashtags/language/exclude terms + sort +
bech32 direct lookup). The new enums live in commons so Desktop can adopt.

Build note: gradle 9.3.1 can't be downloaded in this environment, so Android
and Desktop compilation were not verified here.
This commit is contained in:
Claude
2026-04-17 12:26:39 +00:00
committed by davotoula
parent 952d91a1fd
commit 376cbf8d10
7 changed files with 330 additions and 23 deletions
@@ -30,12 +30,16 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
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.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -45,6 +49,9 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.INip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
@@ -79,6 +86,11 @@ class SearchBarViewModel(
val invalidations = MutableStateFlow(0)
val searchValueFlow = MutableStateFlow("")
val scope = MutableStateFlow(SearchScope.ALL)
val source = MutableStateFlow(SearchSource.RELAYS)
val followsOnly = MutableStateFlow(false)
val sortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT)
val searchTerm =
searchValueFlow
.debounce(300)
@@ -88,6 +100,12 @@ class SearchBarViewModel(
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
@Suppress("unused")
val sourceWatcher =
source
.onEach { updateDataSource(searchValue) }
.stateIn(viewModelScope, SharingStarted.Eagerly, SearchSource.RELAYS)
val listState: LazyListState = LazyListState(0, 0)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
@@ -152,21 +170,45 @@ class SearchBarViewModel(
}
}.flowOn(Dispatchers.IO)
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
val directEventResolver: Flow<Route?> =
searchTerm
.debounce(200)
.mapLatest { term ->
if (term.isBlank()) return@mapLatest null
runCatching {
when (val parsed = Nip19Parser.uriToRoute(term)?.entity) {
is NEvent -> Route.Note(parsed.hex)
is NNote -> Route.Note(parsed.hex)
is NAddress -> Route.Note(parsed.aTag())
else -> null
}
}.getOrNull()
}.flowOn(Dispatchers.IO)
val searchResultsUsers =
combine(
searchValueFlow.debounce(100),
invalidations.debounce(100),
directNip05Resolver,
) { term, version, nip05Resolver ->
scope,
combine(followsOnly, account.kind3FollowList.flow) { only, follows ->
if (only) follows.authorsPlusMe else null
},
) { term, _, nip05Resolver, currentScope, follows ->
if (currentScope == SearchScope.NOTES) return@combine emptyList<User>()
if (nip05Resolver != null) {
return@combine listOf(nip05Resolver)
return@combine if (follows == null || nip05Resolver.pubkeyHex in follows) {
listOf(nip05Resolver)
} else {
emptyList()
}
}
if (term.isNotBlank()) {
LocalCache.findUsersStartingWith(term, account)
} else {
emptyList()
}
if (term.isBlank()) return@combine emptyList<User>()
val users = LocalCache.findUsersStartingWith(term, account)
if (follows != null) users.filter { it.pubkeyHex in follows } else users
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -174,10 +216,31 @@ class SearchBarViewModel(
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
LocalCache
.findNotesStartingWith(term, account.hiddenUsers)
.sortedWith(DefaultFeedOrder)
scope,
sortOrder,
combine(followsOnly, account.kind3FollowList.flow) { only, follows ->
if (only) follows.authorsPlusMe else null
},
) { term, _, currentScope, order, follows ->
if (currentScope == SearchScope.PEOPLE) return@combine emptyList()
val raw = LocalCache.findNotesStartingWith(term, account.hiddenUsers)
val filtered = if (follows != null) raw.filter { it.author?.pubkeyHex in follows } else raw
when (order) {
SearchSortOrder.POPULAR ->
filtered.sortedWith(
compareByDescending<com.vitorpamplona.amethyst.model.Note> { it.zapsAmount }
.thenByDescending { it.createdAt() ?: 0L },
)
SearchSortOrder.OLDEST ->
filtered.sortedBy { it.createdAt() ?: 0L }
SearchSortOrder.RELEVANCE, SearchSortOrder.NEWEST -> filtered.sortedWith(DefaultFeedOrder)
else -> filtered.sortedWith(DefaultFeedOrder)
}
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -185,8 +248,9 @@ class SearchBarViewModel(
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
LocalCache.findPublicChatChannelsStartingWith(term)
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findPublicChatChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -194,8 +258,9 @@ class SearchBarViewModel(
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
LocalCache.findEphemeralChatChannelsStartingWith(term)
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findEphemeralChatChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -203,8 +268,9 @@ class SearchBarViewModel(
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
LocalCache.findLiveActivityChannelsStartingWith(term)
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findLiveActivityChannelsStartingWith(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -212,8 +278,9 @@ class SearchBarViewModel(
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
findHashtags(term)
scope,
) { term, _, currentScope ->
if (currentScope == SearchScope.PEOPLE) emptyList() else findHashtags(term)
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
@@ -221,7 +288,9 @@ class SearchBarViewModel(
combine(
searchValueFlow.debounce(100),
invalidations,
) { term, version ->
scope,
) { term, _, currentScope ->
if (currentScope != SearchScope.ALL) return@combine emptyList()
if (term.length > 1) {
val isTypingRelay = term.length > 7 && (term.startsWith("wss://") || term.startsWith("ws://"))
val relayUrl =
@@ -263,7 +332,7 @@ class SearchBarViewModel(
fun clear() = updateSearchValue("")
suspend fun updateDataSource(searchTerm: String) {
if (searchTerm.isBlank()) {
if (searchTerm.isBlank() || source.value == SearchSource.LOCAL) {
searchDataSourceState.searchQuery.tryEmit("")
} else {
searchDataSourceState.searchQuery.tryEmit(searchTerm)
@@ -271,6 +340,22 @@ class SearchBarViewModel(
}
}
fun updateScope(newScope: SearchScope) {
scope.value = newScope
}
fun updateSource(newSource: SearchSource) {
source.value = newSource
}
fun updateFollowsOnly(value: Boolean) {
followsOnly.value = value
}
fun updateSortOrder(order: SearchSortOrder) {
sortOrder.value = order
}
fun isSearchingFun() = searchValue.isNotBlank()
class Factory(
@@ -32,15 +32,26 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
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.focus.focusRequester
@@ -52,6 +63,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
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.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription
@@ -77,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
@Composable
@@ -157,9 +172,145 @@ private fun SearchBar(
}
}
}
// bech32 auto-resolve: navigate on hit without displaying results
launch {
searchBarViewModel.directEventResolver.filterNotNull().collect { route ->
nav.nav(route)
searchBarViewModel.clear()
}
}
}
SearchTextField(searchBarViewModel, Modifier.statusBarsPadding())
Column(modifier = Modifier.statusBarsPadding()) {
SearchTextField(searchBarViewModel, Modifier)
SearchFilterRow(searchBarViewModel)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) {
val currentScope by searchBarViewModel.scope.collectAsStateWithLifecycle()
val currentSource by searchBarViewModel.source.collectAsStateWithLifecycle()
val currentFollowsOnly by searchBarViewModel.followsOnly.collectAsStateWithLifecycle()
val currentSort by searchBarViewModel.sortOrder.collectAsStateWithLifecycle()
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.weight(1f)) {
val scopes = listOf(SearchScope.ALL, SearchScope.PEOPLE, SearchScope.NOTES)
scopes.forEachIndexed { index, s ->
SegmentedButton(
selected = currentScope == s,
onClick = { searchBarViewModel.updateScope(s) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = scopes.size),
) {
Text(
text =
when (s) {
SearchScope.ALL -> stringRes(R.string.search_scope_all)
SearchScope.PEOPLE -> stringRes(R.string.search_scope_people)
SearchScope.NOTES -> stringRes(R.string.search_scope_notes)
},
)
}
}
}
SortMenu(
currentSort = currentSort,
enabled = currentScope != SearchScope.PEOPLE,
onSelect = { searchBarViewModel.updateSortOrder(it) },
)
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SingleChoiceSegmentedButtonRow {
val sources = listOf(SearchSource.LOCAL, SearchSource.RELAYS)
sources.forEachIndexed { index, s ->
SegmentedButton(
selected = currentSource == s,
onClick = { searchBarViewModel.updateSource(s) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = sources.size),
) {
Text(
text =
when (s) {
SearchSource.LOCAL -> stringRes(R.string.search_source_local)
SearchSource.RELAYS -> stringRes(R.string.search_source_relays)
},
)
}
}
}
FilterChip(
selected = currentFollowsOnly,
onClick = { searchBarViewModel.updateFollowsOnly(!currentFollowsOnly) },
label = { Text(stringRes(R.string.search_follows_only)) },
)
}
}
@Composable
private fun SortMenu(
currentSort: SearchSortOrder,
enabled: Boolean,
onSelect: (SearchSortOrder) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val options = SearchSortOrder.EVENT_OPTIONS
TextButton(
onClick = { if (enabled) expanded = true },
enabled = enabled,
) {
Text(
text =
when (currentSort) {
SearchSortOrder.NEWEST -> stringRes(R.string.search_sort_newest)
SearchSortOrder.RELEVANCE -> stringRes(R.string.search_sort_relevance)
SearchSortOrder.POPULAR -> stringRes(R.string.search_sort_popular)
SearchSortOrder.OLDEST -> stringRes(R.string.search_sort_oldest)
else -> stringRes(R.string.search_sort_newest)
},
)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { opt ->
DropdownMenuItem(
text = {
Text(
when (opt) {
SearchSortOrder.NEWEST -> stringRes(R.string.search_sort_newest)
SearchSortOrder.RELEVANCE -> stringRes(R.string.search_sort_relevance)
SearchSortOrder.POPULAR -> stringRes(R.string.search_sort_popular)
SearchSortOrder.OLDEST -> stringRes(R.string.search_sort_oldest)
else -> opt.label
},
)
},
onClick = {
onSelect(opt)
expanded = false
},
)
}
}
}
@Composable
+11
View File
@@ -1870,6 +1870,17 @@
<string name="search_by_hashtag">Search hashtag: #%1$s</string>
<string name="search_scope_all">All</string>
<string name="search_scope_people">People</string>
<string name="search_scope_notes">Notes</string>
<string name="search_source_local">Local</string>
<string name="search_source_relays">Relays</string>
<string name="search_follows_only">Follows only</string>
<string name="search_sort_newest">Newest</string>
<string name="search_sort_oldest">Oldest</string>
<string name="search_sort_relevance">Relevance</string>
<string name="search_sort_popular">Popular</string>
<string name="dont_translate_from">Don\'t Translate From</string>
<string name="dont_translate_from_description">Languages shown here will not be translated. Select a language to remove it and have it translated again.</string>
<string name="translate_to">Translate To</string>
@@ -48,6 +48,12 @@ object SearchResultSorter {
}
}
SearchSortOrder.POPULAR -> {
// Raw Event has no zap-total; callers that hold Note objects should sort by
// zapsAmount directly. Fall back to newest so the option is still harmless here.
events.sortedWith(compareByDescending<Event> { it.createdAt }.thenBy { it.id })
}
else -> {
events
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
enum class SearchScope {
ALL,
PEOPLE,
NOTES,
}
@@ -26,12 +26,13 @@ enum class SearchSortOrder(
NEWEST("Newest"),
OLDEST("Oldest"),
RELEVANCE("Relevance"),
POPULAR("Popular"),
NAME_AZ("A → Z"),
NAME_ZA("Z → A"),
;
companion object {
val EVENT_OPTIONS = listOf(NEWEST, OLDEST, RELEVANCE)
val EVENT_OPTIONS = listOf(RELEVANCE, NEWEST, POPULAR)
val PEOPLE_OPTIONS = listOf(NAME_AZ, NAME_ZA)
val DEFAULT_EVENT = NEWEST
val DEFAULT_PEOPLE = NAME_AZ
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.search
enum class SearchSource {
LOCAL,
RELAYS,
}