From 376cbf8d10b2d734e966dc03d82215effbd5908a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 12:26:39 +0000 Subject: [PATCH] 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, +}