From 376cbf8d10b2d734e966dc03d82215effbd5908a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 12:26:39 +0000 Subject: [PATCH 1/3] 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. --- .../loggedIn/search/SearchBarViewModel.kt | 127 ++++++++++++--- .../ui/screen/loggedIn/search/SearchScreen.kt | 153 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 11 ++ .../commons/search/SearchResultSorter.kt | 6 + .../amethyst/commons/search/SearchScope.kt | 27 ++++ .../commons/search/SearchSortOrder.kt | 3 +- .../amethyst/commons/search/SearchSource.kt | 26 +++ 7 files changed, 330 insertions(+), 23 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchScope.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSource.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 3aaf8dcb5..991a84183 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -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 = + 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() + 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() + 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 { 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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 09c7eaa50..decabb57f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 182e677bb..3db5b9529 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1870,6 +1870,17 @@ Search hashtag: #%1$s + All + People + Notes + Local + Relays + Follows only + Newest + Oldest + Relevance + Popular + Don\'t Translate From Languages shown here will not be translated. Select a language to remove it and have it translated again. Translate To diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt index 04e92ebec..188f6de56 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -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 { it.createdAt }.thenBy { it.id }) + } + else -> { events } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchScope.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchScope.kt new file mode 100644 index 000000000..68b0b89b2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchScope.kt @@ -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, +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt index 1b4350787..490dc0e52 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt @@ -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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSource.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSource.kt new file mode 100644 index 000000000..404e37dc0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSource.kt @@ -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, +} From 3b0bf07e5878b045dacf1d65b2183072f4150cf9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 23 Apr 2026 13:33:51 +0200 Subject: [PATCH 2/3] add missing Column import and spotless formatting --- .../screen/loggedIn/search/SearchBarViewModel.kt | 14 ++++++++++---- .../ui/screen/loggedIn/search/SearchScreen.kt | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 991a84183..bd7e2f5f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -228,18 +228,24 @@ class SearchBarViewModel( val filtered = if (follows != null) raw.filter { it.author?.pubkeyHex in follows } else raw when (order) { - SearchSortOrder.POPULAR -> + SearchSortOrder.POPULAR -> { filtered.sortedWith( compareByDescending { it.zapsAmount } .thenByDescending { it.createdAt() ?: 0L }, ) + } - SearchSortOrder.OLDEST -> + SearchSortOrder.OLDEST -> { filtered.sortedBy { it.createdAt() ?: 0L } + } - SearchSortOrder.RELEVANCE, SearchSortOrder.NEWEST -> filtered.sortedWith(DefaultFeedOrder) + SearchSortOrder.RELEVANCE, SearchSortOrder.NEWEST -> { + filtered.sortedWith(DefaultFeedOrder) + } - else -> filtered.sortedWith(DefaultFeedOrder) + else -> { + filtered.sortedWith(DefaultFeedOrder) + } } }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index decabb57f..e5479e836 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search 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.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight From 458d9bcbab417c62aa005efcdc98edfb8f6f08cf Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 23 Apr 2026 14:17:42 +0200 Subject: [PATCH 3/3] compact filter row with bottom sheet for source/follows/sort Replaces the two-row, mixed-control filter UI under the search bar with a single segmented row for scope (All/People/Notes) and a Tune icon button that opens a ModalBottomSheet containing Source, Follows-only, and Sort controls. A primary-color dot on the icon indicates non-default filter state. Also renames SearchSortOrder.DEFAULT_EVENT/DEFAULT_PEOPLE to EVENT_DEFAULT/PEOPLE_DEFAULT for consistency with EVENT_OPTIONS/ PEOPLE_OPTIONS, and routes the sheet's reset button through these canonical constants so the UI default cannot drift from the ViewModel. --- .../loggedIn/search/SearchBarViewModel.kt | 2 +- .../ui/screen/loggedIn/search/SearchScreen.kt | 274 +++++++++++++----- amethyst/src/main/res/values/strings.xml | 5 + .../commons/search/AdvancedSearchBarState.kt | 8 +- .../commons/search/SearchSortOrder.kt | 4 +- 5 files changed, 208 insertions(+), 85 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index bd7e2f5f5..a021dfd78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -89,7 +89,7 @@ class SearchBarViewModel( val scope = MutableStateFlow(SearchScope.ALL) val source = MutableStateFlow(SearchSource.RELAYS) val followsOnly = MutableStateFlow(false) - val sortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT) + val sortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT) val searchTerm = searchValueFlow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index e5479e836..6d2a9cad7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -20,33 +20,43 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.search +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape 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.material.icons.Icons +import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -189,6 +199,21 @@ private fun SearchBar( } } +// Must match SearchBarViewModel.source's initial value. +private val DEFAULT_SOURCE = SearchSource.RELAYS + +private fun hasNonDefaultFilters( + scope: SearchScope, + source: SearchSource, + followsOnly: Boolean, + sort: SearchSortOrder, +): Boolean { + if (source != DEFAULT_SOURCE) return true + if (followsOnly) return true + if (scope != SearchScope.PEOPLE && sort != SearchSortOrder.EVENT_DEFAULT) return true + return false +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) { @@ -197,6 +222,8 @@ private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) { val currentFollowsOnly by searchBarViewModel.followsOnly.collectAsStateWithLifecycle() val currentSort by searchBarViewModel.sortOrder.collectAsStateWithLifecycle() + var sheetOpen by remember { mutableStateOf(false) } + Row( modifier = Modifier @@ -225,95 +252,186 @@ private fun SearchFilterRow(searchBarViewModel: SearchBarViewModel) { } } - SortMenu( - currentSort = currentSort, - enabled = currentScope != SearchScope.PEOPLE, - onSelect = { searchBarViewModel.updateSortOrder(it) }, + FilterIconButton( + hasBadge = + hasNonDefaultFilters( + scope = currentScope, + source = currentSource, + followsOnly = currentFollowsOnly, + sort = currentSort, + ), + onClick = { sheetOpen = true }, ) } - 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)) }, + if (sheetOpen) { + SearchFiltersSheet( + scope = currentScope, + source = currentSource, + followsOnly = currentFollowsOnly, + sort = currentSort, + onSourceChange = searchBarViewModel::updateSource, + onFollowsOnlyChange = searchBarViewModel::updateFollowsOnly, + onSortChange = searchBarViewModel::updateSortOrder, + onReset = { + searchBarViewModel.updateSource(DEFAULT_SOURCE) + searchBarViewModel.updateFollowsOnly(false) + searchBarViewModel.updateSortOrder(SearchSortOrder.EVENT_DEFAULT) + }, + onDismiss = { sheetOpen = false }, ) } } @Composable -private fun SortMenu( - currentSort: SearchSortOrder, - enabled: Boolean, - onSelect: (SearchSortOrder) -> Unit, +private fun FilterIconButton( + hasBadge: Boolean, + onClick: () -> 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 - }, + Box { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = stringRes(R.string.search_filters_open), + ) + } + if (hasBadge) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = (-10).dp, y = 10.dp) + .size(8.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), ) } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchFiltersSheet( + scope: SearchScope, + source: SearchSource, + followsOnly: Boolean, + sort: SearchSortOrder, + onSourceChange: (SearchSource) -> Unit, + onFollowsOnlyChange: (Boolean) -> Unit, + onSortChange: (SearchSortOrder) -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringRes(R.string.search_filters_title), + style = MaterialTheme.typography.titleMedium, + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(R.string.search_filters_section_source), + style = MaterialTheme.typography.labelLarge, + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + val sources = listOf(SearchSource.LOCAL, SearchSource.RELAYS) + sources.forEachIndexed { index, s -> + SegmentedButton( + selected = source == s, + onClick = { onSourceChange(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) + }, + ) + } + } + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onFollowsOnlyChange(!followsOnly) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.search_follows_only), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = followsOnly, + onCheckedChange = onFollowsOnlyChange, + ) + } + + if (scope != SearchScope.PEOPLE) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringRes(R.string.search_filters_section_sort), + style = MaterialTheme.typography.labelLarge, + ) + SearchSortOrder.EVENT_OPTIONS.forEach { opt -> + Row( + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = sort == opt, + onClick = { onSortChange(opt) }, + ).padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = sort == opt, + onClick = { onSortChange(opt) }, + ) + Text( + text = sortLabel(opt), + modifier = Modifier.padding(start = 8.dp), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + + TextButton(onClick = onReset) { + Text(stringRes(R.string.search_filters_reset)) + } + } + } +} + +@Composable +private fun sortLabel(opt: SearchSortOrder): String = + 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) + SearchSortOrder.NAME_AZ, SearchSortOrder.NAME_ZA -> opt.label + } + @Composable private fun SearchTextField( searchBarViewModel: SearchBarViewModel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3db5b9529..ac4486157 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1880,6 +1880,11 @@ Oldest Relevance Popular + Filters + Reset + Source + Sort by + Filters Don\'t Translate From Languages shown here will not be translated. Select a language to remove it and have it translated again. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt index 88702439c..44b16a601 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -84,10 +84,10 @@ class AdvancedSearchBarState( val noteResults: StateFlow> = _noteResults.asStateFlow() // Sort orders - private val _eventSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT) + private val _eventSortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT) val eventSortOrder: StateFlow = _eventSortOrder.asStateFlow() - private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_PEOPLE) + private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.PEOPLE_DEFAULT) val peopleSortOrder: StateFlow = _peopleSortOrder.asStateFlow() // Derived sorted results @@ -266,8 +266,8 @@ class AdvancedSearchBarState( _peopleResults.value = persistentListOf() _noteResults.value = persistentListOf() _relayStates.value = persistentListOf() - _eventSortOrder.value = SearchSortOrder.DEFAULT_EVENT - _peopleSortOrder.value = SearchSortOrder.DEFAULT_PEOPLE + _eventSortOrder.value = SearchSortOrder.EVENT_DEFAULT + _peopleSortOrder.value = SearchSortOrder.PEOPLE_DEFAULT activeSubIds.value = emptySet() eventDeduplicator.clear() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt index 490dc0e52..283af1dbf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt @@ -34,7 +34,7 @@ enum class SearchSortOrder( companion object { val EVENT_OPTIONS = listOf(RELEVANCE, NEWEST, POPULAR) val PEOPLE_OPTIONS = listOf(NAME_AZ, NAME_ZA) - val DEFAULT_EVENT = NEWEST - val DEFAULT_PEOPLE = NAME_AZ + val EVENT_DEFAULT = NEWEST + val PEOPLE_DEFAULT = NAME_AZ } }