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:
+106
-21
@@ -30,12 +30,16 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
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.commons.ui.feeds.InvalidatableContent
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
|
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
|
||||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
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.note.creators.userSuggestions.userUriPrefixes
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
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.nip05DnsIdentifiers.Nip05Id
|
||||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
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.NProfile
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
||||||
@@ -79,6 +86,11 @@ class SearchBarViewModel(
|
|||||||
val invalidations = MutableStateFlow(0)
|
val invalidations = MutableStateFlow(0)
|
||||||
val searchValueFlow = MutableStateFlow("")
|
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 =
|
val searchTerm =
|
||||||
searchValueFlow
|
searchValueFlow
|
||||||
.debounce(300)
|
.debounce(300)
|
||||||
@@ -88,6 +100,12 @@ class SearchBarViewModel(
|
|||||||
|
|
||||||
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
|
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)
|
val listState: LazyListState = LazyListState(0, 0)
|
||||||
|
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
@@ -152,21 +170,45 @@ class SearchBarViewModel(
|
|||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.IO)
|
}.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 =
|
val searchResultsUsers =
|
||||||
combine(
|
combine(
|
||||||
searchValueFlow.debounce(100),
|
searchValueFlow.debounce(100),
|
||||||
invalidations.debounce(100),
|
invalidations.debounce(100),
|
||||||
directNip05Resolver,
|
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) {
|
if (nip05Resolver != null) {
|
||||||
return@combine listOf(nip05Resolver)
|
return@combine if (follows == null || nip05Resolver.pubkeyHex in follows) {
|
||||||
|
listOf(nip05Resolver)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (term.isNotBlank()) {
|
if (term.isBlank()) return@combine emptyList<User>()
|
||||||
LocalCache.findUsersStartingWith(term, account)
|
val users = LocalCache.findUsersStartingWith(term, account)
|
||||||
} else {
|
if (follows != null) users.filter { it.pubkeyHex in follows } else users
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
@@ -174,10 +216,31 @@ class SearchBarViewModel(
|
|||||||
combine(
|
combine(
|
||||||
searchValueFlow.debounce(100),
|
searchValueFlow.debounce(100),
|
||||||
invalidations,
|
invalidations,
|
||||||
) { term, version ->
|
scope,
|
||||||
LocalCache
|
sortOrder,
|
||||||
.findNotesStartingWith(term, account.hiddenUsers)
|
combine(followsOnly, account.kind3FollowList.flow) { only, follows ->
|
||||||
.sortedWith(DefaultFeedOrder)
|
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)
|
}.flowOn(Dispatchers.IO)
|
||||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
@@ -185,8 +248,9 @@ class SearchBarViewModel(
|
|||||||
combine(
|
combine(
|
||||||
searchValueFlow.debounce(100),
|
searchValueFlow.debounce(100),
|
||||||
invalidations,
|
invalidations,
|
||||||
) { term, version ->
|
scope,
|
||||||
LocalCache.findPublicChatChannelsStartingWith(term)
|
) { term, _, currentScope ->
|
||||||
|
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findPublicChatChannelsStartingWith(term)
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
@@ -194,8 +258,9 @@ class SearchBarViewModel(
|
|||||||
combine(
|
combine(
|
||||||
searchValueFlow.debounce(100),
|
searchValueFlow.debounce(100),
|
||||||
invalidations,
|
invalidations,
|
||||||
) { term, version ->
|
scope,
|
||||||
LocalCache.findEphemeralChatChannelsStartingWith(term)
|
) { term, _, currentScope ->
|
||||||
|
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findEphemeralChatChannelsStartingWith(term)
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
@@ -203,8 +268,9 @@ class SearchBarViewModel(
|
|||||||
combine(
|
combine(
|
||||||
searchValueFlow.debounce(100),
|
searchValueFlow.debounce(100),
|
||||||
invalidations,
|
invalidations,
|
||||||
) { term, version ->
|
scope,
|
||||||
LocalCache.findLiveActivityChannelsStartingWith(term)
|
) { term, _, currentScope ->
|
||||||
|
if (currentScope != SearchScope.ALL) emptyList() else LocalCache.findLiveActivityChannelsStartingWith(term)
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
@@ -212,8 +278,9 @@ class SearchBarViewModel(
|
|||||||
combine(
|
combine(
|
||||||
searchValueFlow.debounce(100),
|
searchValueFlow.debounce(100),
|
||||||
invalidations,
|
invalidations,
|
||||||
) { term, version ->
|
scope,
|
||||||
findHashtags(term)
|
) { term, _, currentScope ->
|
||||||
|
if (currentScope == SearchScope.PEOPLE) emptyList() else findHashtags(term)
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
@@ -221,7 +288,9 @@ class SearchBarViewModel(
|
|||||||
combine(
|
combine(
|
||||||
searchValueFlow.debounce(100),
|
searchValueFlow.debounce(100),
|
||||||
invalidations,
|
invalidations,
|
||||||
) { term, version ->
|
scope,
|
||||||
|
) { term, _, currentScope ->
|
||||||
|
if (currentScope != SearchScope.ALL) return@combine emptyList()
|
||||||
if (term.length > 1) {
|
if (term.length > 1) {
|
||||||
val isTypingRelay = term.length > 7 && (term.startsWith("wss://") || term.startsWith("ws://"))
|
val isTypingRelay = term.length > 7 && (term.startsWith("wss://") || term.startsWith("ws://"))
|
||||||
val relayUrl =
|
val relayUrl =
|
||||||
@@ -263,7 +332,7 @@ class SearchBarViewModel(
|
|||||||
fun clear() = updateSearchValue("")
|
fun clear() = updateSearchValue("")
|
||||||
|
|
||||||
suspend fun updateDataSource(searchTerm: String) {
|
suspend fun updateDataSource(searchTerm: String) {
|
||||||
if (searchTerm.isBlank()) {
|
if (searchTerm.isBlank() || source.value == SearchSource.LOCAL) {
|
||||||
searchDataSourceState.searchQuery.tryEmit("")
|
searchDataSourceState.searchQuery.tryEmit("")
|
||||||
} else {
|
} else {
|
||||||
searchDataSourceState.searchQuery.tryEmit(searchTerm)
|
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()
|
fun isSearchingFun() = searchValue.isNotBlank()
|
||||||
|
|
||||||
class Factory(
|
class Factory(
|
||||||
|
|||||||
+152
-1
@@ -32,15 +32,26 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
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.HorizontalDivider
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextField
|
import androidx.compose.material3.TextField
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
@@ -52,6 +63,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.R
|
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.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription
|
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 com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@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
|
@Composable
|
||||||
|
|||||||
@@ -1870,6 +1870,17 @@
|
|||||||
|
|
||||||
<string name="search_by_hashtag">Search hashtag: #%1$s</string>
|
<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">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="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>
|
<string name="translate_to">Translate To</string>
|
||||||
|
|||||||
+6
@@ -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 -> {
|
else -> {
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -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,
|
||||||
|
}
|
||||||
+2
-1
@@ -26,12 +26,13 @@ enum class SearchSortOrder(
|
|||||||
NEWEST("Newest"),
|
NEWEST("Newest"),
|
||||||
OLDEST("Oldest"),
|
OLDEST("Oldest"),
|
||||||
RELEVANCE("Relevance"),
|
RELEVANCE("Relevance"),
|
||||||
|
POPULAR("Popular"),
|
||||||
NAME_AZ("A → Z"),
|
NAME_AZ("A → Z"),
|
||||||
NAME_ZA("Z → A"),
|
NAME_ZA("Z → A"),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
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 PEOPLE_OPTIONS = listOf(NAME_AZ, NAME_ZA)
|
||||||
val DEFAULT_EVENT = NEWEST
|
val DEFAULT_EVENT = NEWEST
|
||||||
val DEFAULT_PEOPLE = NAME_AZ
|
val DEFAULT_PEOPLE = NAME_AZ
|
||||||
|
|||||||
+26
@@ -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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user