Merge pull request #2522 from davotoula/improve-search-toggles
Improve search toggles
This commit is contained in:
+112
-21
@@ -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.EVENT_DEFAULT)
|
||||
|
||||
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,37 @@ 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 +254,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 +264,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 +274,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 +284,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 +294,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 +338,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 +346,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(
|
||||
|
||||
+271
-1
@@ -20,27 +20,49 @@
|
||||
*/
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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
|
||||
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 +74,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 +102,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,11 +183,255 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
val currentScope by searchBarViewModel.scope.collectAsStateWithLifecycle()
|
||||
val currentSource by searchBarViewModel.source.collectAsStateWithLifecycle()
|
||||
val currentFollowsOnly by searchBarViewModel.followsOnly.collectAsStateWithLifecycle()
|
||||
val currentSort by searchBarViewModel.sortOrder.collectAsStateWithLifecycle()
|
||||
|
||||
var sheetOpen by remember { mutableStateOf(false) }
|
||||
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilterIconButton(
|
||||
hasBadge =
|
||||
hasNonDefaultFilters(
|
||||
scope = currentScope,
|
||||
source = currentSource,
|
||||
followsOnly = currentFollowsOnly,
|
||||
sort = currentSort,
|
||||
),
|
||||
onClick = { sheetOpen = true },
|
||||
)
|
||||
}
|
||||
|
||||
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 FilterIconButton(
|
||||
hasBadge: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -1870,6 +1870,22 @@
|
||||
|
||||
<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="search_filters_title">Filters</string>
|
||||
<string name="search_filters_reset">Reset</string>
|
||||
<string name="search_filters_section_source">Source</string>
|
||||
<string name="search_filters_section_sort">Sort by</string>
|
||||
<string name="search_filters_open">Filters</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>
|
||||
|
||||
+4
-4
@@ -84,10 +84,10 @@ class AdvancedSearchBarState(
|
||||
val noteResults: StateFlow<ImmutableList<Event>> = _noteResults.asStateFlow()
|
||||
|
||||
// Sort orders
|
||||
private val _eventSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT)
|
||||
private val _eventSortOrder = MutableStateFlow(SearchSortOrder.EVENT_DEFAULT)
|
||||
val eventSortOrder: StateFlow<SearchSortOrder> = _eventSortOrder.asStateFlow()
|
||||
|
||||
private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_PEOPLE)
|
||||
private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.PEOPLE_DEFAULT)
|
||||
val peopleSortOrder: StateFlow<SearchSortOrder> = _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()
|
||||
}
|
||||
|
||||
+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 -> {
|
||||
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,
|
||||
}
|
||||
+4
-3
@@ -26,14 +26,15 @@ 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
|
||||
val EVENT_DEFAULT = NEWEST
|
||||
val PEOPLE_DEFAULT = 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