Merge pull request #1840 from nrobi144/feat/desktop-advanced-search
feat(desktop): advanced search with NIP-50, collapsible sections, and nav state preservation
This commit is contained in:
+314
@@ -0,0 +1,314 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncState
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
enum class ChangeSource {
|
||||||
|
TEXT,
|
||||||
|
FORM,
|
||||||
|
INIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
class AdvancedSearchBarState(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val debounceMs: Long = 300L,
|
||||||
|
) {
|
||||||
|
private val _query = MutableStateFlow(SearchQuery.EMPTY)
|
||||||
|
val query: StateFlow<SearchQuery> = _query.asStateFlow()
|
||||||
|
|
||||||
|
private var _changeSource: ChangeSource = ChangeSource.INIT
|
||||||
|
val changeSource get() = _changeSource
|
||||||
|
|
||||||
|
private val _rawText = MutableStateFlow("")
|
||||||
|
val rawText: StateFlow<String> = _rawText.asStateFlow()
|
||||||
|
|
||||||
|
val displayText: StateFlow<String> =
|
||||||
|
combine(_query, _rawText) { query, raw ->
|
||||||
|
if (_changeSource == ChangeSource.TEXT) {
|
||||||
|
raw
|
||||||
|
} else {
|
||||||
|
QuerySerializer.serialize(query)
|
||||||
|
}
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, "")
|
||||||
|
|
||||||
|
val debouncedQuery: StateFlow<SearchQuery> =
|
||||||
|
_query
|
||||||
|
.debounce(debounceMs)
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY)
|
||||||
|
|
||||||
|
// People search results (from cache + relay)
|
||||||
|
private val _peopleResults = MutableStateFlow<ImmutableList<User>>(persistentListOf())
|
||||||
|
val peopleResults: StateFlow<ImmutableList<User>> = _peopleResults.asStateFlow()
|
||||||
|
|
||||||
|
// Note/event results (from relay subscriptions)
|
||||||
|
private val _noteResults = MutableStateFlow<ImmutableList<Event>>(persistentListOf())
|
||||||
|
val noteResults: StateFlow<ImmutableList<Event>> = _noteResults.asStateFlow()
|
||||||
|
|
||||||
|
// Sort orders
|
||||||
|
private val _eventSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT)
|
||||||
|
val eventSortOrder: StateFlow<SearchSortOrder> = _eventSortOrder.asStateFlow()
|
||||||
|
|
||||||
|
private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_PEOPLE)
|
||||||
|
val peopleSortOrder: StateFlow<SearchSortOrder> = _peopleSortOrder.asStateFlow()
|
||||||
|
|
||||||
|
// Derived sorted results
|
||||||
|
val sortedNoteResults: StateFlow<ImmutableList<Event>> =
|
||||||
|
combine(_noteResults, _eventSortOrder, _rawText) { notes, order, text ->
|
||||||
|
SearchResultSorter.sortEvents(notes, order, text).toImmutableList()
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
|
||||||
|
|
||||||
|
val sortedPeopleResults: StateFlow<ImmutableList<User>> =
|
||||||
|
combine(_peopleResults, _peopleSortOrder) { people, order ->
|
||||||
|
SearchResultSorter.sortPeople(people, order).toImmutableList()
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
|
||||||
|
|
||||||
|
private val activeSubIds = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val isSearching: StateFlow<Boolean> =
|
||||||
|
activeSubIds
|
||||||
|
.map { it.isNotEmpty() }
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
|
private val eventDeduplicator = EventDeduplicator()
|
||||||
|
|
||||||
|
// Expanded panel state
|
||||||
|
private val _panelExpanded = MutableStateFlow(false)
|
||||||
|
val panelExpanded: StateFlow<Boolean> = _panelExpanded.asStateFlow()
|
||||||
|
|
||||||
|
// Per-relay sync status
|
||||||
|
private val _relayStates = MutableStateFlow<ImmutableList<RelaySyncState>>(persistentListOf())
|
||||||
|
val relayStates: StateFlow<ImmutableList<RelaySyncState>> = _relayStates.asStateFlow()
|
||||||
|
|
||||||
|
// Text bar input
|
||||||
|
fun updateFromText(rawText: String) {
|
||||||
|
_changeSource = ChangeSource.TEXT
|
||||||
|
_rawText.value = rawText
|
||||||
|
_query.value = QueryParser.parse(rawText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form panel inputs
|
||||||
|
fun updateKinds(kinds: List<Int>) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
_query.value = _query.value.copy(kinds = kinds.toImmutableList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePseudoKinds(pseudoKinds: List<String>) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
_query.value = _query.value.copy(pseudoKinds = pseudoKinds.toImmutableList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addAuthor(hexOrName: String) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
val current = _query.value
|
||||||
|
val hex =
|
||||||
|
com.vitorpamplona.quartz.nip19Bech32
|
||||||
|
.decodePublicKeyAsHexOrNull(hexOrName)
|
||||||
|
if (hex != null) {
|
||||||
|
if (hex !in current.authors) {
|
||||||
|
_query.value = current.copy(authors = (current.authors + hex).toImmutableList())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (hexOrName !in current.authorNames) {
|
||||||
|
_query.value = current.copy(authorNames = (current.authorNames + hexOrName).toImmutableList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAuthor(hex: String) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
val current = _query.value
|
||||||
|
_query.value =
|
||||||
|
current.copy(
|
||||||
|
authors = current.authors.filter { it != hex }.toImmutableList(),
|
||||||
|
authorNames = current.authorNames.filter { it != hex }.toImmutableList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDateRange(
|
||||||
|
since: Long?,
|
||||||
|
until: Long?,
|
||||||
|
) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
_query.value = _query.value.copy(since = since, until = until)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addHashtag(tag: String) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
val current = _query.value
|
||||||
|
val cleaned = tag.removePrefix("#")
|
||||||
|
if (cleaned !in current.hashtags) {
|
||||||
|
_query.value = current.copy(hashtags = (current.hashtags + cleaned).toImmutableList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeHashtag(tag: String) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
val current = _query.value
|
||||||
|
_query.value = current.copy(hashtags = current.hashtags.filter { it != tag }.toImmutableList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addExcludeTerm(term: String) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
val current = _query.value
|
||||||
|
if (term !in current.excludeTerms) {
|
||||||
|
_query.value = current.copy(excludeTerms = (current.excludeTerms + term).toImmutableList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeExcludeTerm(term: String) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
val current = _query.value
|
||||||
|
_query.value = current.copy(excludeTerms = current.excludeTerms.filter { it != term }.toImmutableList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLanguage(lang: String?) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
_query.value = _query.value.copy(language = lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initRelayStates(relays: Set<NormalizedRelayUrl>) {
|
||||||
|
_relayStates.value =
|
||||||
|
relays
|
||||||
|
.map {
|
||||||
|
RelaySyncState(
|
||||||
|
url = it.url,
|
||||||
|
displayName = it.displayUrl(),
|
||||||
|
status = RelaySyncStatus.WAITING,
|
||||||
|
)
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRelayState(
|
||||||
|
relayUrl: String,
|
||||||
|
status: RelaySyncStatus,
|
||||||
|
eventsDelta: Int = 0,
|
||||||
|
) {
|
||||||
|
_relayStates.update { states ->
|
||||||
|
states
|
||||||
|
.map {
|
||||||
|
if (it.url == relayUrl) {
|
||||||
|
it.copy(status = status, eventsReceived = it.eventsReceived + eventsDelta)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun timeoutWaitingRelays() {
|
||||||
|
_relayStates.update { states ->
|
||||||
|
states
|
||||||
|
.map {
|
||||||
|
if (it.status == RelaySyncStatus.WAITING || it.status == RelaySyncStatus.CONNECTING) {
|
||||||
|
it.copy(status = RelaySyncStatus.FAILED)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
activeSubIds.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePanel() {
|
||||||
|
_panelExpanded.value = !_panelExpanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateEventSortOrder(order: SearchSortOrder) {
|
||||||
|
_eventSortOrder.value = order
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePeopleSortOrder(order: SearchSortOrder) {
|
||||||
|
_peopleSortOrder.value = order
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSearch() {
|
||||||
|
_changeSource = ChangeSource.INIT
|
||||||
|
_rawText.value = ""
|
||||||
|
_query.value = SearchQuery.EMPTY
|
||||||
|
_peopleResults.value = persistentListOf()
|
||||||
|
_noteResults.value = persistentListOf()
|
||||||
|
_relayStates.value = persistentListOf()
|
||||||
|
_eventSortOrder.value = SearchSortOrder.DEFAULT_EVENT
|
||||||
|
_peopleSortOrder.value = SearchSortOrder.DEFAULT_PEOPLE
|
||||||
|
activeSubIds.value = emptySet()
|
||||||
|
eventDeduplicator.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results management (called from subscription callbacks)
|
||||||
|
fun startSearching(subId: String) {
|
||||||
|
activeSubIds.update { it + subId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopSearching(subId: String) {
|
||||||
|
activeSubIds.update { it - subId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trackRelayEvent(
|
||||||
|
relayUrl: String,
|
||||||
|
eventId: String,
|
||||||
|
): Boolean {
|
||||||
|
val isNew = eventDeduplicator.tryAdd(eventId)
|
||||||
|
if (isNew) {
|
||||||
|
updateRelayState(relayUrl, RelaySyncStatus.RECEIVING, eventsDelta = 1)
|
||||||
|
}
|
||||||
|
return isNew
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearResults() {
|
||||||
|
_peopleResults.value = persistentListOf()
|
||||||
|
_noteResults.value = persistentListOf()
|
||||||
|
eventDeduplicator.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addPeopleResult(user: User) {
|
||||||
|
val current = _peopleResults.value
|
||||||
|
if (current.none { it.pubkeyHex == user.pubkeyHex }) {
|
||||||
|
_peopleResults.value = (current + user).toImmutableList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNoteResults(events: List<Event>) {
|
||||||
|
if (events.isNotEmpty()) {
|
||||||
|
val current = _noteResults.value
|
||||||
|
_noteResults.value = (current + events).toImmutableList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
object DateUtils {
|
||||||
|
fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||||||
|
|
||||||
|
fun dateToUnix(
|
||||||
|
year: Int,
|
||||||
|
month: Int,
|
||||||
|
day: Int,
|
||||||
|
): Long {
|
||||||
|
var totalDays = 0L
|
||||||
|
|
||||||
|
for (y in 1970 until year) {
|
||||||
|
totalDays += if (isLeapYear(y)) 366 else 365
|
||||||
|
}
|
||||||
|
|
||||||
|
val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||||
|
if (isLeapYear(year)) daysInMonth[2] = 29
|
||||||
|
for (m in 1 until month) {
|
||||||
|
totalDays += daysInMonth[m]
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDays += (day - 1)
|
||||||
|
|
||||||
|
return totalDays * 86400L
|
||||||
|
}
|
||||||
|
|
||||||
|
fun timestampToDate(timestamp: Long): String {
|
||||||
|
var remaining = timestamp
|
||||||
|
var year = 1970
|
||||||
|
while (true) {
|
||||||
|
val daysInYear = if (isLeapYear(year)) 366L else 365L
|
||||||
|
val secondsInYear = daysInYear * 86400L
|
||||||
|
if (remaining < secondsInYear) break
|
||||||
|
remaining -= secondsInYear
|
||||||
|
year++
|
||||||
|
}
|
||||||
|
|
||||||
|
val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
||||||
|
if (isLeapYear(year)) daysInMonth[2] = 29
|
||||||
|
|
||||||
|
var dayOfYear = (remaining / 86400).toInt() + 1
|
||||||
|
var month = 1
|
||||||
|
while (month <= 12 && dayOfYear > daysInMonth[month]) {
|
||||||
|
dayOfYear -= daysInMonth[month]
|
||||||
|
month++
|
||||||
|
}
|
||||||
|
|
||||||
|
return "$year-${month.toString().padStart(2, '0')}-${dayOfYear.toString().padStart(2, '0')}"
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
class EventDeduplicator {
|
||||||
|
private val lock = Any()
|
||||||
|
private val seenIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) }
|
||||||
|
|
||||||
|
fun contains(id: String): Boolean = synchronized(lock) { id in seenIds }
|
||||||
|
|
||||||
|
fun clear() = synchronized(lock) { seenIds.clear() }
|
||||||
|
|
||||||
|
val size: Int get() = synchronized(lock) { seenIds.size }
|
||||||
|
}
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||||
|
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||||
|
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||||
|
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||||
|
|
||||||
|
data class ContentPreset(
|
||||||
|
val kinds: List<Int> = emptyList(),
|
||||||
|
val pseudoKind: String? = null,
|
||||||
|
) {
|
||||||
|
/** Check if this preset is active given current query state. */
|
||||||
|
fun isSelected(
|
||||||
|
queryKinds: List<Int>,
|
||||||
|
queryPseudoKinds: List<String>,
|
||||||
|
): Boolean =
|
||||||
|
if (pseudoKind != null) {
|
||||||
|
pseudoKind in queryPseudoKinds
|
||||||
|
} else {
|
||||||
|
kinds.isNotEmpty() && queryKinds.containsAll(kinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object KindRegistry {
|
||||||
|
val aliases: Map<String, List<Int>> =
|
||||||
|
mapOf(
|
||||||
|
"note" to listOf(TextNoteEvent.KIND),
|
||||||
|
"article" to listOf(LongTextNoteEvent.KIND),
|
||||||
|
"repost" to listOf(RepostEvent.KIND),
|
||||||
|
"profile" to listOf(MetadataEvent.KIND),
|
||||||
|
"channel" to listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
|
||||||
|
"live" to listOf(LiveActivitiesEvent.KIND),
|
||||||
|
"community" to listOf(CommunityDefinitionEvent.KIND),
|
||||||
|
"wiki" to listOf(WikiNoteEvent.KIND),
|
||||||
|
"classified" to listOf(ClassifiedsEvent.KIND),
|
||||||
|
"highlight" to listOf(HighlightEvent.KIND),
|
||||||
|
)
|
||||||
|
|
||||||
|
val pseudoKinds: Set<String> = setOf("reply", "media")
|
||||||
|
|
||||||
|
val presets: Map<String, ContentPreset> =
|
||||||
|
mapOf(
|
||||||
|
"Notes" to ContentPreset(kinds = listOf(TextNoteEvent.KIND)),
|
||||||
|
"Articles" to ContentPreset(kinds = listOf(LongTextNoteEvent.KIND)),
|
||||||
|
"Media" to ContentPreset(pseudoKind = "media"),
|
||||||
|
"Channels" to ContentPreset(kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND)),
|
||||||
|
"Communities" to ContentPreset(kinds = listOf(CommunityDefinitionEvent.KIND)),
|
||||||
|
"Wiki" to ContentPreset(kinds = listOf(WikiNoteEvent.KIND)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun resolve(alias: String): List<Int>? = aliases[alias.lowercase()]
|
||||||
|
|
||||||
|
fun isPseudoKind(alias: String): Boolean = alias.lowercase() in pseudoKinds
|
||||||
|
|
||||||
|
fun nameFor(kind: Int): String? = aliases.entries.find { kind in it.value }?.key
|
||||||
|
}
|
||||||
+323
@@ -0,0 +1,323 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
|
||||||
|
sealed interface Token {
|
||||||
|
data class Operator(
|
||||||
|
val name: String,
|
||||||
|
val value: String,
|
||||||
|
val raw: String,
|
||||||
|
) : Token
|
||||||
|
|
||||||
|
data class Text(
|
||||||
|
val value: String,
|
||||||
|
) : Token
|
||||||
|
|
||||||
|
data object Or : Token
|
||||||
|
|
||||||
|
data class Quoted(
|
||||||
|
val value: String,
|
||||||
|
val raw: String,
|
||||||
|
) : Token
|
||||||
|
|
||||||
|
data class Negation(
|
||||||
|
val term: String,
|
||||||
|
) : Token
|
||||||
|
|
||||||
|
data class Hashtag(
|
||||||
|
val tag: String,
|
||||||
|
) : Token
|
||||||
|
}
|
||||||
|
|
||||||
|
object QueryParser {
|
||||||
|
private val KNOWN_OPERATORS = setOf("from", "kind", "since", "until", "lang", "domain")
|
||||||
|
|
||||||
|
fun parse(input: String): SearchQuery {
|
||||||
|
if (input.isBlank()) return SearchQuery.EMPTY
|
||||||
|
val tokens = tokenize(input)
|
||||||
|
return buildQuery(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun tokenize(input: String): List<Token> {
|
||||||
|
val tokens = mutableListOf<Token>()
|
||||||
|
var i = 0
|
||||||
|
val len = input.length
|
||||||
|
|
||||||
|
while (i < len) {
|
||||||
|
// Skip whitespace
|
||||||
|
if (input[i].isWhitespace()) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quoted phrase
|
||||||
|
if (input[i] == '"') {
|
||||||
|
val start = i
|
||||||
|
i++ // skip opening quote
|
||||||
|
val sb = StringBuilder()
|
||||||
|
while (i < len && input[i] != '"') {
|
||||||
|
sb.append(input[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (i < len) i++ // skip closing quote
|
||||||
|
val value = sb.toString()
|
||||||
|
tokens.add(Token.Quoted(value, input.substring(start, i)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negation
|
||||||
|
if (input[i] == '-' && i + 1 < len && !input[i + 1].isWhitespace()) {
|
||||||
|
i++ // skip -
|
||||||
|
val word = readWord(input, i)
|
||||||
|
i += word.length
|
||||||
|
if (word.isNotEmpty()) {
|
||||||
|
tokens.add(Token.Negation(word))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashtag
|
||||||
|
if (input[i] == '#' && i + 1 < len && !input[i + 1].isWhitespace()) {
|
||||||
|
i++ // skip #
|
||||||
|
val tag = readWord(input, i)
|
||||||
|
i += tag.length
|
||||||
|
if (tag.isNotEmpty()) {
|
||||||
|
tokens.add(Token.Hashtag(tag))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a word (may be operator:value, OR, or plain text)
|
||||||
|
val word = readWord(input, i)
|
||||||
|
i += word.length
|
||||||
|
|
||||||
|
if (word.isEmpty()) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for OR keyword
|
||||||
|
if (word == "OR") {
|
||||||
|
tokens.add(Token.Or)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for operator pattern (word:value)
|
||||||
|
val colonIdx = word.indexOf(':')
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
val opName = word.substring(0, colonIdx).lowercase()
|
||||||
|
val opValue = word.substring(colonIdx + 1)
|
||||||
|
if (opName in KNOWN_OPERATORS && opValue.isNotEmpty()) {
|
||||||
|
tokens.add(Token.Operator(opName, opValue, word))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Malformed operator (no value or unknown) → treat as text
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens.add(Token.Text(word))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readWord(
|
||||||
|
input: String,
|
||||||
|
start: Int,
|
||||||
|
): String {
|
||||||
|
var i = start
|
||||||
|
while (i < input.length && !input[i].isWhitespace()) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return input.substring(start, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildQuery(tokens: List<Token>): SearchQuery {
|
||||||
|
val authors = mutableListOf<String>()
|
||||||
|
val authorNames = mutableListOf<String>()
|
||||||
|
val kinds = mutableListOf<Int>()
|
||||||
|
val hashtags = mutableListOf<String>()
|
||||||
|
val excludeTerms = mutableListOf<String>()
|
||||||
|
val pseudoKinds = mutableListOf<String>()
|
||||||
|
val textParts = mutableListOf<String>()
|
||||||
|
val orTerms = mutableListOf<String>()
|
||||||
|
var since: Long? = null
|
||||||
|
var until: Long? = null
|
||||||
|
var language: String? = null
|
||||||
|
var domain: String? = null
|
||||||
|
|
||||||
|
// Collect OR groups: text terms separated by OR
|
||||||
|
var i = 0
|
||||||
|
while (i < tokens.size) {
|
||||||
|
when (val token = tokens[i]) {
|
||||||
|
is Token.Operator -> {
|
||||||
|
when (token.name) {
|
||||||
|
"from" -> {
|
||||||
|
val hex = decodePublicKeyAsHexOrNull(token.value)
|
||||||
|
if (hex != null) {
|
||||||
|
authors.add(hex)
|
||||||
|
} else {
|
||||||
|
authorNames.add(token.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"kind" -> {
|
||||||
|
if (KindRegistry.isPseudoKind(token.value)) {
|
||||||
|
pseudoKinds.add(token.value.lowercase())
|
||||||
|
} else {
|
||||||
|
val resolved = KindRegistry.resolve(token.value)
|
||||||
|
if (resolved != null) {
|
||||||
|
kinds.addAll(resolved)
|
||||||
|
} else {
|
||||||
|
token.value.toIntOrNull()?.let { kinds.add(it) }
|
||||||
|
?: textParts.add(token.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"since" -> {
|
||||||
|
val ts = parseDateToTimestamp(token.value)
|
||||||
|
if (ts != null) {
|
||||||
|
since = ts
|
||||||
|
} else {
|
||||||
|
textParts.add(token.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"until" -> {
|
||||||
|
val ts = parseDateToTimestamp(token.value)
|
||||||
|
if (ts != null) {
|
||||||
|
until = ts
|
||||||
|
} else {
|
||||||
|
textParts.add(token.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"lang" -> {
|
||||||
|
language = token.value.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
"domain" -> {
|
||||||
|
domain = token.value.lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Token.Text -> {
|
||||||
|
// Check if this is part of an OR chain
|
||||||
|
if (i + 2 < tokens.size && tokens[i + 1] is Token.Or && tokens[i + 2] is Token.Text) {
|
||||||
|
// Start of OR chain: collect all terms
|
||||||
|
orTerms.add(token.value)
|
||||||
|
i++ // skip to OR
|
||||||
|
while (i < tokens.size && tokens[i] is Token.Or && i + 1 < tokens.size && tokens[i + 1] is Token.Text) {
|
||||||
|
i++ // skip OR
|
||||||
|
orTerms.add((tokens[i] as Token.Text).value)
|
||||||
|
i++ // skip text
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
textParts.add(token.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is Token.Quoted -> {
|
||||||
|
textParts.add(token.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Token.Negation -> {
|
||||||
|
excludeTerms.add(token.term)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Token.Hashtag -> {
|
||||||
|
hashtags.add(token.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Token.Or -> {
|
||||||
|
// Orphaned OR (no adjacent text terms) → treat as text
|
||||||
|
textParts.add("OR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap OR terms at 3
|
||||||
|
val cappedOrTerms = orTerms.take(3)
|
||||||
|
|
||||||
|
return SearchQuery(
|
||||||
|
text = textParts.joinToString(" "),
|
||||||
|
authors = authors.distinct().toImmutableList(),
|
||||||
|
authorNames = authorNames.distinct().toImmutableList(),
|
||||||
|
kinds = kinds.distinct().toImmutableList(),
|
||||||
|
since = since,
|
||||||
|
until = until,
|
||||||
|
hashtags = hashtags.distinct().toImmutableList(),
|
||||||
|
excludeTerms = excludeTerms.distinct().toImmutableList(),
|
||||||
|
language = language,
|
||||||
|
domain = domain,
|
||||||
|
orTerms = cappedOrTerms.toPersistentList(),
|
||||||
|
pseudoKinds = pseudoKinds.distinct().toImmutableList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseDateToTimestamp(dateStr: String): Long? {
|
||||||
|
// ISO 8601 formats: YYYY, YYYY-MM, YYYY-MM-DD
|
||||||
|
return try {
|
||||||
|
val parts = dateStr.split("-")
|
||||||
|
when (parts.size) {
|
||||||
|
1 -> {
|
||||||
|
val year = parts[0].toIntOrNull() ?: return null
|
||||||
|
if (year < 1970 || year > 2100) return null
|
||||||
|
dateToUnix(year, 1, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> {
|
||||||
|
val year = parts[0].toIntOrNull() ?: return null
|
||||||
|
val month = parts[1].toIntOrNull() ?: return null
|
||||||
|
if (year < 1970 || year > 2100 || month < 1 || month > 12) return null
|
||||||
|
dateToUnix(year, month, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
3 -> {
|
||||||
|
val year = parts[0].toIntOrNull() ?: return null
|
||||||
|
val month = parts[1].toIntOrNull() ?: return null
|
||||||
|
val day = parts[2].toIntOrNull() ?: return null
|
||||||
|
if (year < 1970 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null
|
||||||
|
dateToUnix(year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dateToUnix(
|
||||||
|
year: Int,
|
||||||
|
month: Int,
|
||||||
|
day: Int,
|
||||||
|
): Long = DateUtils.dateToUnix(year, month, day)
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||||
|
|
||||||
|
object QuerySerializer {
|
||||||
|
fun serialize(query: SearchQuery): String {
|
||||||
|
if (query.isEmpty) return ""
|
||||||
|
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Operators first
|
||||||
|
query.authors.forEach { hex ->
|
||||||
|
val npub =
|
||||||
|
try {
|
||||||
|
NPub.create(hex)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
parts.add("from:${npub ?: hex}")
|
||||||
|
}
|
||||||
|
query.authorNames.forEach { name ->
|
||||||
|
parts.add("from:$name")
|
||||||
|
}
|
||||||
|
query.kinds.forEach { kind ->
|
||||||
|
val name = KindRegistry.nameFor(kind)
|
||||||
|
parts.add("kind:${name ?: kind}")
|
||||||
|
}
|
||||||
|
query.pseudoKinds.forEach { pseudo ->
|
||||||
|
parts.add("kind:$pseudo")
|
||||||
|
}
|
||||||
|
query.since?.let { ts ->
|
||||||
|
parts.add("since:${timestampToDate(ts)}")
|
||||||
|
}
|
||||||
|
query.until?.let { ts ->
|
||||||
|
parts.add("until:${timestampToDate(ts)}")
|
||||||
|
}
|
||||||
|
query.language?.let { lang ->
|
||||||
|
parts.add("lang:$lang")
|
||||||
|
}
|
||||||
|
query.domain?.let { dom ->
|
||||||
|
parts.add("domain:$dom")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashtags
|
||||||
|
query.hashtags.forEach { tag ->
|
||||||
|
parts.add("#$tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free text
|
||||||
|
if (query.text.isNotBlank()) {
|
||||||
|
parts.add(query.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OR terms
|
||||||
|
if (query.orTerms.isNotEmpty()) {
|
||||||
|
parts.add(query.orTerms.joinToString(" OR "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclusions last
|
||||||
|
query.excludeTerms.forEach { term ->
|
||||||
|
parts.add("-$term")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun timestampToDate(timestamp: Long): String = DateUtils.timestampToDate(timestamp)
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
data class SavedSearch(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val query: SearchQuery,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class SearchQuery(
|
||||||
|
val text: String = "",
|
||||||
|
val authors: ImmutableList<String> = persistentListOf(),
|
||||||
|
val authorNames: ImmutableList<String> = persistentListOf(),
|
||||||
|
val kinds: ImmutableList<Int> = persistentListOf(),
|
||||||
|
val since: Long? = null,
|
||||||
|
val until: Long? = null,
|
||||||
|
val hashtags: ImmutableList<String> = persistentListOf(),
|
||||||
|
val excludeTerms: ImmutableList<String> = persistentListOf(),
|
||||||
|
val language: String? = null,
|
||||||
|
val domain: String? = null,
|
||||||
|
val orTerms: ImmutableList<String> = persistentListOf(),
|
||||||
|
val pseudoKinds: ImmutableList<String> = persistentListOf(),
|
||||||
|
) {
|
||||||
|
val isEmpty
|
||||||
|
get() =
|
||||||
|
text.isBlank() &&
|
||||||
|
authors.isEmpty() &&
|
||||||
|
authorNames.isEmpty() &&
|
||||||
|
kinds.isEmpty() &&
|
||||||
|
since == null &&
|
||||||
|
until == null &&
|
||||||
|
hashtags.isEmpty() &&
|
||||||
|
orTerms.isEmpty() &&
|
||||||
|
excludeTerms.isEmpty() &&
|
||||||
|
pseudoKinds.isEmpty() &&
|
||||||
|
language == null &&
|
||||||
|
domain == null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val EMPTY = SearchQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
-9
@@ -20,8 +20,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.search
|
package com.vitorpamplona.amethyst.commons.search
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.model.User
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a parsed search result from Bech32/hex input.
|
* Represents a parsed search result from Bech32/hex input.
|
||||||
* Shared between Android and Desktop for consistent search behavior.
|
* Shared between Android and Desktop for consistent search behavior.
|
||||||
@@ -35,13 +33,6 @@ sealed class SearchResult {
|
|||||||
val displayId: String,
|
val displayId: String,
|
||||||
) : SearchResult()
|
) : SearchResult()
|
||||||
|
|
||||||
/**
|
|
||||||
* User from local cache with full metadata.
|
|
||||||
*/
|
|
||||||
data class CachedUserResult(
|
|
||||||
val user: User,
|
|
||||||
) : SearchResult()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note lookup from note1 or nevent.
|
* Note lookup from note1 or nevent.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
|
||||||
|
object SearchResultFilter {
|
||||||
|
fun filter(
|
||||||
|
events: List<Event>,
|
||||||
|
query: SearchQuery,
|
||||||
|
): List<Event> {
|
||||||
|
var result = events
|
||||||
|
|
||||||
|
// Dedup by event ID
|
||||||
|
result = result.distinctBy { it.id }
|
||||||
|
|
||||||
|
// Exclusion terms (client-side)
|
||||||
|
if (query.excludeTerms.isNotEmpty()) {
|
||||||
|
result =
|
||||||
|
result.filter { event ->
|
||||||
|
query.excludeTerms.none { term ->
|
||||||
|
event.content.contains(term, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pseudo-kind: reply (kind 1 with e tag)
|
||||||
|
if ("reply" in query.pseudoKinds) {
|
||||||
|
result = result.filter { event -> isReply(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pseudo-kind: media (kind 1 with imeta tag or image URLs)
|
||||||
|
if ("media" in query.pseudoKinds) {
|
||||||
|
result = result.filter { event -> isMedia(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by createdAt descending
|
||||||
|
return result.sortedByDescending { it.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isReply(event: Event): Boolean = event.kind == 1 && event.tags.any { it.size >= 2 && it[0] == "e" }
|
||||||
|
|
||||||
|
fun isMedia(event: Event): Boolean {
|
||||||
|
if (event.kind != 1) return false
|
||||||
|
// Check for imeta tag
|
||||||
|
if (event.tags.any { it.size >= 2 && it[0] == "imeta" }) return true
|
||||||
|
// Check for image/video URLs in content
|
||||||
|
return IMAGE_URL_PATTERN.containsMatchIn(event.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val IMAGE_URL_PATTERN =
|
||||||
|
Regex(
|
||||||
|
"""https?://\S+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)""",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
}
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.currentTimeSeconds
|
||||||
|
|
||||||
|
object SearchResultSorter {
|
||||||
|
fun sortEvents(
|
||||||
|
events: List<Event>,
|
||||||
|
order: SearchSortOrder,
|
||||||
|
searchText: String,
|
||||||
|
): List<Event> =
|
||||||
|
when (order) {
|
||||||
|
SearchSortOrder.NEWEST -> {
|
||||||
|
events.sortedByDescending { it.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchSortOrder.OLDEST -> {
|
||||||
|
events.sortedBy { it.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchSortOrder.RELEVANCE -> {
|
||||||
|
if (searchText.isBlank()) {
|
||||||
|
events.sortedByDescending { it.createdAt }
|
||||||
|
} else {
|
||||||
|
events.sortedByDescending { scoreEvent(it, searchText) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sortPeople(
|
||||||
|
people: List<User>,
|
||||||
|
order: SearchSortOrder,
|
||||||
|
): List<User> =
|
||||||
|
when (order) {
|
||||||
|
SearchSortOrder.NAME_AZ -> people.sortedBy { it.toBestDisplayName().lowercase() }
|
||||||
|
SearchSortOrder.NAME_ZA -> people.sortedByDescending { it.toBestDisplayName().lowercase() }
|
||||||
|
else -> people
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scoreEvent(
|
||||||
|
event: Event,
|
||||||
|
searchText: String,
|
||||||
|
): Double {
|
||||||
|
val query = searchText.trim().lowercase()
|
||||||
|
if (query.isEmpty()) return event.createdAt.toDouble()
|
||||||
|
|
||||||
|
var score = 0.0
|
||||||
|
val content = event.content.lowercase()
|
||||||
|
val tokens = query.split("\\s+".toRegex())
|
||||||
|
|
||||||
|
// Exact phrase match in content
|
||||||
|
if (content.contains(query)) {
|
||||||
|
score += 10.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-token scoring
|
||||||
|
for (token in tokens) {
|
||||||
|
if (token.isEmpty()) continue
|
||||||
|
val wordBoundary = "\\b${Regex.escape(token)}\\b".toRegex()
|
||||||
|
if (wordBoundary.containsMatchIn(content)) {
|
||||||
|
score += 5.0
|
||||||
|
} else if (content.contains(token)) {
|
||||||
|
score += 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Article title boost
|
||||||
|
if (event is LongTextNoteEvent) {
|
||||||
|
val title = event.title()?.lowercase()
|
||||||
|
if (title != null) {
|
||||||
|
if (title.contains(query)) {
|
||||||
|
score += 15.0
|
||||||
|
}
|
||||||
|
for (token in tokens) {
|
||||||
|
if (token.isEmpty()) continue
|
||||||
|
if (title.contains(token)) {
|
||||||
|
score += 3.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recency tiebreaker (normalized 0..1)
|
||||||
|
val now = currentTimeSeconds()
|
||||||
|
val age = (now - event.createdAt).coerceAtLeast(1)
|
||||||
|
val maxAge = 365L * 24 * 3600 // 1 year
|
||||||
|
score += (1.0 - (age.toDouble() / maxAge).coerceIn(0.0, 1.0))
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* 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 SearchSortOrder(
|
||||||
|
val label: String,
|
||||||
|
) {
|
||||||
|
NEWEST("Newest"),
|
||||||
|
OLDEST("Oldest"),
|
||||||
|
RELEVANCE("Relevance"),
|
||||||
|
NAME_AZ("A → Z"),
|
||||||
|
NAME_ZA("Z → A"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val EVENT_OPTIONS = listOf(NEWEST, OLDEST, RELEVANCE)
|
||||||
|
val PEOPLE_OPTIONS = listOf(NAME_AZ, NAME_ZA)
|
||||||
|
val DEFAULT_EVENT = NEWEST
|
||||||
|
val DEFAULT_PEOPLE = NAME_AZ
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class KindRegistryTest {
|
||||||
|
@Test
|
||||||
|
fun resolveNote() {
|
||||||
|
assertEquals(listOf(1), KindRegistry.resolve("note"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveArticle() {
|
||||||
|
assertEquals(listOf(30023), KindRegistry.resolve("article"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveChannel() {
|
||||||
|
val kinds = KindRegistry.resolve("channel")!!
|
||||||
|
assertTrue(40 in kinds)
|
||||||
|
assertTrue(41 in kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveCaseInsensitive() {
|
||||||
|
assertEquals(KindRegistry.resolve("NOTE"), KindRegistry.resolve("note"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveUnknown() {
|
||||||
|
assertNull(KindRegistry.resolve("unknown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isPseudoKindReply() {
|
||||||
|
assertTrue(KindRegistry.isPseudoKind("reply"))
|
||||||
|
assertTrue(KindRegistry.isPseudoKind("Reply"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isPseudoKindMedia() {
|
||||||
|
assertTrue(KindRegistry.isPseudoKind("media"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNotPseudoKind() {
|
||||||
|
assertFalse(KindRegistry.isPseudoKind("note"))
|
||||||
|
assertFalse(KindRegistry.isPseudoKind("article"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nameForKind1() {
|
||||||
|
assertEquals("note", KindRegistry.nameFor(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nameForKind30023() {
|
||||||
|
assertEquals("article", KindRegistry.nameFor(30023))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nameForUnknownKind() {
|
||||||
|
assertNull(KindRegistry.nameFor(99999))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allAliasesResolve() {
|
||||||
|
KindRegistry.aliases.forEach { (alias, kinds) ->
|
||||||
|
assertEquals(kinds, KindRegistry.resolve(alias))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun presetsContainExpectedEntries() {
|
||||||
|
assertTrue(KindRegistry.presets.containsKey("Notes"))
|
||||||
|
assertTrue(KindRegistry.presets.containsKey("Articles"))
|
||||||
|
assertTrue(KindRegistry.presets.containsKey("Media"))
|
||||||
|
assertTrue(KindRegistry.presets.containsKey("Channels"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class QueryParserTest {
|
||||||
|
@Test
|
||||||
|
fun emptyInput() {
|
||||||
|
val q = QueryParser.parse("")
|
||||||
|
assertTrue(q.isEmpty)
|
||||||
|
assertEquals(SearchQuery.EMPTY, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun whitespaceOnly() {
|
||||||
|
val q = QueryParser.parse(" ")
|
||||||
|
assertTrue(q.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun plainText() {
|
||||||
|
val q = QueryParser.parse("bitcoin lightning")
|
||||||
|
assertEquals("bitcoin lightning", q.text)
|
||||||
|
assertTrue(q.authors.isEmpty())
|
||||||
|
assertTrue(q.kinds.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kindOperatorAlias() {
|
||||||
|
val q = QueryParser.parse("kind:note")
|
||||||
|
assertEquals(listOf(1), q.kinds.toList())
|
||||||
|
assertTrue(q.text.isBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kindOperatorNumeric() {
|
||||||
|
val q = QueryParser.parse("kind:30023")
|
||||||
|
assertEquals(listOf(30023), q.kinds.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kindOperatorArticle() {
|
||||||
|
val q = QueryParser.parse("kind:article")
|
||||||
|
assertEquals(listOf(30023), q.kinds.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kindOperatorInvalid() {
|
||||||
|
val q = QueryParser.parse("kind:invalid")
|
||||||
|
// Unresolvable kind → treated as text
|
||||||
|
assertEquals("kind:invalid", q.text)
|
||||||
|
assertTrue(q.kinds.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pseudoKindReply() {
|
||||||
|
val q = QueryParser.parse("kind:reply")
|
||||||
|
assertTrue(q.kinds.isEmpty())
|
||||||
|
assertEquals(listOf("reply"), q.pseudoKinds.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pseudoKindMedia() {
|
||||||
|
val q = QueryParser.parse("kind:media")
|
||||||
|
assertTrue(q.kinds.isEmpty())
|
||||||
|
assertEquals(listOf("media"), q.pseudoKinds.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleKinds() {
|
||||||
|
val q = QueryParser.parse("kind:note kind:article")
|
||||||
|
assertEquals(listOf(1, 30023), q.kinds.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sinceDate() {
|
||||||
|
val q = QueryParser.parse("since:2025-01-01")
|
||||||
|
// 2025-01-01 00:00:00 UTC
|
||||||
|
assertEquals(1735689600L, q.since)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sinceDateYearOnly() {
|
||||||
|
val q = QueryParser.parse("since:2025")
|
||||||
|
// 2025-01-01 00:00:00 UTC
|
||||||
|
assertEquals(1735689600L, q.since)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sinceDateYearMonth() {
|
||||||
|
val q = QueryParser.parse("since:2025-06")
|
||||||
|
// 2025-06-01 00:00:00 UTC
|
||||||
|
val q2 = QueryParser.parse("since:2025-06-01")
|
||||||
|
assertEquals(q2.since, q.since)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sinceInvalidDate() {
|
||||||
|
val q = QueryParser.parse("since:not-a-date")
|
||||||
|
assertNull(q.since)
|
||||||
|
assertEquals("since:not-a-date", q.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun untilDate() {
|
||||||
|
val q = QueryParser.parse("until:2025-12-31")
|
||||||
|
assertNull(q.since)
|
||||||
|
assertTrue(q.until != null && q.until!! > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hashtag() {
|
||||||
|
val q = QueryParser.parse("#bitcoin")
|
||||||
|
assertEquals(listOf("bitcoin"), q.hashtags.toList())
|
||||||
|
assertTrue(q.text.isBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleHashtags() {
|
||||||
|
val q = QueryParser.parse("#bitcoin #nostr")
|
||||||
|
assertEquals(listOf("bitcoin", "nostr"), q.hashtags.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negationTerm() {
|
||||||
|
val q = QueryParser.parse("-spam")
|
||||||
|
assertEquals(listOf("spam"), q.excludeTerms.toList())
|
||||||
|
assertTrue(q.text.isBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleNegations() {
|
||||||
|
val q = QueryParser.parse("-spam -scam")
|
||||||
|
assertEquals(listOf("spam", "scam"), q.excludeTerms.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun quotedPhrase() {
|
||||||
|
val q = QueryParser.parse("\"exact phrase\"")
|
||||||
|
assertEquals("\"exact phrase\"", q.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun orTerms() {
|
||||||
|
val q = QueryParser.parse("bitcoin OR lightning")
|
||||||
|
assertEquals(listOf("bitcoin", "lightning"), q.orTerms.toList())
|
||||||
|
assertTrue(q.text.isBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun orTermsWithOperators() {
|
||||||
|
val q = QueryParser.parse("from:vitor bitcoin OR lightning kind:note")
|
||||||
|
assertEquals(listOf("bitcoin", "lightning"), q.orTerms.toList())
|
||||||
|
assertEquals(listOf(1), q.kinds.toList())
|
||||||
|
// from:vitor → authorNames since it's not a valid npub
|
||||||
|
assertEquals(listOf("vitor"), q.authorNames.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun orTermsCappedAtThree() {
|
||||||
|
val q = QueryParser.parse("a OR b OR c OR d OR e")
|
||||||
|
assertEquals(3, q.orTerms.size)
|
||||||
|
assertEquals(listOf("a", "b", "c"), q.orTerms.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun orphanedOr() {
|
||||||
|
val q = QueryParser.parse("OR")
|
||||||
|
assertEquals("OR", q.text)
|
||||||
|
assertTrue(q.orTerms.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun languageOperator() {
|
||||||
|
val q = QueryParser.parse("lang:en bitcoin")
|
||||||
|
assertEquals("en", q.language)
|
||||||
|
assertEquals("bitcoin", q.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun domainOperator() {
|
||||||
|
val q = QueryParser.parse("domain:nostr.com bitcoin")
|
||||||
|
assertEquals("nostr.com", q.domain)
|
||||||
|
assertEquals("bitcoin", q.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun combinedQuery() {
|
||||||
|
val q = QueryParser.parse("kind:note since:2025-01-01 #bitcoin -spam lightning")
|
||||||
|
assertEquals(listOf(1), q.kinds.toList())
|
||||||
|
assertEquals(1735689600L, q.since)
|
||||||
|
assertEquals(listOf("bitcoin"), q.hashtags.toList())
|
||||||
|
assertEquals(listOf("spam"), q.excludeTerms.toList())
|
||||||
|
assertEquals("lightning", q.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun caseInsensitiveOperators() {
|
||||||
|
val q = QueryParser.parse("FROM:vitor KIND:Note")
|
||||||
|
assertEquals(listOf("vitor"), q.authorNames.toList())
|
||||||
|
assertEquals(listOf(1), q.kinds.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun operatorWithNoValue() {
|
||||||
|
val q = QueryParser.parse("from:")
|
||||||
|
// Malformed → treated as text
|
||||||
|
assertEquals("from:", q.text)
|
||||||
|
assertTrue(q.authors.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fromWithAuthorName() {
|
||||||
|
val q = QueryParser.parse("from:vitor")
|
||||||
|
assertEquals(listOf("vitor"), q.authorNames.toList())
|
||||||
|
assertTrue(q.authors.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleFromAuthors() {
|
||||||
|
val q = QueryParser.parse("from:alice from:bob")
|
||||||
|
assertEquals(listOf("alice", "bob"), q.authorNames.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun duplicateAuthorsDeduped() {
|
||||||
|
val q = QueryParser.parse("from:vitor from:vitor")
|
||||||
|
assertEquals(1, q.authorNames.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseDateToTimestamp_validDates() {
|
||||||
|
// 1970-01-01 = 0
|
||||||
|
assertEquals(0L, QueryParser.parseDateToTimestamp("1970-01-01"))
|
||||||
|
// 2000-01-01
|
||||||
|
assertEquals(946684800L, QueryParser.parseDateToTimestamp("2000-01-01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseDateToTimestamp_invalidDates() {
|
||||||
|
assertNull(QueryParser.parseDateToTimestamp("not-a-date"))
|
||||||
|
assertNull(QueryParser.parseDateToTimestamp("1800-01-01"))
|
||||||
|
assertNull(QueryParser.parseDateToTimestamp("2025-13-01"))
|
||||||
|
assertNull(QueryParser.parseDateToTimestamp("2025-01-32"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unicodeInFreeText() {
|
||||||
|
val q = QueryParser.parse("bitcoin 日本語 🚀")
|
||||||
|
assertFalse(q.isEmpty)
|
||||||
|
assertTrue(q.text.contains("日本語"))
|
||||||
|
assertTrue(q.text.contains("🚀"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun veryLongQuery() {
|
||||||
|
val longText = "word ".repeat(100).trim()
|
||||||
|
val q = QueryParser.parse(longText)
|
||||||
|
assertFalse(q.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class QuerySerializerTest {
|
||||||
|
@Test
|
||||||
|
fun emptyQuery() {
|
||||||
|
assertEquals("", QuerySerializer.serialize(SearchQuery.EMPTY))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun textOnly() {
|
||||||
|
val q = SearchQuery(text = "bitcoin")
|
||||||
|
assertEquals("bitcoin", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kindOnly() {
|
||||||
|
val q = SearchQuery(kinds = persistentListOf(1))
|
||||||
|
assertEquals("kind:note", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun kindUnknown() {
|
||||||
|
val q = SearchQuery(kinds = persistentListOf(99999))
|
||||||
|
assertEquals("kind:99999", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleKinds() {
|
||||||
|
val q = SearchQuery(kinds = persistentListOf(1, 30023))
|
||||||
|
assertEquals("kind:note kind:article", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pseudoKinds() {
|
||||||
|
val q = SearchQuery(pseudoKinds = persistentListOf("reply", "media"))
|
||||||
|
assertEquals("kind:reply kind:media", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sinceDate() {
|
||||||
|
val q = SearchQuery(since = 1735689600L) // 2025-01-01
|
||||||
|
assertEquals("since:2025-01-01", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun untilDate() {
|
||||||
|
val q = SearchQuery(until = 1735689600L)
|
||||||
|
assertEquals("until:2025-01-01", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hashtags() {
|
||||||
|
val q = SearchQuery(hashtags = persistentListOf("bitcoin", "nostr"))
|
||||||
|
assertEquals("#bitcoin #nostr", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun excludeTerms() {
|
||||||
|
val q = SearchQuery(excludeTerms = persistentListOf("spam", "scam"))
|
||||||
|
assertEquals("-spam -scam", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun orTerms() {
|
||||||
|
val q = SearchQuery(orTerms = persistentListOf("bitcoin", "lightning"))
|
||||||
|
assertEquals("bitcoin OR lightning", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun language() {
|
||||||
|
val q = SearchQuery(language = "en", text = "bitcoin")
|
||||||
|
assertEquals("lang:en bitcoin", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun domain() {
|
||||||
|
val q = SearchQuery(domain = "nostr.com", text = "hello")
|
||||||
|
assertEquals("domain:nostr.com hello", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun authorNames() {
|
||||||
|
val q = SearchQuery(authorNames = persistentListOf("vitor"))
|
||||||
|
assertEquals("from:vitor", QuerySerializer.serialize(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun combinedQuery() {
|
||||||
|
val q =
|
||||||
|
SearchQuery(
|
||||||
|
authorNames = persistentListOf("vitor"),
|
||||||
|
kinds = persistentListOf(1),
|
||||||
|
since = 1735689600L,
|
||||||
|
hashtags = persistentListOf("bitcoin"),
|
||||||
|
text = "lightning",
|
||||||
|
excludeTerms = persistentListOf("spam"),
|
||||||
|
)
|
||||||
|
val result = QuerySerializer.serialize(q)
|
||||||
|
assertTrue(result.contains("from:vitor"))
|
||||||
|
assertTrue(result.contains("kind:note"))
|
||||||
|
assertTrue(result.contains("since:2025-01-01"))
|
||||||
|
assertTrue(result.contains("#bitcoin"))
|
||||||
|
assertTrue(result.contains("lightning"))
|
||||||
|
assertTrue(result.contains("-spam"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun orderingOperatorsFirst() {
|
||||||
|
val q =
|
||||||
|
SearchQuery(
|
||||||
|
authorNames = persistentListOf("alice"),
|
||||||
|
kinds = persistentListOf(1),
|
||||||
|
hashtags = persistentListOf("nostr"),
|
||||||
|
text = "hello",
|
||||||
|
excludeTerms = persistentListOf("bad"),
|
||||||
|
)
|
||||||
|
val result = QuerySerializer.serialize(q)
|
||||||
|
val fromIdx = result.indexOf("from:")
|
||||||
|
val kindIdx = result.indexOf("kind:")
|
||||||
|
val hashIdx = result.indexOf("#nostr")
|
||||||
|
val textIdx = result.indexOf("hello")
|
||||||
|
val excludeIdx = result.indexOf("-bad")
|
||||||
|
assertTrue(fromIdx < kindIdx)
|
||||||
|
assertTrue(kindIdx < hashIdx)
|
||||||
|
assertTrue(hashIdx < textIdx)
|
||||||
|
assertTrue(textIdx < excludeIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun timestampToDateEpoch() {
|
||||||
|
assertEquals("1970-01-01", QuerySerializer.timestampToDate(0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun timestampToDate2025() {
|
||||||
|
assertEquals("2025-01-01", QuerySerializer.timestampToDate(1735689600L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roundtrip() {
|
||||||
|
// Parse a complex query, serialize, parse again — should produce equivalent SearchQuery
|
||||||
|
val input = "kind:note since:2025-01-01 #bitcoin lightning -spam"
|
||||||
|
val q1 = QueryParser.parse(input)
|
||||||
|
val serialized = QuerySerializer.serialize(q1)
|
||||||
|
val q2 = QueryParser.parse(serialized)
|
||||||
|
assertEquals(q1.kinds, q2.kinds)
|
||||||
|
assertEquals(q1.since, q2.since)
|
||||||
|
assertEquals(q1.hashtags, q2.hashtags)
|
||||||
|
assertEquals(q1.excludeTerms, q2.excludeTerms)
|
||||||
|
assertEquals(q1.text, q2.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SearchResultSorterTest {
|
||||||
|
private fun event(
|
||||||
|
id: String,
|
||||||
|
createdAt: Long,
|
||||||
|
content: String = "",
|
||||||
|
kind: Int = 1,
|
||||||
|
) = Event(
|
||||||
|
id = id,
|
||||||
|
pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
|
||||||
|
createdAt = createdAt,
|
||||||
|
kind = kind,
|
||||||
|
tags = emptyArray(),
|
||||||
|
content = content,
|
||||||
|
sig = "sig",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun article(
|
||||||
|
id: String,
|
||||||
|
createdAt: Long,
|
||||||
|
content: String = "",
|
||||||
|
title: String? = null,
|
||||||
|
): LongTextNoteEvent {
|
||||||
|
val tags =
|
||||||
|
if (title != null) {
|
||||||
|
arrayOf(arrayOf("title", title))
|
||||||
|
} else {
|
||||||
|
emptyArray()
|
||||||
|
}
|
||||||
|
return LongTextNoteEvent(
|
||||||
|
id = id,
|
||||||
|
pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
|
||||||
|
createdAt = createdAt,
|
||||||
|
tags = tags,
|
||||||
|
content = content,
|
||||||
|
sig = "sig",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun user(
|
||||||
|
hex: String,
|
||||||
|
displayName: String,
|
||||||
|
): User {
|
||||||
|
val u = User(hex, Note("r1-$hex"), Note("r2-$hex"))
|
||||||
|
val meta = UserMetadata().apply { this.displayName = displayName }
|
||||||
|
val metaEvent =
|
||||||
|
MetadataEvent(
|
||||||
|
id = "meta-$hex",
|
||||||
|
pubKey = hex,
|
||||||
|
createdAt = 0L,
|
||||||
|
tags = emptyArray(),
|
||||||
|
content = "{}",
|
||||||
|
sig = "sig",
|
||||||
|
)
|
||||||
|
u.updateUserInfo(meta, metaEvent)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event sorting ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newestSortsDescending() {
|
||||||
|
val events = listOf(event("a", 100), event("b", 300), event("c", 200))
|
||||||
|
val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.NEWEST, "")
|
||||||
|
assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oldestSortsAscending() {
|
||||||
|
val events = listOf(event("a", 300), event("b", 100), event("c", 200))
|
||||||
|
val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.OLDEST, "")
|
||||||
|
assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relevanceEmptyQueryFallsBackToRecency() {
|
||||||
|
val events = listOf(event("a", 100), event("b", 300), event("c", 200))
|
||||||
|
val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.RELEVANCE, "")
|
||||||
|
assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relevanceExactMatchBeatsPartial() {
|
||||||
|
val exact = event("exact", 100, content = "bitcoin is great")
|
||||||
|
val partial = event("partial", 100, content = "bit of something")
|
||||||
|
val sorted = SearchResultSorter.sortEvents(listOf(partial, exact), SearchSortOrder.RELEVANCE, "bitcoin")
|
||||||
|
assertEquals("exact", sorted.first().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relevanceWordBoundaryBeatsSubstring() {
|
||||||
|
val boundary = event("boundary", 100, content = "I love bitcoin and lightning")
|
||||||
|
val substring = event("substr", 100, content = "bitcoinery is not a word")
|
||||||
|
val sorted = SearchResultSorter.sortEvents(listOf(substring, boundary), SearchSortOrder.RELEVANCE, "bitcoin")
|
||||||
|
assertEquals("boundary", sorted.first().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relevanceArticleTitleBoost() {
|
||||||
|
val withTitle = article("titled", 100, content = "some content", title = "Bitcoin Guide")
|
||||||
|
val withoutTitle = event("notitle", 100, content = "bitcoin bitcoin bitcoin")
|
||||||
|
val sorted = SearchResultSorter.sortEvents(listOf(withoutTitle, withTitle), SearchSortOrder.RELEVANCE, "bitcoin")
|
||||||
|
assertEquals("titled", sorted.first().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relevanceMultipleTokensAddUp() {
|
||||||
|
val multi = event("multi", 100, content = "bitcoin and lightning network")
|
||||||
|
val single = event("single", 100, content = "bitcoin only here")
|
||||||
|
val sorted =
|
||||||
|
SearchResultSorter.sortEvents(
|
||||||
|
listOf(single, multi),
|
||||||
|
SearchSortOrder.RELEVANCE,
|
||||||
|
"bitcoin lightning",
|
||||||
|
)
|
||||||
|
assertEquals("multi", sorted.first().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- People sorting ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nameAzSortsAlphabetically() {
|
||||||
|
val people =
|
||||||
|
listOf(
|
||||||
|
user("cc00000000000000000000000000000000000000000000000000000000000000", "Charlie"),
|
||||||
|
user("aa00000000000000000000000000000000000000000000000000000000000000", "Alice"),
|
||||||
|
user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"),
|
||||||
|
)
|
||||||
|
val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_AZ)
|
||||||
|
assertEquals(listOf("Alice", "Bob", "Charlie"), sorted.map { it.toBestDisplayName() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nameZaSortsReverseAlphabetically() {
|
||||||
|
val people =
|
||||||
|
listOf(
|
||||||
|
user("aa00000000000000000000000000000000000000000000000000000000000000", "Alice"),
|
||||||
|
user("cc00000000000000000000000000000000000000000000000000000000000000", "Charlie"),
|
||||||
|
user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"),
|
||||||
|
)
|
||||||
|
val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_ZA)
|
||||||
|
assertEquals(listOf("Charlie", "Bob", "Alice"), sorted.map { it.toBestDisplayName() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nameSortIsCaseInsensitive() {
|
||||||
|
val people =
|
||||||
|
listOf(
|
||||||
|
user("aa00000000000000000000000000000000000000000000000000000000000000", "alice"),
|
||||||
|
user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"),
|
||||||
|
)
|
||||||
|
val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_AZ)
|
||||||
|
assertEquals("alice", sorted.first().toBestDisplayName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Score function ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scoreExactPhraseHigherThanTokens() {
|
||||||
|
val exactEvent = event("e", 100, content = "bitcoin lightning network")
|
||||||
|
val tokenEvent = event("t", 100, content = "lightning and bitcoin elsewhere network")
|
||||||
|
val exactScore = SearchResultSorter.scoreEvent(exactEvent, "bitcoin lightning")
|
||||||
|
val tokenScore = SearchResultSorter.scoreEvent(tokenEvent, "bitcoin lightning")
|
||||||
|
assertTrue(exactScore > tokenScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 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.desktop
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.QueryParser
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SavedSearch
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
|
object SearchHistoryStore {
|
||||||
|
private val prefs: Preferences = Preferences.userNodeForPackage(SearchHistoryStore::class.java)
|
||||||
|
|
||||||
|
private const val KEY_HISTORY = "search_history"
|
||||||
|
private const val KEY_SAVED = "saved_searches"
|
||||||
|
private const val SEPARATOR = "\n"
|
||||||
|
private const val SAVED_SEPARATOR = "\t"
|
||||||
|
private const val MAX_HISTORY = 20
|
||||||
|
|
||||||
|
private val _history = MutableStateFlow<List<SearchQuery>>(emptyList())
|
||||||
|
val history: StateFlow<List<SearchQuery>> = _history.asStateFlow()
|
||||||
|
|
||||||
|
private val _savedSearches = MutableStateFlow<List<SavedSearch>>(emptyList())
|
||||||
|
val savedSearches: StateFlow<List<SavedSearch>> = _savedSearches.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
_history.value = loadHistory()
|
||||||
|
_savedSearches.value = loadSaved()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addToHistory(query: SearchQuery) {
|
||||||
|
if (query.isEmpty) return
|
||||||
|
val serialized = QuerySerializer.serialize(query)
|
||||||
|
val current = _history.value.toMutableList()
|
||||||
|
current.removeAll { QuerySerializer.serialize(it) == serialized }
|
||||||
|
current.add(0, query)
|
||||||
|
if (current.size > MAX_HISTORY) {
|
||||||
|
current.subList(MAX_HISTORY, current.size).clear()
|
||||||
|
}
|
||||||
|
_history.value = current.toList()
|
||||||
|
persistHistory(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearHistory() {
|
||||||
|
_history.value = emptyList()
|
||||||
|
prefs.remove(KEY_HISTORY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSearch(
|
||||||
|
query: SearchQuery,
|
||||||
|
label: String,
|
||||||
|
) {
|
||||||
|
if (query.isEmpty) return
|
||||||
|
val saved =
|
||||||
|
SavedSearch(
|
||||||
|
id = System.currentTimeMillis().toString(),
|
||||||
|
label = label,
|
||||||
|
query = query,
|
||||||
|
createdAt = System.currentTimeMillis() / 1000,
|
||||||
|
)
|
||||||
|
val current = _savedSearches.value + saved
|
||||||
|
_savedSearches.value = current
|
||||||
|
persistSaved(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSavedSearch(id: String) {
|
||||||
|
val current = _savedSearches.value.filter { it.id != id }
|
||||||
|
_savedSearches.value = current
|
||||||
|
persistSaved(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadHistory(): List<SearchQuery> {
|
||||||
|
val raw = prefs.get(KEY_HISTORY, "")
|
||||||
|
if (raw.isBlank()) return emptyList()
|
||||||
|
return raw
|
||||||
|
.split(SEPARATOR)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.mapNotNull { line ->
|
||||||
|
val parsed = QueryParser.parse(line)
|
||||||
|
if (parsed.isEmpty) null else parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistHistory(queries: List<SearchQuery>) {
|
||||||
|
val raw = queries.joinToString(SEPARATOR) { QuerySerializer.serialize(it) }
|
||||||
|
prefs.put(KEY_HISTORY, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSaved(): List<SavedSearch> {
|
||||||
|
val raw = prefs.get(KEY_SAVED, "")
|
||||||
|
if (raw.isBlank()) return emptyList()
|
||||||
|
return raw
|
||||||
|
.split(SEPARATOR)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.mapNotNull { line ->
|
||||||
|
val parts = line.split(SAVED_SEPARATOR)
|
||||||
|
if (parts.size < 4) return@mapNotNull null
|
||||||
|
val id = parts[0]
|
||||||
|
val label = parts[1]
|
||||||
|
val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val queryText = parts[3]
|
||||||
|
val query = QueryParser.parse(queryText)
|
||||||
|
if (query.isEmpty) return@mapNotNull null
|
||||||
|
SavedSearch(id = id, label = label, query = query, createdAt = createdAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistSaved(searches: List<SavedSearch>) {
|
||||||
|
val raw =
|
||||||
|
searches.joinToString(SEPARATOR) { s ->
|
||||||
|
listOf(s.id, s.label, s.createdAt.toString(), QuerySerializer.serialize(s.query))
|
||||||
|
.joinToString(SAVED_SEPARATOR)
|
||||||
|
}
|
||||||
|
prefs.put(KEY_SAVED, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -45,6 +45,7 @@ object DefaultRelays {
|
|||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
"wss://relay.snort.social",
|
"wss://relay.snort.social",
|
||||||
"wss://nostr.wine",
|
"wss://nostr.wine",
|
||||||
|
"wss://relay.noswhere.com",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -145,6 +145,7 @@ fun createSearchPeopleSubscription(
|
|||||||
limit: Int = 50,
|
limit: Int = 50,
|
||||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
onClosed: (NormalizedRelayUrl, String, List<Filter>?) -> Unit = { _, _, _ -> },
|
||||||
): SubscriptionConfig? {
|
): SubscriptionConfig? {
|
||||||
if (searchQuery.isBlank()) return null
|
if (searchQuery.isBlank()) return null
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ fun createSearchPeopleSubscription(
|
|||||||
relays = relays,
|
relays = relays,
|
||||||
onEvent = onEvent,
|
onEvent = onEvent,
|
||||||
onEose = onEose,
|
onEose = onEose,
|
||||||
|
onClosed = onClosed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* 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.desktop.subscriptions
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||||
|
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||||
|
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||||
|
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||||
|
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
|
||||||
|
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||||
|
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||||
|
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
|
||||||
|
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||||
|
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
|
||||||
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||||
|
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||||
|
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||||
|
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||||
|
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||||
|
|
||||||
|
object SearchFilterFactory {
|
||||||
|
// Default kind groups (ported from Android SearchPostsByText)
|
||||||
|
private val defaultKindGroup1 =
|
||||||
|
listOf(
|
||||||
|
TextNoteEvent.KIND,
|
||||||
|
LongTextNoteEvent.KIND,
|
||||||
|
BadgeDefinitionEvent.KIND,
|
||||||
|
PeopleListEvent.KIND,
|
||||||
|
BookmarkListEvent.KIND,
|
||||||
|
AudioHeaderEvent.KIND,
|
||||||
|
AudioTrackEvent.KIND,
|
||||||
|
PinListEvent.KIND,
|
||||||
|
PollNoteEvent.KIND,
|
||||||
|
ChannelCreateEvent.KIND,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val defaultKindGroup2 =
|
||||||
|
listOf(
|
||||||
|
ChannelMetadataEvent.KIND,
|
||||||
|
ClassifiedsEvent.KIND,
|
||||||
|
CommunityDefinitionEvent.KIND,
|
||||||
|
EmojiPackEvent.KIND,
|
||||||
|
HighlightEvent.KIND,
|
||||||
|
LiveActivitiesEvent.KIND,
|
||||||
|
PublicMessageEvent.KIND,
|
||||||
|
NNSEvent.KIND,
|
||||||
|
WikiNoteEvent.KIND,
|
||||||
|
CommentEvent.KIND,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val defaultKindGroup3 =
|
||||||
|
listOf(
|
||||||
|
InteractiveStoryPrologueEvent.KIND,
|
||||||
|
InteractiveStorySceneEvent.KIND,
|
||||||
|
FollowListEvent.KIND,
|
||||||
|
NipTextEvent.KIND,
|
||||||
|
PollEvent.KIND,
|
||||||
|
PollResponseEvent.KIND,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createFilters(
|
||||||
|
query: SearchQuery,
|
||||||
|
limit: Int = 100,
|
||||||
|
): List<Filter> {
|
||||||
|
if (query.isEmpty) return emptyList()
|
||||||
|
|
||||||
|
val searchString = buildSearchString(query)
|
||||||
|
val tags = buildTags(query)
|
||||||
|
val authors = query.authors.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
if (query.kinds.isNotEmpty()) {
|
||||||
|
// User specified kinds — single filter (no group splitting needed)
|
||||||
|
return listOf(
|
||||||
|
Filter(
|
||||||
|
kinds = query.kinds.toList(),
|
||||||
|
search = searchString,
|
||||||
|
authors = authors,
|
||||||
|
tags = tags,
|
||||||
|
since = query.since,
|
||||||
|
until = query.until,
|
||||||
|
limit = limit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No kinds specified — use default 3-group search (Android parity)
|
||||||
|
return listOf(defaultKindGroup1, defaultKindGroup2, defaultKindGroup3).map { kindGroup ->
|
||||||
|
Filter(
|
||||||
|
kinds = kindGroup,
|
||||||
|
search = searchString,
|
||||||
|
authors = authors,
|
||||||
|
tags = tags,
|
||||||
|
since = query.since,
|
||||||
|
until = query.until,
|
||||||
|
limit = limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSearchString(query: SearchQuery): String? {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
|
||||||
|
// Free text (exclude negation terms — those are client-side only)
|
||||||
|
if (query.text.isNotBlank()) {
|
||||||
|
parts.add(query.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP-50 inline extensions
|
||||||
|
query.language?.let { parts.add("language:$it") }
|
||||||
|
query.domain?.let { parts.add("domain:$it") }
|
||||||
|
|
||||||
|
return parts.joinToString(" ").takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTags(query: SearchQuery): Map<String, List<String>>? {
|
||||||
|
if (query.hashtags.isEmpty()) return null
|
||||||
|
return mapOf("t" to query.hashtags.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -46,6 +46,7 @@ data class SubscriptionConfig(
|
|||||||
val relays: Set<NormalizedRelayUrl>,
|
val relays: Set<NormalizedRelayUrl>,
|
||||||
val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
val onClosed: (NormalizedRelayUrl, String, List<Filter>?) -> Unit = { _, _, _ -> },
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,6 +96,14 @@ fun rememberSubscription(
|
|||||||
) {
|
) {
|
||||||
cfg.onEose(relay, forFilters)
|
cfg.onEose(relay, forFilters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
message: String,
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
cfg.onClosed(relay, message, forFilters)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+456
-223
@@ -20,6 +20,11 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui
|
package com.vitorpamplona.amethyst.desktop.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -37,39 +42,68 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Description
|
import androidx.compose.material.icons.filled.Description
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.filled.Tag
|
import androidx.compose.material.icons.filled.Tag
|
||||||
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
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
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.User
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SavedSearch
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||||
import com.vitorpamplona.amethyst.commons.search.SearchResult
|
import com.vitorpamplona.amethyst.commons.search.SearchResult
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
|
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState
|
import com.vitorpamplona.amethyst.commons.search.parseSearchInput
|
||||||
|
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||||
|
import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
|
||||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||||
|
|
||||||
@@ -85,61 +119,140 @@ fun SearchScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val searchState = remember { SearchBarState(localCache, scope) }
|
val state = remember { AdvancedSearchBarState(scope) }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
// Pre-fill initial query (e.g., hashtag column)
|
// Pre-fill initial query
|
||||||
LaunchedEffect(initialQuery) {
|
LaunchedEffect(initialQuery) {
|
||||||
if (initialQuery.isNotBlank()) {
|
if (initialQuery.isNotBlank()) {
|
||||||
searchState.updateSearchText(initialQuery)
|
state.updateFromText(initialQuery)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||||
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
|
val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
|
||||||
|
val displayText by state.displayText.collectAsState()
|
||||||
|
// Track TextFieldValue locally to preserve cursor position
|
||||||
|
var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) }
|
||||||
|
// Sync from flow only when text changes externally (form-driven updates)
|
||||||
|
LaunchedEffect(displayText) {
|
||||||
|
if (textFieldValue.text != displayText) {
|
||||||
|
textFieldValue = TextFieldValue(text = displayText, selection = TextRange(displayText.length))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val query by state.query.collectAsState()
|
||||||
|
val debouncedQuery by state.debouncedQuery.collectAsState()
|
||||||
|
val panelExpanded by state.panelExpanded.collectAsState()
|
||||||
|
val isSearching by state.isSearching.collectAsState()
|
||||||
|
val peopleResults by state.peopleResults.collectAsState()
|
||||||
|
val noteResults by state.noteResults.collectAsState()
|
||||||
|
val relayStates by state.relayStates.collectAsState()
|
||||||
|
|
||||||
// Collect state from SearchBarState
|
// Bech32 parsing (immediate, no debounce)
|
||||||
val searchText by searchState.searchText.collectAsState()
|
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
|
||||||
val bech32Results by searchState.bech32Results.collectAsState()
|
|
||||||
val cachedUserResults by searchState.cachedUserResults.collectAsState()
|
|
||||||
val relaySearchResults by searchState.relaySearchResults.collectAsState()
|
|
||||||
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
|
||||||
|
|
||||||
// NIP-50 relay search when local cache has few/no results
|
// Skip people search when query specifies kinds that don't include profile (kind 0)
|
||||||
rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) {
|
val shouldSearchPeople =
|
||||||
if (connectedRelays.isEmpty()) return@rememberSubscription null
|
(debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
|
||||||
|
debouncedQuery.kinds.contains(MetadataEvent.KIND)
|
||||||
|
|
||||||
// Only search relays if we have a real query and limited local results
|
// Clear results and start loading when query changes
|
||||||
if (searchState.shouldSearchRelays) {
|
LaunchedEffect(debouncedQuery) {
|
||||||
searchState.startRelaySearch()
|
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
|
||||||
createSearchPeopleSubscription(
|
state.clearResults()
|
||||||
relays = connectedRelays,
|
state.initRelayStates(allRelayUrls)
|
||||||
searchQuery = searchText,
|
if (shouldSearchPeople) {
|
||||||
limit = 20,
|
state.startSearching("people-search")
|
||||||
onEvent = { event, _, _, _ ->
|
}
|
||||||
if (event is MetadataEvent) {
|
state.startSearching("adv-search")
|
||||||
localCache.consumeMetadata(event)
|
// Timeout relays that silently ignore NIP-50 (e.g. strfry)
|
||||||
val user = localCache.getUserIfExists(event.pubKey)
|
kotlinx.coroutines.delay(10_000L)
|
||||||
if (user != null) {
|
state.timeoutWaitingRelays()
|
||||||
searchState.addRelaySearchResult(user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onEose = { _, _ ->
|
|
||||||
searchState.endRelaySearch()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to metadata for searched users (to populate cache)
|
// NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect)
|
||||||
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
|
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
||||||
if (connectedRelays.isEmpty() || searchText.length < 2) {
|
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
|
||||||
|
return@rememberSubscription null
|
||||||
|
}
|
||||||
|
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
||||||
|
if (!shouldSearchPeople) {
|
||||||
|
state.stopSearching("people-search")
|
||||||
return@rememberSubscription null
|
return@rememberSubscription null
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a specific pubkey search, fetch that user's metadata
|
createSearchPeopleSubscription(
|
||||||
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
relays = allRelayUrls,
|
||||||
|
searchQuery =
|
||||||
|
debouncedQuery.text.ifBlank {
|
||||||
|
QuerySerializer.serialize(debouncedQuery)
|
||||||
|
},
|
||||||
|
limit = 20,
|
||||||
|
onEvent = { event, _, relay, _ ->
|
||||||
|
if (state.trackRelayEvent(relay.url, event.id)) {
|
||||||
|
if (event is MetadataEvent) {
|
||||||
|
localCache.consumeMetadata(event)
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val user = localCache.getUserIfExists(event.pubKey) as? User
|
||||||
|
if (user != null) {
|
||||||
|
state.addPeopleResult(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEose = { relay, _ ->
|
||||||
|
state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
|
||||||
|
state.stopSearching("people-search")
|
||||||
|
},
|
||||||
|
onClosed = { relay, _, _ ->
|
||||||
|
state.updateRelayState(relay.url, RelaySyncStatus.FAILED)
|
||||||
|
state.stopSearching("people-search")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP-50 advanced note search subscription (use allRelayUrls)
|
||||||
|
rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
|
||||||
|
if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
|
||||||
|
return@rememberSubscription null
|
||||||
|
}
|
||||||
|
if (bech32Results.isNotEmpty()) return@rememberSubscription null
|
||||||
|
|
||||||
|
val filters = SearchFilterFactory.createFilters(debouncedQuery)
|
||||||
|
if (filters.isEmpty()) return@rememberSubscription null
|
||||||
|
|
||||||
|
SubscriptionConfig(
|
||||||
|
subId = generateSubId("adv-search"),
|
||||||
|
filters = filters,
|
||||||
|
relays = allRelayUrls,
|
||||||
|
onEvent = { event, _, relay, _ ->
|
||||||
|
if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
|
||||||
|
if (state.trackRelayEvent(relay.url, event.id)) {
|
||||||
|
val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery)
|
||||||
|
if (filtered.isNotEmpty()) {
|
||||||
|
state.addNoteResults(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEose = { relay, _ ->
|
||||||
|
state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
|
||||||
|
state.stopSearching("adv-search")
|
||||||
|
},
|
||||||
|
onClosed = { relay, _, _ ->
|
||||||
|
state.updateRelayState(relay.url, RelaySyncStatus.FAILED)
|
||||||
|
state.stopSearching("adv-search")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata subscription for bech32 pubkey lookups
|
||||||
|
rememberSubscription(connectedRelays, displayText, relayManager = relayManager) {
|
||||||
|
if (connectedRelays.isEmpty() || displayText.length < 2) {
|
||||||
|
return@rememberSubscription null
|
||||||
|
}
|
||||||
|
val pubkeyHex = decodePublicKeyAsHexOrNull(displayText)
|
||||||
if (pubkeyHex != null) {
|
if (pubkeyHex != null) {
|
||||||
createMetadataSubscription(
|
createMetadataSubscription(
|
||||||
relays = connectedRelays,
|
relays = connectedRelays,
|
||||||
@@ -155,14 +268,67 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-focus the search field
|
// Save to history when search completes (snapshotFlow avoids LaunchedEffect race)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { isSearching to debouncedQuery }
|
||||||
|
.collect { (searching, query) ->
|
||||||
|
if (!searching && !query.isEmpty) {
|
||||||
|
SearchHistoryStore.addToHistory(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// History state
|
||||||
|
val historyItems by SearchHistoryStore.history.collectAsState()
|
||||||
|
val savedSearches by SearchHistoryStore.savedSearches.collectAsState()
|
||||||
|
|
||||||
|
// Auto-focus
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
|
when (event.key) {
|
||||||
|
Key.Escape -> {
|
||||||
|
if (panelExpanded) {
|
||||||
|
state.togglePanel()
|
||||||
|
} else if (displayText.isNotEmpty()) {
|
||||||
|
state.clearSearch()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
|
// Progress bar at very top
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isSearching,
|
||||||
|
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||||
|
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay status banner
|
||||||
|
SearchSyncBanner(
|
||||||
|
relayStates = relayStates,
|
||||||
|
isSearching = isSearching,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Title row
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -182,180 +348,265 @@ fun SearchScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Search input field
|
// Search bar with advanced toggle
|
||||||
OutlinedTextField(
|
Row(
|
||||||
value = searchText,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onValueChange = { searchState.updateSearchText(it) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
Modifier
|
) {
|
||||||
.fillMaxWidth()
|
OutlinedTextField(
|
||||||
.focusRequester(focusRequester),
|
value = textFieldValue,
|
||||||
placeholder = { Text("Search by name, npub, nevent, or #hashtag") },
|
onValueChange = {
|
||||||
leadingIcon = {
|
textFieldValue = it
|
||||||
Icon(
|
state.updateFromText(it.text)
|
||||||
Icons.Default.Search,
|
},
|
||||||
contentDescription = "Search",
|
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
placeholder = { Text("Search notes, people, tags... or use operators") },
|
||||||
)
|
leadingIcon = {
|
||||||
},
|
Icon(
|
||||||
trailingIcon = {
|
Icons.Default.Search,
|
||||||
if (searchText.isNotEmpty()) {
|
contentDescription = "Search",
|
||||||
IconButton(onClick = { searchState.clearSearch() }) {
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
Icon(
|
)
|
||||||
Icons.Default.Clear,
|
},
|
||||||
contentDescription = "Clear",
|
trailingIcon = {
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
if (displayText.isNotEmpty()) {
|
||||||
)
|
IconButton(onClick = { state.clearSearch() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Clear,
|
||||||
|
contentDescription = "Clear",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
singleLine = true,
|
||||||
singleLine = true,
|
shape = RoundedCornerShape(12.dp),
|
||||||
shape = RoundedCornerShape(12.dp),
|
)
|
||||||
)
|
IconButton(onClick = { state.togglePanel() }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Tune,
|
||||||
|
contentDescription = "Advanced Search",
|
||||||
|
tint =
|
||||||
|
if (panelExpanded) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expandable advanced panel
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = panelExpanded,
|
||||||
|
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||||
|
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||||
|
) {
|
||||||
|
AdvancedSearchPanel(
|
||||||
|
query = query,
|
||||||
|
onKindsChanged = { state.updateKinds(it) },
|
||||||
|
onPseudoKindsChanged = { state.updatePseudoKinds(it) },
|
||||||
|
onAuthorAdded = { state.addAuthor(it) },
|
||||||
|
onAuthorRemoved = { state.removeAuthor(it) },
|
||||||
|
onDateRangeChanged = { since, until -> state.updateDateRange(since, until) },
|
||||||
|
onHashtagAdded = { state.addHashtag(it) },
|
||||||
|
onHashtagRemoved = { state.removeHashtag(it) },
|
||||||
|
onExcludeAdded = { state.addExcludeTerm(it) },
|
||||||
|
onExcludeRemoved = { state.removeExcludeTerm(it) },
|
||||||
|
onLanguageChanged = { state.updateLanguage(it) },
|
||||||
|
onClear = { state.clearSearch() },
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty()
|
val hasAnyResults =
|
||||||
|
bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty()
|
||||||
|
|
||||||
if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) {
|
if (bech32Results.isNotEmpty()) {
|
||||||
|
// Show bech32 results (exact lookup)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"Direct lookup",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
bech32Results.forEach { result ->
|
||||||
|
SearchResultCard(
|
||||||
|
result = result,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
onNavigateToHashtag = onNavigateToHashtag,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (hasAnyResults) {
|
||||||
|
SearchResultsList(
|
||||||
|
state = state,
|
||||||
|
onNavigateToProfile = onNavigateToProfile,
|
||||||
|
onNavigateToThread = onNavigateToThread,
|
||||||
|
)
|
||||||
|
} else if (!debouncedQuery.isEmpty && !isSearching) {
|
||||||
Text(
|
Text(
|
||||||
"No matches found. Try a name, npub, nevent, or #hashtag.",
|
"No results found. Try broader terms or fewer filters.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
} else if (isSearchingRelays && !hasResults) {
|
} else if (!isSearching) {
|
||||||
Text(
|
// Empty state: show history + saved searches + operator hints
|
||||||
"Searching relays...",
|
SearchEmptyState(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
historyItems = historyItems,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
savedSearches = savedSearches,
|
||||||
|
onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) },
|
||||||
|
onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) },
|
||||||
|
onClearHistory = { SearchHistoryStore.clearHistory() },
|
||||||
)
|
)
|
||||||
} else if (hasResults) {
|
|
||||||
LazyColumn(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
// Bech32/hex results first
|
|
||||||
if (bech32Results.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Direct lookup",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(bech32Results) { result ->
|
|
||||||
SearchResultCard(
|
|
||||||
result = result,
|
|
||||||
onNavigateToProfile = onNavigateToProfile,
|
|
||||||
onNavigateToThread = onNavigateToThread,
|
|
||||||
onNavigateToHashtag = onNavigateToHashtag,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached user results
|
|
||||||
if (cachedUserResults.isNotEmpty()) {
|
|
||||||
if (bech32Results.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Cached users (${cachedUserResults.size})",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user ->
|
|
||||||
UserSearchCard(
|
|
||||||
user = user,
|
|
||||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relay search results (NIP-50)
|
|
||||||
if (relaySearchResults.isNotEmpty()) {
|
|
||||||
if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"From relays (${relaySearchResults.size})",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
)
|
|
||||||
if (isSearchingRelays) {
|
|
||||||
Text(
|
|
||||||
"searching...",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user ->
|
|
||||||
UserSearchCard(
|
|
||||||
user = user,
|
|
||||||
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (isSearchingRelays && cachedUserResults.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Searching relays...",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Empty state
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Search for users or notes",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
"Enter a name or Nostr identifier:",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
SearchHint("vitor", "Search by name")
|
|
||||||
SearchHint("npub1...", "User profile")
|
|
||||||
SearchHint("note1...", "Single note")
|
|
||||||
SearchHint("nevent1...", "Note with metadata")
|
|
||||||
SearchHint("#hashtag", "Hashtag search")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchEmptyState(
|
||||||
|
historyItems: List<SearchQuery>,
|
||||||
|
savedSearches: List<SavedSearch>,
|
||||||
|
onLoadQuery: (SearchQuery) -> Unit,
|
||||||
|
onDeleteSaved: (String) -> Unit,
|
||||||
|
onClearHistory: () -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
// Saved searches
|
||||||
|
if (savedSearches.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Saved Searches",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(savedSearches, key = { "saved-${it.id}" }) { saved ->
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onLoadQuery(saved.query) }
|
||||||
|
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Star,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
saved.label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
QuerySerializer.serialize(saved.query),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { onDeleteSaved(saved.id) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = "Delete",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent history
|
||||||
|
if (historyItems.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Recent",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
TextButton(onClick = onClearHistory) {
|
||||||
|
Text("Clear", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(historyItems.take(10), key = { "history-${QuerySerializer.serialize(it)}" }) { query ->
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onLoadQuery(query) }
|
||||||
|
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.History,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
QuerySerializer.serialize(query),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator hints
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = if (historyItems.isEmpty() && savedSearches.isEmpty()) 32.dp else 8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Search operators",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { SearchHint("from:npub1...", "Filter by author") }
|
||||||
|
item { SearchHint("kind:article", "Long-form content") }
|
||||||
|
item { SearchHint("since:2025-01", "After January 2025") }
|
||||||
|
item { SearchHint("#bitcoin", "Hashtag search") }
|
||||||
|
item { SearchHint("\"exact phrase\"", "Exact match") }
|
||||||
|
item { SearchHint("bitcoin OR nostr", "Either term") }
|
||||||
|
item { SearchHint("-spam", "Exclude term") }
|
||||||
|
item { SearchHint("lang:en", "Language filter") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchHint(
|
private fun SearchHint(
|
||||||
identifier: String,
|
example: String,
|
||||||
description: String,
|
description: String,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -363,7 +614,7 @@ private fun SearchHint(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
identifier,
|
example,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
@@ -390,25 +641,10 @@ private fun SearchResultCard(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
when (result) {
|
when (result) {
|
||||||
is SearchResult.UserResult -> {
|
is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex)
|
||||||
onNavigateToProfile(result.pubKeyHex)
|
is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex)
|
||||||
}
|
is SearchResult.AddressResult -> onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
|
||||||
|
is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag)
|
||||||
is SearchResult.CachedUserResult -> {
|
|
||||||
onNavigateToProfile(result.user.pubkeyHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SearchResult.NoteResult -> {
|
|
||||||
onNavigateToThread(result.noteIdHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
is SearchResult.AddressResult -> {
|
|
||||||
onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
|
|
||||||
}
|
|
||||||
|
|
||||||
is SearchResult.HashtagResult -> {
|
|
||||||
onNavigateToHashtag(result.hashtag)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors =
|
colors =
|
||||||
@@ -425,7 +661,6 @@ private fun SearchResultCard(
|
|||||||
imageVector =
|
imageVector =
|
||||||
when (result) {
|
when (result) {
|
||||||
is SearchResult.UserResult -> Icons.Default.Person
|
is SearchResult.UserResult -> Icons.Default.Person
|
||||||
is SearchResult.CachedUserResult -> Icons.Default.Person
|
|
||||||
is SearchResult.NoteResult -> Icons.Default.Description
|
is SearchResult.NoteResult -> Icons.Default.Description
|
||||||
is SearchResult.AddressResult -> Icons.Default.Description
|
is SearchResult.AddressResult -> Icons.Default.Description
|
||||||
is SearchResult.HashtagResult -> Icons.Default.Tag
|
is SearchResult.HashtagResult -> Icons.Default.Tag
|
||||||
@@ -439,7 +674,6 @@ private fun SearchResultCard(
|
|||||||
Text(
|
Text(
|
||||||
when (result) {
|
when (result) {
|
||||||
is SearchResult.UserResult -> "User Profile"
|
is SearchResult.UserResult -> "User Profile"
|
||||||
is SearchResult.CachedUserResult -> result.user.toBestDisplayName()
|
|
||||||
is SearchResult.NoteResult -> "Note"
|
is SearchResult.NoteResult -> "Note"
|
||||||
is SearchResult.AddressResult -> "Event (kind ${result.kind})"
|
is SearchResult.AddressResult -> "Event (kind ${result.kind})"
|
||||||
is SearchResult.HashtagResult -> "#${result.hashtag}"
|
is SearchResult.HashtagResult -> "#${result.hashtag}"
|
||||||
@@ -450,7 +684,6 @@ private fun SearchResultCard(
|
|||||||
Text(
|
Text(
|
||||||
when (result) {
|
when (result) {
|
||||||
is SearchResult.UserResult -> result.displayId
|
is SearchResult.UserResult -> result.displayId
|
||||||
is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex()
|
|
||||||
is SearchResult.NoteResult -> result.displayId
|
is SearchResult.NoteResult -> result.displayId
|
||||||
is SearchResult.AddressResult -> result.displayId
|
is SearchResult.AddressResult -> result.displayId
|
||||||
is SearchResult.HashtagResult -> "Search posts with this hashtag"
|
is SearchResult.HashtagResult -> "Search posts with this hashtag"
|
||||||
|
|||||||
+37
-30
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -120,37 +122,42 @@ fun DeckColumnContainer(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||||
) {
|
) {
|
||||||
|
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||||
|
RootContent(
|
||||||
|
columnType = column.type,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
accountManager = accountManager,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
appScope = appScope,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
|
)
|
||||||
if (currentOverlay != null) {
|
if (currentOverlay != null) {
|
||||||
OverlayContent(
|
Surface(
|
||||||
screen = currentOverlay,
|
color = MaterialTheme.colorScheme.background,
|
||||||
relayManager = relayManager,
|
modifier = Modifier.fillMaxSize(),
|
||||||
localCache = localCache,
|
) {
|
||||||
account = account,
|
OverlayContent(
|
||||||
nwcConnection = nwcConnection,
|
screen = currentOverlay,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
relayManager = relayManager,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
localCache = localCache,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
account = account,
|
||||||
onZapFeedback = onZapFeedback,
|
nwcConnection = nwcConnection,
|
||||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onBack = { navState.pop() },
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
)
|
onZapFeedback = onZapFeedback,
|
||||||
} else {
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
RootContent(
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
columnType = column.type,
|
onBack = { navState.pop() },
|
||||||
relayManager = relayManager,
|
)
|
||||||
localCache = localCache,
|
}
|
||||||
accountManager = accountManager,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
|
||||||
appScope = appScope,
|
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
|
||||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-85
@@ -20,20 +20,16 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.ui.deck
|
package com.vitorpamplona.amethyst.desktop.ui.deck
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.Article
|
import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
import androidx.compose.material.icons.filled.Bookmark
|
import androidx.compose.material.icons.filled.Bookmark
|
||||||
import androidx.compose.material.icons.filled.Email
|
import androidx.compose.material.icons.filled.Email
|
||||||
@@ -43,12 +39,11 @@ import androidx.compose.material.icons.filled.Notifications
|
|||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationRail
|
import androidx.compose.material3.NavigationRail
|
||||||
import androidx.compose.material3.NavigationRailItem
|
import androidx.compose.material3.NavigationRailItem
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -57,7 +52,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -156,90 +150,47 @@ fun SinglePaneLayout(
|
|||||||
VerticalDivider()
|
VerticalDivider()
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||||
// Show header with back button when navigated into overlay
|
|
||||||
if (navStack.isNotEmpty()) {
|
|
||||||
SinglePaneHeader(
|
|
||||||
title =
|
|
||||||
when (currentOverlay) {
|
|
||||||
is DesktopScreen.UserProfile -> "Profile"
|
|
||||||
is DesktopScreen.Thread -> "Thread"
|
|
||||||
else -> currentColumnType.title()
|
|
||||||
},
|
|
||||||
onBack = { navState.pop() },
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||||
) {
|
) {
|
||||||
|
// Always keep RootContent composed so state (e.g. search results) survives navigation
|
||||||
|
RootContent(
|
||||||
|
columnType = currentColumnType,
|
||||||
|
relayManager = relayManager,
|
||||||
|
localCache = localCache,
|
||||||
|
accountManager = accountManager,
|
||||||
|
account = account,
|
||||||
|
nwcConnection = nwcConnection,
|
||||||
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
|
appScope = appScope,
|
||||||
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
|
onZapFeedback = onZapFeedback,
|
||||||
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
|
)
|
||||||
if (currentOverlay != null) {
|
if (currentOverlay != null) {
|
||||||
OverlayContent(
|
Surface(
|
||||||
screen = currentOverlay,
|
color = MaterialTheme.colorScheme.background,
|
||||||
relayManager = relayManager,
|
modifier = Modifier.fillMaxSize(),
|
||||||
localCache = localCache,
|
) {
|
||||||
account = account,
|
OverlayContent(
|
||||||
nwcConnection = nwcConnection,
|
screen = currentOverlay,
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
relayManager = relayManager,
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
localCache = localCache,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
account = account,
|
||||||
onZapFeedback = onZapFeedback,
|
nwcConnection = nwcConnection,
|
||||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
subscriptionsCoordinator = subscriptionsCoordinator,
|
||||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onBack = { navState.pop() },
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
)
|
onZapFeedback = onZapFeedback,
|
||||||
} else {
|
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
||||||
RootContent(
|
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
||||||
columnType = currentColumnType,
|
onBack = { navState.pop() },
|
||||||
relayManager = relayManager,
|
)
|
||||||
localCache = localCache,
|
}
|
||||||
accountManager = accountManager,
|
|
||||||
account = account,
|
|
||||||
nwcConnection = nwcConnection,
|
|
||||||
subscriptionsCoordinator = subscriptionsCoordinator,
|
|
||||||
appScope = appScope,
|
|
||||||
onShowComposeDialog = onShowComposeDialog,
|
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
|
||||||
onZapFeedback = onZapFeedback,
|
|
||||||
onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
|
|
||||||
onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SinglePaneHeader(
|
|
||||||
title: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(40.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = "Back",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+429
@@ -0,0 +1,429 @@
|
|||||||
|
/*
|
||||||
|
* 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.desktop.ui.search
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.ContentPreset
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.DateUtils
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.KindRegistry
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.QueryParser
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SearchQuery
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AdvancedSearchPanel(
|
||||||
|
query: SearchQuery,
|
||||||
|
onKindsChanged: (List<Int>) -> Unit,
|
||||||
|
onPseudoKindsChanged: (List<String>) -> Unit,
|
||||||
|
onAuthorAdded: (String) -> Unit,
|
||||||
|
onAuthorRemoved: (String) -> Unit,
|
||||||
|
onDateRangeChanged: (Long?, Long?) -> Unit,
|
||||||
|
onHashtagAdded: (String) -> Unit,
|
||||||
|
onHashtagRemoved: (String) -> Unit,
|
||||||
|
onExcludeAdded: (String) -> Unit,
|
||||||
|
onExcludeRemoved: (String) -> Unit,
|
||||||
|
onLanguageChanged: (String?) -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// Content type presets
|
||||||
|
Text(
|
||||||
|
"Content Type",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
KindRegistry.presets.forEach { (name, preset) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = preset.isSelected(query.kinds.toList(), query.pseudoKinds.toList()),
|
||||||
|
onClick = { togglePreset(preset, query, onKindsChanged, onPseudoKindsChanged) },
|
||||||
|
label = { Text(name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author field
|
||||||
|
AuthorInputField(
|
||||||
|
authors = query.authors.toList() + query.authorNames.toList(),
|
||||||
|
onAuthorAdded = onAuthorAdded,
|
||||||
|
onAuthorRemoved = onAuthorRemoved,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
DateRangeFields(
|
||||||
|
since = query.since,
|
||||||
|
until = query.until,
|
||||||
|
onChanged = onDateRangeChanged,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hashtags
|
||||||
|
ChipGroupWithInput(
|
||||||
|
label = "Tags",
|
||||||
|
items = query.hashtags.toList(),
|
||||||
|
prefix = "#",
|
||||||
|
placeholder = "Add tag...",
|
||||||
|
onAdd = onHashtagAdded,
|
||||||
|
onRemove = onHashtagRemoved,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exclude terms
|
||||||
|
ChipGroupWithInput(
|
||||||
|
label = "Exclude",
|
||||||
|
items = query.excludeTerms.toList(),
|
||||||
|
prefix = "-",
|
||||||
|
placeholder = "Exclude term...",
|
||||||
|
onAdd = onExcludeAdded,
|
||||||
|
onRemove = onExcludeRemoved,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Language
|
||||||
|
LanguageSelector(
|
||||||
|
selected = query.language,
|
||||||
|
onChanged = onLanguageChanged,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear button
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = onClear) {
|
||||||
|
Text("Clear All")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun togglePreset(
|
||||||
|
preset: ContentPreset,
|
||||||
|
query: SearchQuery,
|
||||||
|
onKindsChanged: (List<Int>) -> Unit,
|
||||||
|
onPseudoKindsChanged: (List<String>) -> Unit,
|
||||||
|
) {
|
||||||
|
val pseudo = preset.pseudoKind
|
||||||
|
if (pseudo != null) {
|
||||||
|
val current = query.pseudoKinds.toList()
|
||||||
|
if (pseudo in current) {
|
||||||
|
onPseudoKindsChanged(current - pseudo)
|
||||||
|
} else {
|
||||||
|
onPseudoKindsChanged(current + pseudo)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val current = query.kinds.toList()
|
||||||
|
if (current.containsAll(preset.kinds)) {
|
||||||
|
onKindsChanged(current - preset.kinds.toSet())
|
||||||
|
} else {
|
||||||
|
onKindsChanged((current + preset.kinds).distinct())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun AuthorInputField(
|
||||||
|
authors: List<String>,
|
||||||
|
onAuthorAdded: (String) -> Unit,
|
||||||
|
onAuthorRemoved: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Author",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (authors.isNotEmpty()) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
authors.forEach { author ->
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onAuthorRemoved(author) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
if (author.length > 16) author.take(8) + "..." + author.takeLast(4) else author,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
var authorInput by remember { mutableStateOf("") }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = authorInput,
|
||||||
|
onValueChange = { authorInput = it },
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().onKeyEvent {
|
||||||
|
if (it.key == Key.Enter && authorInput.isNotBlank()) {
|
||||||
|
onAuthorAdded(authorInput.trim())
|
||||||
|
authorInput = ""
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = { Text("npub or name...") },
|
||||||
|
singleLine = true,
|
||||||
|
trailingIcon = {
|
||||||
|
if (authorInput.isNotBlank()) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
onAuthorAdded(authorInput.trim())
|
||||||
|
authorInput = ""
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add author")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DateRangeFields(
|
||||||
|
since: Long?,
|
||||||
|
until: Long?,
|
||||||
|
onChanged: (Long?, Long?) -> Unit,
|
||||||
|
) {
|
||||||
|
// Local text is source of truth while typing.
|
||||||
|
// Only propagate valid timestamps (or null when cleared).
|
||||||
|
// Only sync from external when the timestamp changes to something we didn't produce.
|
||||||
|
var sinceText by remember { mutableStateOf(since?.let { DateUtils.timestampToDate(it) } ?: "") }
|
||||||
|
var lastSince by remember { mutableStateOf(since) }
|
||||||
|
if (since != lastSince) {
|
||||||
|
sinceText = since?.let { DateUtils.timestampToDate(it) } ?: ""
|
||||||
|
lastSince = since
|
||||||
|
}
|
||||||
|
|
||||||
|
var untilText by remember { mutableStateOf(until?.let { DateUtils.timestampToDate(it) } ?: "") }
|
||||||
|
var lastUntil by remember { mutableStateOf(until) }
|
||||||
|
if (until != lastUntil) {
|
||||||
|
untilText = until?.let { DateUtils.timestampToDate(it) } ?: ""
|
||||||
|
lastUntil = until
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Since",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = sinceText,
|
||||||
|
onValueChange = {
|
||||||
|
sinceText = it
|
||||||
|
val ts = QueryParser.parseDateToTimestamp(it)
|
||||||
|
if (ts != null || it.isBlank()) {
|
||||||
|
lastSince = ts
|
||||||
|
onChanged(ts, until)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("YYYY-MM-DD") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Until",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = untilText,
|
||||||
|
onValueChange = {
|
||||||
|
untilText = it
|
||||||
|
val ts = QueryParser.parseDateToTimestamp(it)
|
||||||
|
if (ts != null || it.isBlank()) {
|
||||||
|
lastUntil = ts
|
||||||
|
onChanged(since, ts)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("YYYY-MM-DD") },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun ChipGroupWithInput(
|
||||||
|
label: String,
|
||||||
|
items: List<String>,
|
||||||
|
prefix: String,
|
||||||
|
placeholder: String,
|
||||||
|
onAdd: (String) -> Unit,
|
||||||
|
onRemove: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
items.forEach { item ->
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onRemove(item) },
|
||||||
|
label = { Text("$prefix$item", style = MaterialTheme.typography.bodySmall) },
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (items.isNotEmpty()) Spacer(Modifier.height(4.dp))
|
||||||
|
var inputText by remember { mutableStateOf("") }
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = inputText,
|
||||||
|
onValueChange = { inputText = it },
|
||||||
|
modifier =
|
||||||
|
Modifier.weight(1f).onKeyEvent {
|
||||||
|
if (it.key == Key.Enter && inputText.isNotBlank()) {
|
||||||
|
onAdd(inputText.trim())
|
||||||
|
inputText = ""
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = { Text(placeholder) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (inputText.isNotBlank()) {
|
||||||
|
onAdd(inputText.trim())
|
||||||
|
inputText = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LanguageSelector(
|
||||||
|
selected: String?,
|
||||||
|
onChanged: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val languages =
|
||||||
|
listOf(
|
||||||
|
null to "Any",
|
||||||
|
"en" to "English",
|
||||||
|
"es" to "Spanish",
|
||||||
|
"pt" to "Portuguese",
|
||||||
|
"ja" to "Japanese",
|
||||||
|
"zh" to "Chinese",
|
||||||
|
"de" to "German",
|
||||||
|
"fr" to "French",
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"Language",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
languages.forEach { (code, name) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == code,
|
||||||
|
onClick = { onChanged(code) },
|
||||||
|
label = { Text(name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+379
@@ -0,0 +1,379 @@
|
|||||||
|
/*
|
||||||
|
* 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.desktop.ui.search
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Article
|
||||||
|
import androidx.compose.material.icons.filled.Description
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.Forum
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
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.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.KindRegistry
|
||||||
|
import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
|
||||||
|
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchResultsList(
|
||||||
|
state: AdvancedSearchBarState,
|
||||||
|
onNavigateToProfile: (String) -> Unit,
|
||||||
|
onNavigateToThread: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
) {
|
||||||
|
val people by state.sortedPeopleResults.collectAsState()
|
||||||
|
val notes by state.sortedNoteResults.collectAsState()
|
||||||
|
val eventSortOrder by state.eventSortOrder.collectAsState()
|
||||||
|
val peopleSortOrder by state.peopleSortOrder.collectAsState()
|
||||||
|
|
||||||
|
val hasResults = people.isNotEmpty() || notes.isNotEmpty()
|
||||||
|
|
||||||
|
if (!hasResults) return
|
||||||
|
|
||||||
|
// Group notes by kind
|
||||||
|
val textNotes = notes.filter { it.kind == 1 }
|
||||||
|
val articles = notes.filter { it.kind == LongTextNoteEvent.KIND }
|
||||||
|
val otherNotes = notes.filter { it.kind != 1 && it.kind != LongTextNoteEvent.KIND }
|
||||||
|
|
||||||
|
// Per-section collapsed state (absent = expanded)
|
||||||
|
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
// People section
|
||||||
|
if (people.isNotEmpty()) {
|
||||||
|
val collapsed = collapsedSections["people"] == true
|
||||||
|
stickyHeader(key = "header-people") {
|
||||||
|
SortableHeader(
|
||||||
|
title = "People",
|
||||||
|
count = people.size,
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
options = SearchSortOrder.PEOPLE_OPTIONS,
|
||||||
|
selected = peopleSortOrder,
|
||||||
|
onSelect = { state.updatePeopleSortOrder(it) },
|
||||||
|
collapsed = collapsed,
|
||||||
|
onToggleCollapse = { collapsedSections["people"] = !collapsed },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!collapsed) {
|
||||||
|
val displayPeople = people.take(5)
|
||||||
|
items(displayPeople, key = { "person-${it.pubkeyHex}" }) { user ->
|
||||||
|
UserSearchCard(
|
||||||
|
user = user,
|
||||||
|
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (people.size > 5) {
|
||||||
|
item(key = "people-expand") {
|
||||||
|
ExpandableSection(
|
||||||
|
remaining = people.drop(5),
|
||||||
|
) { user ->
|
||||||
|
UserSearchCard(
|
||||||
|
user = user,
|
||||||
|
onClick = { onNavigateToProfile(user.pubkeyHex) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes section
|
||||||
|
if (textNotes.isNotEmpty()) {
|
||||||
|
if (people.isNotEmpty()) {
|
||||||
|
item(key = "divider-notes") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
|
||||||
|
}
|
||||||
|
val collapsed = collapsedSections["notes"] == true
|
||||||
|
stickyHeader(key = "header-notes") {
|
||||||
|
SortableHeader(
|
||||||
|
title = "Notes",
|
||||||
|
count = textNotes.size,
|
||||||
|
icon = Icons.Default.Description,
|
||||||
|
options = SearchSortOrder.EVENT_OPTIONS,
|
||||||
|
selected = eventSortOrder,
|
||||||
|
onSelect = { state.updateEventSortOrder(it) },
|
||||||
|
collapsed = collapsed,
|
||||||
|
onToggleCollapse = { collapsedSections["notes"] = !collapsed },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!collapsed) {
|
||||||
|
val displayNotes = textNotes.take(5)
|
||||||
|
items(displayNotes, key = { "note-${it.id}" }) { event ->
|
||||||
|
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||||
|
}
|
||||||
|
if (textNotes.size > 5) {
|
||||||
|
item(key = "notes-expand") {
|
||||||
|
ExpandableSection(
|
||||||
|
remaining = textNotes.drop(5),
|
||||||
|
) { event ->
|
||||||
|
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Articles section
|
||||||
|
if (articles.isNotEmpty()) {
|
||||||
|
if (people.isNotEmpty() || textNotes.isNotEmpty()) {
|
||||||
|
item(key = "divider-articles") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
|
||||||
|
}
|
||||||
|
val collapsed = collapsedSections["articles"] == true
|
||||||
|
stickyHeader(key = "header-articles") {
|
||||||
|
SortableHeader(
|
||||||
|
title = "Articles",
|
||||||
|
count = articles.size,
|
||||||
|
icon = Icons.Default.Article,
|
||||||
|
options = SearchSortOrder.EVENT_OPTIONS,
|
||||||
|
selected = eventSortOrder,
|
||||||
|
onSelect = { state.updateEventSortOrder(it) },
|
||||||
|
collapsed = collapsed,
|
||||||
|
onToggleCollapse = { collapsedSections["articles"] = !collapsed },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!collapsed) {
|
||||||
|
items(articles.take(5), key = { "article-${it.id}" }) { event ->
|
||||||
|
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||||
|
}
|
||||||
|
if (articles.size > 5) {
|
||||||
|
item(key = "articles-expand") {
|
||||||
|
ExpandableSection(
|
||||||
|
remaining = articles.drop(5),
|
||||||
|
) { event ->
|
||||||
|
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other section
|
||||||
|
if (otherNotes.isNotEmpty()) {
|
||||||
|
item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
|
||||||
|
val collapsed = collapsedSections["other"] == true
|
||||||
|
stickyHeader(key = "header-other") {
|
||||||
|
SortableHeader(
|
||||||
|
title = "Other",
|
||||||
|
count = otherNotes.size,
|
||||||
|
icon = Icons.Default.Forum,
|
||||||
|
options = SearchSortOrder.EVENT_OPTIONS,
|
||||||
|
selected = eventSortOrder,
|
||||||
|
onSelect = { state.updateEventSortOrder(it) },
|
||||||
|
collapsed = collapsed,
|
||||||
|
onToggleCollapse = { collapsedSections["other"] = !collapsed },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!collapsed) {
|
||||||
|
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
|
||||||
|
NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom padding
|
||||||
|
item(key = "bottom-spacer") { Spacer(Modifier.height(16.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SortableHeader(
|
||||||
|
title: String,
|
||||||
|
count: Int,
|
||||||
|
icon: ImageVector,
|
||||||
|
options: List<SearchSortOrder>,
|
||||||
|
selected: SearchSortOrder,
|
||||||
|
onSelect: (SearchSortOrder) -> Unit,
|
||||||
|
collapsed: Boolean = false,
|
||||||
|
onToggleCollapse: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val chevronRotation by animateFloatAsState(if (collapsed) -90f else 0f)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable(onClick = onToggleCollapse).padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ExpandMore,
|
||||||
|
contentDescription = if (collapsed) "Expand $title" else "Collapse $title",
|
||||||
|
modifier = Modifier.size(18.dp).rotate(chevronRotation),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(start = 4.dp).size(18.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"$title ($count)",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (!collapsed) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
options.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = option == selected,
|
||||||
|
onClick = { onSelect(option) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
option.label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors =
|
||||||
|
FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
modifier = Modifier.height(28.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotePreviewCard(
|
||||||
|
event: Event,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Kind badge
|
||||||
|
val kindName = KindRegistry.nameFor(event.kind) ?: "kind ${event.kind}"
|
||||||
|
Text(
|
||||||
|
kindName,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
// Author (hex truncated)
|
||||||
|
Text(
|
||||||
|
event.pubKey.take(8) + "..." + event.pubKey.takeLast(4),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
// Timestamp
|
||||||
|
Text(
|
||||||
|
event.createdAt.toTimeAgo(withDot = false),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
// Content preview
|
||||||
|
Text(
|
||||||
|
event.content.take(200),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> ExpandableSection(
|
||||||
|
remaining: List<T>,
|
||||||
|
content: @Composable (T) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
if (expanded) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
remaining.forEach { item ->
|
||||||
|
content(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextButton(
|
||||||
|
onClick = { expanded = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ExpandMore, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Text("Show all ${remaining.size} more")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* 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.desktop.ui.search
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.CloudDownload
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncState
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchSyncBanner(
|
||||||
|
relayStates: ImmutableList<RelaySyncState>,
|
||||||
|
isSearching: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val isVisible = isSearching || relayStates.isNotEmpty()
|
||||||
|
var isExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||||
|
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// Collapsed summary row
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { isExpanded = !isExpanded }
|
||||||
|
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
val responded = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
|
||||||
|
val total = relayStates.size
|
||||||
|
val totalEvents = relayStates.sumOf { it.eventsReceived }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (total > 0) {
|
||||||
|
"$responded/$total relays responded \u00B7 $totalEvents events"
|
||||||
|
} else {
|
||||||
|
"Connecting to relays..."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded per-relay details
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isExpanded && relayStates.isNotEmpty(),
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
thickness = 0.5.dp,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
relayStates.forEach { relay ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = relayStatusIcon(relay.status),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = relayStatusColor(relay.status),
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = relay.displayName,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${relay.eventsReceived} events",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relayStatusIcon(status: RelaySyncStatus): ImageVector =
|
||||||
|
when (status) {
|
||||||
|
RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty
|
||||||
|
RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty
|
||||||
|
RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload
|
||||||
|
RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle
|
||||||
|
RelaySyncStatus.FAILED -> Icons.Default.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun relayStatusColor(status: RelaySyncStatus): Color =
|
||||||
|
when (status) {
|
||||||
|
RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary
|
||||||
|
RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary
|
||||||
|
RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary
|
||||||
|
RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary
|
||||||
|
RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Brainstorm: Advanced Search for Desktop
|
||||||
|
|
||||||
|
**Date:** 2026-03-10
|
||||||
|
**Status:** Draft
|
||||||
|
**Branch:** TBD (`feat/desktop-advanced-search`)
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
Full-featured advanced search for Amethyst Desktop with:
|
||||||
|
1. **Twitter-style query operators** (`from:`, `kind:`, `since:`, etc.) that map to NIP-50 Filter fields
|
||||||
|
2. **Form-based UI** (expandable panel below search bar) for users who don't want to learn syntax
|
||||||
|
3. **Bidirectional sync** between text operators and form controls — editing one updates the other
|
||||||
|
4. **Extensible kind presets** — toggle groups like Notes, Articles, Media, Communities
|
||||||
|
5. **Future AI bridge** (follow-up) — natural language → structured query conversion
|
||||||
|
|
||||||
|
**Philosophy:** Relay-first, pragmatic. Desktop users have resources; we prioritize completeness over privacy. Privacy controls available but not default friction.
|
||||||
|
|
||||||
|
## Why This Approach
|
||||||
|
|
||||||
|
- **No standard Nostr query language exists** — we define one that maps cleanly to `Filter` fields
|
||||||
|
- **Dual interface (text + form)** — power users get speed, casual users get discoverability
|
||||||
|
- **Current desktop search is minimal** — only kind 0 + 1, no operators, no kind filtering
|
||||||
|
- **Android has 30+ kinds** but no query language — desktop leapfrogs with both
|
||||||
|
- **AI deferred** — core query system must work standalone first; AI is a parsing layer on top
|
||||||
|
|
||||||
|
## How Other Clients Do Search
|
||||||
|
|
||||||
|
| Client | Approach | Operators | Notable |
|
||||||
|
|--------|----------|-----------|---------|
|
||||||
|
| **Amethyst Android** | Local cache + NIP-50, 30+ kinds | None (plain text) | Search relay list (kind 10007) |
|
||||||
|
| **Primal** | Proprietary caching server | Form UI only | Event type, time range, scope dropdowns |
|
||||||
|
| **Coracle** | NIP-50 + configurable relays | None | Also supports DVM requests (NIP-90) |
|
||||||
|
| **Damus** | NIP-50 relay search | None | User/profile focused |
|
||||||
|
| **noStrudel** | Local relay + NIP-50 | None | IndexedDB local indexing |
|
||||||
|
| **Gossip** | NIP-50 relay search | None | Desktop Rust client |
|
||||||
|
| **Noogle.lol** | NIP-90 DVM search | `from:npub` `from:me` | Pay-per-query via Lightning |
|
||||||
|
|
||||||
|
**Key insight:** No client ships a query operator language. This is greenfield.
|
||||||
|
|
||||||
|
## Proposed Query Schema
|
||||||
|
|
||||||
|
Operators map directly to `Filter` fields and NIP-50 extensions:
|
||||||
|
|
||||||
|
### Core Operators
|
||||||
|
|
||||||
|
| Operator | Maps To | Example | Notes |
|
||||||
|
|----------|---------|---------|-------|
|
||||||
|
| `from:<npub\|name>` | `Filter.authors` | `from:npub1abc...` | Resolve names via NIP-05/local cache |
|
||||||
|
| `kind:<number\|name>` | `Filter.kinds` | `kind:note` or `kind:1` | Named aliases for common kinds |
|
||||||
|
| `since:<date>` | `Filter.since` | `since:2025-01-01` | ISO 8601 date parsing |
|
||||||
|
| `until:<date>` | `Filter.until` | `until:2025-06-30` | ISO 8601 date parsing |
|
||||||
|
| `#<tag>` | `Filter.tags["t"]` | `#bitcoin` | Hashtag filter |
|
||||||
|
| `"exact phrase"` | Quoted in `Filter.search` | `"lightning network"` | Relay-dependent support |
|
||||||
|
| `-<term>` | Client-side exclusion | `-spam` | Post-filter after relay results |
|
||||||
|
|
||||||
|
### NIP-50 Extension Operators
|
||||||
|
|
||||||
|
| Operator | NIP-50 Extension | Example |
|
||||||
|
|----------|-----------------|---------|
|
||||||
|
| `lang:<code>` | `language:xx` | `lang:en` |
|
||||||
|
| `domain:<nip05>` | `domain:xx` | `domain:nostr.com` |
|
||||||
|
| `nsfw:<bool>` | `nsfw:xx` | `nsfw:false` |
|
||||||
|
|
||||||
|
### Kind Name Aliases
|
||||||
|
|
||||||
|
| Alias | Kind(s) | Description |
|
||||||
|
|-------|---------|-------------|
|
||||||
|
| `note` | 1 | Short text note |
|
||||||
|
| `article` | 30023 | Long-form content |
|
||||||
|
| `repost` | 6 | Reposts |
|
||||||
|
| `reply` | 1 (with `e` tag) | Replies (client-side filter) |
|
||||||
|
| `media` | 1 (with `imeta` tag) | Notes with media |
|
||||||
|
| `channel` | 40, 41, 42 | Public channels |
|
||||||
|
| `live` | 30311 | Live activities |
|
||||||
|
| `community` | 34550 | Communities |
|
||||||
|
| `wiki` | 30818 | Wiki pages |
|
||||||
|
| `video` | 34235 | Video events |
|
||||||
|
| `classified` | 30402 | Classifieds |
|
||||||
|
| `profile` | 0 | Metadata/profiles |
|
||||||
|
|
||||||
|
### Boolean Logic
|
||||||
|
|
||||||
|
| Syntax | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `bitcoin lightning` | AND (default) |
|
||||||
|
| `bitcoin OR lightning` | OR — requires multiple relay queries |
|
||||||
|
| `-spam` | NOT — client-side exclusion |
|
||||||
|
|
||||||
|
## UX Design
|
||||||
|
|
||||||
|
### Search Bar (Default State)
|
||||||
|
```
|
||||||
|
[ Search notes, people, tags... ] [Advanced v]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expanded Advanced Panel
|
||||||
|
```
|
||||||
|
[ from:npub1abc kind:note since:2025-01 bitcoin ] [Advanced ^]
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
| Content Type: [x] Notes [ ] Articles [ ] Media [ ] All |
|
||||||
|
| Author: [ npub or name... ] [+ Add] |
|
||||||
|
| Date Range: [ 2025-01-01 ] to [ today ] |
|
||||||
|
| Language: [ Any v ] |
|
||||||
|
| Hashtags: [ #bitcoin ] [+ Add] |
|
||||||
|
| Exclude: [ spam, nsfw... ] [+ Add] |
|
||||||
|
| |
|
||||||
|
| [Clear Filters] [Search] |
|
||||||
|
+------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bidirectional Sync
|
||||||
|
- Typing `from:npub1abc` in search bar → Author field populates in panel
|
||||||
|
- Selecting "Articles" checkbox → `kind:article` appears in search bar
|
||||||
|
- Editing either side updates the other in real-time
|
||||||
|
- Form is the "visual representation" of the query string
|
||||||
|
|
||||||
|
### Result Display
|
||||||
|
- Results grouped by type: People, Notes, Articles, Channels
|
||||||
|
- Each result shows: content preview, author, timestamp, kind badge
|
||||||
|
- Infinite scroll with "Load more from relays" button
|
||||||
|
- Sort: Relevance (default, relay-determined) or Chronological
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Query Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
User Input (text or form)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
QueryParser (commons/commonMain)
|
||||||
|
|-- Tokenize operators: from:, kind:, since:, etc.
|
||||||
|
|-- Resolve names → hex pubkeys (local cache + NIP-05)
|
||||||
|
|-- Parse dates → unix timestamps
|
||||||
|
|-- Map kind aliases → kind numbers
|
||||||
|
|
|
||||||
|
v
|
||||||
|
SearchQuery (data class in commons)
|
||||||
|
|-- text: String (free text for NIP-50 search field)
|
||||||
|
|-- authors: List<HexKey>
|
||||||
|
|-- kinds: List<Int>
|
||||||
|
|-- since: Long?
|
||||||
|
|-- until: Long?
|
||||||
|
|-- tags: Map<String, List<String>>
|
||||||
|
|-- excludeTerms: List<String>
|
||||||
|
|-- language: String?
|
||||||
|
|-- nip50Extensions: Map<String, String>
|
||||||
|
|
|
||||||
|
v
|
||||||
|
FilterBuilder (desktop)
|
||||||
|
|-- Convert SearchQuery → List<Filter>
|
||||||
|
|-- Split by kind groups (like Android: 3 filters, ~10 kinds each)
|
||||||
|
|-- Inject NIP-50 extensions into search string
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Relay Subscription
|
||||||
|
|-- Use search relay list (kind 10007) or connected relays
|
||||||
|
|-- Send REQ with filters
|
||||||
|
|-- Aggregate results
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Client-side Post-filter
|
||||||
|
|-- Apply exclusions (-term)
|
||||||
|
|-- Apply "reply" detection (has e tag)
|
||||||
|
|-- Apply "media" detection (has imeta tag)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Results Display
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module Placement
|
||||||
|
|
||||||
|
| Component | Module | Rationale |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| `QueryParser` | `commons/commonMain` | Reusable for Android later |
|
||||||
|
| `SearchQuery` | `commons/commonMain` | Shared data model |
|
||||||
|
| `QuerySerializer` | `commons/commonMain` | SearchQuery ↔ string conversion |
|
||||||
|
| `AdvancedSearchPanel` | `desktopApp` | Desktop-specific UI |
|
||||||
|
| `SearchScreen` (updated) | `desktopApp` | Desktop layout |
|
||||||
|
| `FilterBuilder` (updated) | `desktopApp` | Desktop filter assembly |
|
||||||
|
| Kind alias registry | `commons/commonMain` | Shared kind name mapping |
|
||||||
|
|
||||||
|
### Search Relay Management
|
||||||
|
|
||||||
|
- Use existing `SearchRelayListEvent` (kind 10007) from quartz
|
||||||
|
- Desktop UI to configure search relays (settings page)
|
||||||
|
- Default fallback: `relay.nostr.band`, `nostr.wine`, `relay.damus.io`
|
||||||
|
- Future: auto-discover NIP-50 capable relays via NIP-11
|
||||||
|
|
||||||
|
## Privacy Considerations
|
||||||
|
|
||||||
|
| Concern | Mitigation | Default |
|
||||||
|
|---------|-----------|---------|
|
||||||
|
| Relay sees search queries | User-configurable search relay list | On (kind 10007) |
|
||||||
|
| Relay sees IP + query | VPN/Tor support (system-level) | Not enforced |
|
||||||
|
| Query history stored | No server-side history; client-side optional | Off |
|
||||||
|
| NIP-05 resolution leaks interest | Cache NIP-05 lookups locally | On |
|
||||||
|
| Author search reveals social graph | Already visible via follow lists | N/A |
|
||||||
|
|
||||||
|
**Stance:** Relay-first, pragmatic. Desktop users accept relay visibility for better results. Advanced users can configure search relays or use Tor.
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
1. **Query language is NOT a Nostr standard** — it's a client-side UX convention that maps to `Filter` fields
|
||||||
|
2. **Bidirectional sync** between text bar and form panel — single source of truth (`SearchQuery` data class)
|
||||||
|
3. **Kind presets with extensibility** — start with core groups, users can toggle individual kinds
|
||||||
|
4. **Relay-first execution** — NIP-50 search as primary, local cache as supplement
|
||||||
|
5. **AI deferred** — follow-up feature, will parse natural language → `SearchQuery`
|
||||||
|
6. **Query parser in commons** — shared module so Android can adopt later
|
||||||
|
7. **Client-side post-filtering** for operators relays can't handle (exclusions, reply detection, media detection)
|
||||||
|
|
||||||
|
## Resolved Questions
|
||||||
|
|
||||||
|
1. **Name resolution** — Async + refine. Search immediately with text, resolve `from:name` in background, refine results when pubkey resolved. No blocking.
|
||||||
|
2. **OR queries** — Yes in v1. `bitcoin OR lightning` sends parallel relay subscriptions, merges results.
|
||||||
|
3. **Search history** — Recent history (last 20) stored locally.
|
||||||
|
4. **Saved searches** — Yes in v1. Pin/save queries. Future: persist as Nostr events.
|
||||||
|
5. **Result caching** — Session cache only (in-memory). Cleared on app restart. No disk persistence.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None — all resolved.
|
||||||
|
|
||||||
|
## Follow-up Features (Out of Scope)
|
||||||
|
|
||||||
|
- AI natural language → query parsing (local Ollama / cloud API / NIP-90 DVM)
|
||||||
|
- Local SQLite FTS5 index for offline search
|
||||||
|
- NIP-90 DVM search integration
|
||||||
|
- Search analytics / trending topics
|
||||||
|
- Collaborative search (shared saved searches via Nostr events)
|
||||||
@@ -0,0 +1,962 @@
|
|||||||
|
---
|
||||||
|
title: "feat: Desktop Advanced Search with Query Operators and Form UI"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-03-10
|
||||||
|
deepened: 2026-03-10
|
||||||
|
origin: docs/brainstorms/2026-03-10-advanced-search-brainstorm.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Desktop Advanced Search
|
||||||
|
|
||||||
|
## Enhancement Summary
|
||||||
|
|
||||||
|
**Deepened on:** 2026-03-10
|
||||||
|
**Agents used:** kotlin-expert, compose-expert, kotlin-coroutines, nostr-expert, desktop-expert, kmp-expert, best-practices-researcher, architecture-strategist, performance-oracle, code-simplicity-reviewer, security-sentinel
|
||||||
|
|
||||||
|
### Key Improvements
|
||||||
|
1. **Bidirectional sync loop prevention** — `sourceOfChange` discriminator (TEXT/FORM/INIT) breaks parse→serialize→parse cycles
|
||||||
|
2. **Performance** — batch result accumulation via `channelFlow` + 100ms windows, cap OR to 3 terms (not 5), `@Immutable` SearchQuery
|
||||||
|
3. **Parser architecture** — hand-written recursive descent tokenizer + parser, error recovery via literal text degradation
|
||||||
|
4. **Module corrections** — SearchFilterFactory stays in desktopApp (needs SubscriptionConfig); SearchResultFilter and SearchHistoryStore can move to commons
|
||||||
|
5. **Compose patterns** — `FilterChip` for kind presets, `expandVertically(Alignment.Top)` + `fadeIn`, sticky section headers, shimmer loading
|
||||||
|
6. **Coroutine patterns** — `flatMapLatest` for auto-canceling old subscriptions, `merge()` for OR queries, `supervisorScope` for relay isolation
|
||||||
|
7. **Simplicity guidance** — MVP can cut OR queries, lang:/domain:, saved searches to ~350 LOC / 5 files. Full plan phases appropriately.
|
||||||
|
|
||||||
|
### New Considerations Discovered
|
||||||
|
- NIP-50 extensions go inline in search string (`"bitcoin language:en"`), not as separate filter fields
|
||||||
|
- Pseudo-kinds (reply, media) need separate handling from real kinds — they're client-side post-filters, not relay filters
|
||||||
|
- `TextFieldValue` (not raw String) needed for cursor position stability during bidirectional sync
|
||||||
|
- Use `query.hashtags` → `Filter.tags["t"]` (more reliable than putting hashtags in search string)
|
||||||
|
- OR cap: 3 terms max (not 5) — 5 terms × 3 groups × 3 relays = 45 subs is too many
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Full-featured search for Amethyst Desktop: Twitter-style query operators (`from:`, `kind:`, `since:`, etc.), expandable form panel below search bar, bidirectional sync between text and form, extensible kind presets, OR queries, search history + saved searches. Relay-first via NIP-50.
|
||||||
|
|
||||||
|
Current desktop search only handles kind 0 (people) + kind 1 (notes) with plain text. Android searches 30+ kinds. This closes that gap and adds capabilities neither platform has.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Desktop search (`SearchScreen.kt`) is minimal:
|
||||||
|
- Only `searchPeople()` (kind 0) wired to relay subscription
|
||||||
|
- `searchNotes()` exists in `FeedSubscription.kt` but not connected
|
||||||
|
- No kind filtering, no author filtering, no date ranges
|
||||||
|
- No query language — users can only type plain text or bech32 identifiers
|
||||||
|
- `SearchBarState` in commons only returns `User` results, no notes/channels
|
||||||
|
|
||||||
|
Users can't find content they've seen, discover new content by topic, or filter by author/type/date.
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Query Operator Language
|
||||||
|
|
||||||
|
Client-side query language that maps to `Filter` fields. Not a Nostr standard — a UX convention.
|
||||||
|
|
||||||
|
```
|
||||||
|
from:npub1abc kind:note since:2025-01-01 bitcoin OR lightning -spam #nostr
|
||||||
|
```
|
||||||
|
|
||||||
|
| Operator | Maps To | Relay-side? |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `from:<npub\|name>` | `Filter.authors` | Yes |
|
||||||
|
| `kind:<number\|alias>` | `Filter.kinds` | Yes |
|
||||||
|
| `since:<date>` | `Filter.since` | Yes |
|
||||||
|
| `until:<date>` | `Filter.until` | Yes |
|
||||||
|
| `#<tag>` | `Filter.tags["t"]` | Yes |
|
||||||
|
| `"exact phrase"` | Quoted in `Filter.search` | Yes (relay-dependent) |
|
||||||
|
| `lang:<code>` | NIP-50 extension in search string | Relay-dependent |
|
||||||
|
| `domain:<nip05>` | NIP-50 extension in search string | Relay-dependent |
|
||||||
|
| `-<term>` | Client-side exclusion post-filter | No |
|
||||||
|
| `OR` | Parallel subscriptions, merged | Multiple queries |
|
||||||
|
|
||||||
|
#### Research Insights: NIP-50 Protocol Details
|
||||||
|
|
||||||
|
**NIP-50 extension placement:** Extensions go *inline in the search string*, not as separate filter fields. The relay parses them out:
|
||||||
|
```json
|
||||||
|
{"kinds": [1], "search": "bitcoin language:en domain:nostr.com"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hashtag handling:** Use `tags = {"t": ["bitcoin"]}` in the filter (more reliable across relays) rather than putting `#bitcoin` in the search string. Hashtags in `Filter.tags` are protocol-level, not NIP-50 dependent.
|
||||||
|
|
||||||
|
**Quoted phrase search:** Not standardized — relay-dependent. Some relays treat quotes literally, others ignore them. Degrade gracefully.
|
||||||
|
|
||||||
|
**All filter fields AND together** within a single filter. OR requires separate subscriptions.
|
||||||
|
|
||||||
|
### Dual UI: Text Bar + Expandable Form Panel
|
||||||
|
|
||||||
|
```
|
||||||
|
[ from:npub1abc kind:note bitcoin ] [Advanced v]
|
||||||
|
+----------------------------------------------------------+
|
||||||
|
| Content: [x] Notes [ ] Articles [ ] Media [ ] All |
|
||||||
|
| Author: [ npub or name... ] [+ Add] |
|
||||||
|
| Since: [ 2025-01-01 ] Until: [ today ] |
|
||||||
|
| Tags: [ #bitcoin ] [+ Add] |
|
||||||
|
| Exclude: [ spam ] [+ Add] |
|
||||||
|
| Language:[ Any v ] |
|
||||||
|
| |
|
||||||
|
| [Clear] [Search] |
|
||||||
|
+----------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
Bidirectional: typing `kind:article` checks "Articles"; checking "Notes" inserts `kind:note`.
|
||||||
|
|
||||||
|
#### Research Insights: Bidirectional Sync
|
||||||
|
|
||||||
|
**Critical: `sourceOfChange` discriminator.** Without this, parse→serialize→parse loops will occur. Track who initiated the change:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
enum class ChangeSource { TEXT, FORM, INIT }
|
||||||
|
|
||||||
|
fun updateFromText(rawText: String) {
|
||||||
|
_changeSource = ChangeSource.TEXT
|
||||||
|
_query.value = QueryParser.parse(rawText)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateKinds(kinds: List<Int>) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
_query.value = _query.value.copy(kinds = kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the composable, only update text field when source != TEXT
|
||||||
|
val displayText by remember {
|
||||||
|
state.query.map { query ->
|
||||||
|
if (state.changeSource != ChangeSource.TEXT) {
|
||||||
|
QuerySerializer.serialize(query)
|
||||||
|
} else {
|
||||||
|
// Keep user's raw text as-is
|
||||||
|
state.rawText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use `TextFieldValue` (not raw String)** for the text bar to preserve cursor position during form-driven updates. When form changes update the serialized text, set `TextFieldValue(text = newText, selection = TextRange(newText.length))`.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Text Bar ──parse──> SearchQuery <──serialize── Form Panel
|
||||||
|
|
|
||||||
|
FilterBuilder
|
||||||
|
|
|
||||||
|
List<Filter> (split by kind groups, ~10 kinds each)
|
||||||
|
|
|
||||||
|
Relay Subscriptions (NIP-50)
|
||||||
|
|
|
||||||
|
Client-side Post-filter (exclusions, reply/media detection)
|
||||||
|
|
|
||||||
|
Results Display (grouped: People, Notes, Articles, Channels)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Single source of truth:** `MutableStateFlow<SearchQuery>`. Both text bar and form read from it. Text bar changes → `QueryParser` → `SearchQuery`. Form changes → mutate `SearchQuery` directly. `QuerySerializer` regenerates text string. `sourceOfChange` discriminator prevents update loops.
|
||||||
|
|
||||||
|
**Debounce strategy:** Text input debounced 300ms (existing pattern). Form toggle changes trigger immediate search (no debounce).
|
||||||
|
|
||||||
|
#### Research Insights: Kotlin State Patterns
|
||||||
|
|
||||||
|
**`@Immutable` on SearchQuery** — enables Compose to skip recomposition when query hasn't changed:
|
||||||
|
```kotlin
|
||||||
|
@Immutable
|
||||||
|
data class SearchQuery(
|
||||||
|
val text: String = "",
|
||||||
|
val authors: ImmutableList<String> = persistentListOf(),
|
||||||
|
val kinds: ImmutableList<Int> = persistentListOf(),
|
||||||
|
// ...
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val EMPTY = SearchQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `kotlinx.collections.immutable` (`ImmutableList`, `ImmutableSet`, `persistentListOf()`) for all collection fields. This gives Compose structural stability guarantees.
|
||||||
|
|
||||||
|
**Granular derived StateFlows** with `distinctUntilChanged()` to prevent unnecessary recomposition:
|
||||||
|
```kotlin
|
||||||
|
val kindsForUI: StateFlow<ImmutableList<Int>> = _query
|
||||||
|
.map { it.kinds }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), persistentListOf())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow Detail
|
||||||
|
|
||||||
|
```
|
||||||
|
SearchQuery (SSOT)
|
||||||
|
│
|
||||||
|
├─ text bar reads: QuerySerializer.serialize(query) → displayed string
|
||||||
|
│ └─ on text change: QueryParser.parse(rawText) → new SearchQuery
|
||||||
|
│ └─ GUARD: only serialize→display when changeSource != TEXT
|
||||||
|
│
|
||||||
|
├─ form panel reads: query.kinds, query.authors, query.since, etc.
|
||||||
|
│ └─ on form change: query.copy(kinds = ...) → new SearchQuery
|
||||||
|
│
|
||||||
|
└─ relay layer reads: SearchFilterFactory.createFilters(query) → List<Filter>
|
||||||
|
└─ subscription created per filter group
|
||||||
|
└─ OR queries: parallel subscriptions via merge(), results deduped by event ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Phases
|
||||||
|
|
||||||
|
#### Phase 1: Query Engine (commons/commonMain) — Foundation
|
||||||
|
|
||||||
|
Pure Kotlin, no UI, exhaustively unit-tested.
|
||||||
|
|
||||||
|
**Step 1.1: `SearchQuery` data class**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonMain/.../search/SearchQuery.kt
|
||||||
|
@Immutable
|
||||||
|
data class SearchQuery(
|
||||||
|
val text: String = "", // Free text for NIP-50 search field
|
||||||
|
val authors: ImmutableList<String> = persistentListOf(), // Hex pubkeys
|
||||||
|
val authorNames: ImmutableList<String> = persistentListOf(), // Unresolved names (for display)
|
||||||
|
val kinds: ImmutableList<Int> = persistentListOf(), // Empty = all searchable kinds
|
||||||
|
val since: Long? = null, // Unix timestamp
|
||||||
|
val until: Long? = null,
|
||||||
|
val hashtags: ImmutableList<String> = persistentListOf(), // Without # prefix
|
||||||
|
val excludeTerms: ImmutableList<String> = persistentListOf(), // Client-side exclusion
|
||||||
|
val language: String? = null, // ISO 639-1
|
||||||
|
val domain: String? = null, // NIP-05 domain
|
||||||
|
val orTerms: ImmutableList<String> = persistentListOf(), // Terms joined by OR
|
||||||
|
) {
|
||||||
|
val isEmpty get() = text.isBlank() && authors.isEmpty() && kinds.isEmpty()
|
||||||
|
&& since == null && until == null && hashtags.isEmpty()
|
||||||
|
&& orTerms.isEmpty()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val EMPTY = SearchQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Research Insights: Pseudo-Kinds
|
||||||
|
|
||||||
|
**Separate pseudo-kinds from real kinds.** `kind:reply` and `kind:media` are NOT relay filter kinds — they require client-side post-filtering:
|
||||||
|
- `kind:reply` = kind 1 events WITH `e` tag
|
||||||
|
- `kind:media` = kind 1 events WITH `imeta` tag or image URLs
|
||||||
|
|
||||||
|
The `SearchQuery` should track these separately or the `KindRegistry` should flag them. Recommended: a `pseudoKinds: Set<PseudoKind>` field or handle in `SearchResultFilter`.
|
||||||
|
|
||||||
|
**Step 1.2: Kind alias registry**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonMain/.../search/KindRegistry.kt
|
||||||
|
object KindRegistry {
|
||||||
|
// Import quartz KIND constants instead of hardcoding numbers
|
||||||
|
val aliases: Map<String, List<Int>> = mapOf(
|
||||||
|
"note" to listOf(1),
|
||||||
|
"article" to listOf(30023),
|
||||||
|
"repost" to listOf(6),
|
||||||
|
"profile" to listOf(0),
|
||||||
|
"channel" to listOf(40, 41, 42),
|
||||||
|
"live" to listOf(30311),
|
||||||
|
"community" to listOf(34550),
|
||||||
|
"wiki" to listOf(30818),
|
||||||
|
"video" to listOf(34235),
|
||||||
|
"classified" to listOf(30402),
|
||||||
|
"highlight" to listOf(9802),
|
||||||
|
"poll" to listOf(6969),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pseudo-kinds: client-side post-filters, not relay kinds
|
||||||
|
val pseudoKinds: Set<String> = setOf("reply", "media")
|
||||||
|
|
||||||
|
val presets: Map<String, List<Int>> = mapOf(
|
||||||
|
"Notes" to listOf(1),
|
||||||
|
"Articles" to listOf(30023),
|
||||||
|
"Media" to listOf(1), // post-filtered for imeta tag
|
||||||
|
"Channels" to listOf(40, 41, 42),
|
||||||
|
"Communities" to listOf(34550),
|
||||||
|
"Wiki" to listOf(30818),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun resolve(alias: String): List<Int>? = aliases[alias.lowercase()]
|
||||||
|
fun isPseudoKind(alias: String): Boolean = alias.lowercase() in pseudoKinds
|
||||||
|
fun nameFor(kind: Int): String? = aliases.entries.find { kind in it.value }?.key
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1.3: `QueryParser`**
|
||||||
|
|
||||||
|
#### Research Insights: Parser Architecture
|
||||||
|
|
||||||
|
**Hand-written recursive descent parser** (not regex, not parser generators). Two-phase:
|
||||||
|
|
||||||
|
1. **Tokenizer** (state machine): Walks characters, emits tokens: `OperatorToken(name, value)`, `TextToken(value)`, `OrToken`, `QuotedToken(value)`, `NegationToken(value)`, `HashtagToken(value)`
|
||||||
|
2. **Parser** (recursive descent): Consumes tokens, builds `SearchQuery`
|
||||||
|
|
||||||
|
**Key principles:**
|
||||||
|
- **Preserve raw text in tokens** for roundtrip fidelity (`serialize(parse(input))` ≈ `input`)
|
||||||
|
- **Error recovery**: malformed operators degrade to literal text, never throw
|
||||||
|
- **OR precedence**: OR binds to adjacent text terms only. Operators are always AND.
|
||||||
|
- `from:vitor bitcoin OR lightning kind:note` = `from:vitor AND kind:note AND (bitcoin OR lightning)`
|
||||||
|
- **Performance**: sub-microsecond parsing, not a concern
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonMain/.../search/QueryParser.kt
|
||||||
|
object QueryParser {
|
||||||
|
fun parse(input: String): SearchQuery {
|
||||||
|
val tokens = tokenize(input)
|
||||||
|
return buildQuery(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokenize(input: String): List<Token> { /* state machine */ }
|
||||||
|
private fun buildQuery(tokens: List<Token>): SearchQuery { /* recursive descent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface Token {
|
||||||
|
data class Operator(val name: String, val value: String, val raw: String) : Token
|
||||||
|
data class Text(val value: String) : Token
|
||||||
|
data object Or : Token
|
||||||
|
data class Quoted(val value: String, val raw: String) : Token
|
||||||
|
data class Negation(val term: String) : Token
|
||||||
|
data class Hashtag(val tag: String) : Token
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Case-insensitive operator matching (`FROM:` = `from:`)
|
||||||
|
- `from:<value>` → if bech32 npub, decode to hex and add to `authors`; else add to `authorNames` (async resolution)
|
||||||
|
- `kind:<value>` → resolve via `KindRegistry.resolve()` or parse as int. Flag pseudo-kinds separately.
|
||||||
|
- `since:<date>` / `until:<date>` → parse ISO 8601 (`2025-01-01`, `2025-01`, `2025`) to unix timestamp
|
||||||
|
- `#tag` → add to `hashtags`
|
||||||
|
- `"quoted phrase"` → keep in `text` as quoted
|
||||||
|
- `-term` → add to `excludeTerms`, strip from relay search string
|
||||||
|
- `OR` → split adjacent free text terms. `bitcoin OR lightning` → `orTerms = ["bitcoin", "lightning"]`
|
||||||
|
- Multiple `from:` → AND (multiple authors)
|
||||||
|
- Multiple `kind:` → union (combined kinds)
|
||||||
|
- Incomplete operators (`from:` with no value) → treat as literal text
|
||||||
|
|
||||||
|
**Step 1.4: `QuerySerializer`**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonMain/.../search/QuerySerializer.kt
|
||||||
|
object QuerySerializer {
|
||||||
|
fun serialize(query: SearchQuery): String { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerates the canonical text representation from `SearchQuery`. Used to update text bar when form changes. Ordering: operators first (`from:`, `kind:`, `since:`, `until:`, `lang:`, `domain:`), then hashtags, then free text / OR terms, then exclusions.
|
||||||
|
|
||||||
|
**Step 1.5: Unit tests**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonTest/.../search/QueryParserTest.kt
|
||||||
|
// commons/src/commonTest/.../search/QuerySerializerTest.kt
|
||||||
|
// commons/src/commonTest/.../search/KindRegistryTest.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
Test matrix:
|
||||||
|
- Single operator of each type
|
||||||
|
- Combined operators
|
||||||
|
- OR with operators
|
||||||
|
- Malformed/incomplete (`from:`, `kind:invalid`, `since:not-a-date`)
|
||||||
|
- Special characters, emoji, unicode in free text
|
||||||
|
- Roundtrip: `serialize(parse(input)) == normalized(input)`
|
||||||
|
- Multiple `from:` authors
|
||||||
|
- Multiple `kind:` (union)
|
||||||
|
- Quoted phrases
|
||||||
|
- Exclusion terms
|
||||||
|
- Pseudo-kind detection (`kind:reply`, `kind:media`)
|
||||||
|
- Edge: empty string, whitespace only, very long query
|
||||||
|
- OR precedence: `from:x a OR b kind:note` → operators AND, text OR
|
||||||
|
|
||||||
|
**Consider property-based testing** with Kotest for roundtrip fidelity.
|
||||||
|
|
||||||
|
#### Phase 2: Filter Factory + Relay Integration (desktopApp)
|
||||||
|
|
||||||
|
**Step 2.1: `SearchFilterFactory`**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// desktopApp/src/jvmMain/.../subscriptions/SearchFilterFactory.kt
|
||||||
|
object SearchFilterFactory {
|
||||||
|
fun createFilters(query: SearchQuery): List<Filter> { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- If `query.kinds` specified → use those kinds directly
|
||||||
|
- If `query.kinds` empty → use default searchable kinds (align with Android's 3 groups)
|
||||||
|
- Split kinds into groups of ~10 (relay `max_filters` limit safety)
|
||||||
|
- Build NIP-50 search string: `query.text` + inline NIP-50 extensions (`language:en`, `domain:x`)
|
||||||
|
- Strip exclusion terms from search string (don't send `-spam` to relay)
|
||||||
|
- `query.authors` → `Filter.authors` (only resolved hex keys)
|
||||||
|
- `query.since` / `query.until` → `Filter.since` / `Filter.until`
|
||||||
|
- `query.hashtags` → `Filter.tags["t"]` (not in search string — more reliable)
|
||||||
|
- OR queries: return separate filter lists per OR term
|
||||||
|
|
||||||
|
#### Research Insights: Module Placement
|
||||||
|
|
||||||
|
**SearchFilterFactory stays in desktopApp** — it depends on `SubscriptionConfig` and relay topology, which are desktop-specific. Correct as planned.
|
||||||
|
|
||||||
|
**SearchResultFilter can move to commons/commonMain** — pure Kotlin, no platform dependencies. Android can reuse it later.
|
||||||
|
|
||||||
|
**SearchHistoryStore can move to commons as expect/actual** — follows `SecureKeyStorage` pattern. `expect class SearchHistoryStore`, with `actual` implementations using `java.util.prefs.Preferences` on desktop and SharedPreferences/DataStore on Android.
|
||||||
|
|
||||||
|
**Step 2.2: Default searchable kind groups**
|
||||||
|
|
||||||
|
Port from Android's `SearchPostsByText.kt` to desktop. Reference the same kinds:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Group 1: TextNote, LongText, Badge, PeopleList, BookmarkList, AudioHeader, AudioTrack, PinList, PollNote, ChannelCreate
|
||||||
|
// Group 2: ChannelMetadata, Classifieds, Community, EmojiPack, Highlight, LiveActivities, PublicMessage, NNS, Wiki, Comment
|
||||||
|
// Group 3: InteractiveStory (2 kinds), FollowList, NipText, Poll, PollResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
Kind group splitting is relay-imposed (`max_filters` limits), not protocol. Use the quartz KIND constants, don't hardcode numbers.
|
||||||
|
|
||||||
|
**Step 2.3: Search subscription factory**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// desktopApp/src/jvmMain/.../subscriptions/FeedSubscription.kt (extend)
|
||||||
|
fun createAdvancedSearchSubscription(
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
query: SearchQuery,
|
||||||
|
onEvent: ...,
|
||||||
|
onEose: ...,
|
||||||
|
): List<SubscriptionConfig>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Research Insights: Subscription Management
|
||||||
|
|
||||||
|
**Use `flatMapLatest`** on debouncedQuery to auto-cancel old subscriptions when query changes:
|
||||||
|
```kotlin
|
||||||
|
val results: Flow<List<Event>> = debouncedQuery
|
||||||
|
.flatMapLatest { query ->
|
||||||
|
if (query.isEmpty) flowOf(emptyList())
|
||||||
|
else channelFlow {
|
||||||
|
supervisorScope {
|
||||||
|
val filters = SearchFilterFactory.createFilters(query)
|
||||||
|
// Launch independent subscription per filter group
|
||||||
|
filters.forEach { filter ->
|
||||||
|
launch { subscribeAndEmit(filter, relays) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`supervisorScope`** for independent relay subscription failure isolation — one relay failure doesn't cancel others.
|
||||||
|
|
||||||
|
**`merge()` (not `combine()`)** for OR query result flows — emit results as they arrive from any term.
|
||||||
|
|
||||||
|
**Batch filters per OR term** in a single REQ (not per kind group), reducing subscription count:
|
||||||
|
- 3 OR terms × 1 batched REQ × 3 relays = 9 subscriptions (vs 45 if unbatched)
|
||||||
|
|
||||||
|
**Cap: max 3 OR terms** (not 5) — subscription fan-out gets expensive.
|
||||||
|
|
||||||
|
**Step 2.4: Client-side post-filter**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonMain/.../search/SearchResultFilter.kt
|
||||||
|
object SearchResultFilter {
|
||||||
|
fun filter(events: List<Event>, query: SearchQuery): List<Event>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-term` exclusion: check `event.content` doesn't contain term (case-insensitive)
|
||||||
|
- `kind:reply` detection: kind 1 with `e` tag
|
||||||
|
- `kind:media` detection: kind 1 with `imeta` tag or URL patterns
|
||||||
|
- Deduplication by event ID (for OR query merges)
|
||||||
|
|
||||||
|
#### Research Insights: Performance
|
||||||
|
|
||||||
|
**Batch result accumulation** — current Amethyst pattern of `_results.value = _results.value + item` is O(n^2). Use channel-based batching:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
channelFlow {
|
||||||
|
val batch = mutableSetOf<Event>() // Set for O(1) dedup
|
||||||
|
var lastEmit = 0L
|
||||||
|
|
||||||
|
onEvent = { event ->
|
||||||
|
batch.add(event)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastEmit > 100) { // 100ms batch window
|
||||||
|
send(batch.toList())
|
||||||
|
lastEmit = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply post-filter at batch emission time**, not per-event.
|
||||||
|
|
||||||
|
**Result ordering:** dedup by event ID, sort by `createdAt` descending (match Amethyst Android behavior).
|
||||||
|
|
||||||
|
#### Phase 3: Advanced Search State (commons/commonMain)
|
||||||
|
|
||||||
|
**Step 3.1: `AdvancedSearchBarState`**
|
||||||
|
|
||||||
|
New state holder that extends/replaces `SearchBarState`. Manages the `SearchQuery` as SSOT.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// commons/src/commonMain/.../viewmodels/AdvancedSearchBarState.kt
|
||||||
|
class AdvancedSearchBarState(
|
||||||
|
private val cache: ICacheProvider,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val _query = MutableStateFlow(SearchQuery.EMPTY)
|
||||||
|
val query: StateFlow<SearchQuery> = _query.asStateFlow()
|
||||||
|
|
||||||
|
// Track who initiated the change (prevents sync loops)
|
||||||
|
private var _changeSource: ChangeSource = ChangeSource.INIT
|
||||||
|
val changeSource get() = _changeSource
|
||||||
|
|
||||||
|
// Raw text from user typing (preserved when source=TEXT)
|
||||||
|
private val _rawText = MutableStateFlow("")
|
||||||
|
val rawText: StateFlow<String> = _rawText.asStateFlow()
|
||||||
|
|
||||||
|
// Derived: text representation for the search bar
|
||||||
|
val displayText: StateFlow<String> = combine(_query, _rawText) { query, raw ->
|
||||||
|
if (_changeSource == ChangeSource.TEXT) raw
|
||||||
|
else QuerySerializer.serialize(query)
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, "")
|
||||||
|
|
||||||
|
// For relay subscriptions to observe (300ms debounce)
|
||||||
|
val debouncedQuery: StateFlow<SearchQuery> = _query
|
||||||
|
.debounce(300)
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY)
|
||||||
|
|
||||||
|
// Results
|
||||||
|
val peopleResults: StateFlow<ImmutableList<User>>
|
||||||
|
val noteResults: StateFlow<ImmutableList<Event>>
|
||||||
|
val isSearching: StateFlow<Boolean>
|
||||||
|
|
||||||
|
// Text bar input (parses into SearchQuery)
|
||||||
|
fun updateFromText(rawText: String) {
|
||||||
|
_changeSource = ChangeSource.TEXT
|
||||||
|
_rawText.value = rawText
|
||||||
|
_query.value = QueryParser.parse(rawText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form panel input (mutates SearchQuery directly)
|
||||||
|
fun updateKinds(kinds: List<Int>) {
|
||||||
|
_changeSource = ChangeSource.FORM
|
||||||
|
_query.value = _query.value.copy(kinds = kinds.toImmutableList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addAuthor(hexOrName: String) { ... }
|
||||||
|
fun removeAuthor(hex: String) { ... }
|
||||||
|
fun updateDateRange(since: Long?, until: Long?) { ... }
|
||||||
|
fun addHashtag(tag: String) { ... }
|
||||||
|
fun removeHashtag(tag: String) { ... }
|
||||||
|
fun addExcludeTerm(term: String) { ... }
|
||||||
|
fun updateLanguage(lang: String?) { ... }
|
||||||
|
|
||||||
|
// Name resolution (async)
|
||||||
|
fun resolveAuthorName(name: String, onResolved: (String) -> Unit)
|
||||||
|
|
||||||
|
// History
|
||||||
|
fun addToHistory(query: SearchQuery)
|
||||||
|
fun getHistory(): List<SearchQuery>
|
||||||
|
fun saveSearch(query: SearchQuery, label: String)
|
||||||
|
fun getSavedSearches(): List<SavedSearch>
|
||||||
|
fun deleteSavedSearch(id: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ChangeSource { TEXT, FORM, INIT }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3.2: Name resolution**
|
||||||
|
|
||||||
|
- Check local cache first: `cache.findUsersStartingWith(name, 5)`
|
||||||
|
- If multiple matches → expose as `authorSuggestions: StateFlow<List<User>>` for autocomplete dropdown
|
||||||
|
- If single match → auto-resolve to hex key
|
||||||
|
- If no local match → keep as `authorNames` (display in form as "unresolved: vitor")
|
||||||
|
- Keep name resolution **out of QueryParser** — parser returns raw strings, platform layer resolves
|
||||||
|
- No NIP-05 resolution in v1. Follow-up.
|
||||||
|
|
||||||
|
#### Phase 4: Desktop UI (desktopApp)
|
||||||
|
|
||||||
|
**Step 4.1: Rewrite `SearchScreen.kt`**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// desktopApp/src/jvmMain/.../ui/SearchScreen.kt
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
localCache: DesktopLocalCache,
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
...
|
||||||
|
) {
|
||||||
|
val state = remember { AdvancedSearchBarState(localCache, scope) }
|
||||||
|
val query by state.query.collectAsState()
|
||||||
|
val displayText by state.displayText.collectAsState()
|
||||||
|
var panelExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
// Search bar row
|
||||||
|
Row {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = TextFieldValue(
|
||||||
|
text = displayText,
|
||||||
|
selection = TextRange(displayText.length),
|
||||||
|
),
|
||||||
|
onValueChange = { state.updateFromText(it.text) },
|
||||||
|
placeholder = { Text("Search notes, people, tags... or use operators") },
|
||||||
|
...
|
||||||
|
)
|
||||||
|
TextButton(onClick = { panelExpanded = !panelExpanded }) {
|
||||||
|
Text(if (panelExpanded) "Advanced ^" else "Advanced v")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expandable advanced panel
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = panelExpanded,
|
||||||
|
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
|
||||||
|
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
|
||||||
|
) {
|
||||||
|
AdvancedSearchPanel(
|
||||||
|
query = query,
|
||||||
|
onKindsChanged = { state.updateKinds(it) },
|
||||||
|
onAuthorAdded = { state.addAuthor(it) },
|
||||||
|
onAuthorRemoved = { state.removeAuthor(it) },
|
||||||
|
onDateRangeChanged = { since, until -> state.updateDateRange(since, until) },
|
||||||
|
...
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results
|
||||||
|
SearchResultsList(state = state, ...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Research Insights: Compose UI Patterns
|
||||||
|
|
||||||
|
**Panel animation:** `expandVertically(expandFrom = Alignment.Top)` + `fadeIn()` — panel slides down from search bar, feels natural.
|
||||||
|
|
||||||
|
**Kind preset chips:** Use `FilterChip` (not ElevatedFilterChip or AssistChip):
|
||||||
|
```kotlin
|
||||||
|
KindRegistry.presets.forEach { (name, kinds) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = query.kinds.containsAll(kinds),
|
||||||
|
onClick = { onKindsChanged(toggleKinds(query.kinds, kinds)) },
|
||||||
|
label = { Text(name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Author autocomplete:** `DropdownMenu` (not `Popup`) — handles dismissal, positioning, focus correctly.
|
||||||
|
|
||||||
|
**Date input:** Text fields with `YYYY-MM-DD` format (no native date picker on desktop). Validate on blur.
|
||||||
|
|
||||||
|
**Keyboard events:** `onPreviewKeyEvent` for Escape (before children), `onKeyEvent` for `/` (after children).
|
||||||
|
|
||||||
|
**Step 4.2: `AdvancedSearchPanel` composable**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// desktopApp/src/jvmMain/.../ui/search/AdvancedSearchPanel.kt
|
||||||
|
@Composable
|
||||||
|
fun AdvancedSearchPanel(
|
||||||
|
query: SearchQuery,
|
||||||
|
onKindsChanged: (List<Int>) -> Unit,
|
||||||
|
onAuthorAdded: (String) -> Unit,
|
||||||
|
...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Components:
|
||||||
|
- **Content type row**: `FilterChip` per preset from `KindRegistry.presets`. Checked state derived from `query.kinds`.
|
||||||
|
- **Author field**: `OutlinedTextField` + `DropdownMenu` autocomplete dropdown (from `authorSuggestions`). Shows chips for added authors.
|
||||||
|
- **Date range**: Two date text fields (`yyyy-MM-dd` format). Validate on blur.
|
||||||
|
- **Hashtags**: Chip group with add button.
|
||||||
|
- **Exclude terms**: Chip group with add button.
|
||||||
|
- **Language dropdown**: `DropdownMenu` with common ISO 639-1 codes.
|
||||||
|
- **Clear / Search buttons**: Clear resets `SearchQuery.EMPTY`. Search is implicit (debounced).
|
||||||
|
- **Tooltips**: `TooltipBox` + `PlainTooltip` for operator hint text on hover.
|
||||||
|
|
||||||
|
**Step 4.3: `SearchResultsList` composable**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// desktopApp/src/jvmMain/.../ui/search/SearchResultsList.kt
|
||||||
|
@Composable
|
||||||
|
fun SearchResultsList(state: AdvancedSearchBarState, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Research Insights: Results Display
|
||||||
|
|
||||||
|
- Single `LazyColumn` with **sticky section headers**: `stickyHeader { Surface(color = background) { ... } }`
|
||||||
|
- Sections: **People** (kind 0), **Notes** (kind 1), **Articles** (kind 30023), **Other** (everything else)
|
||||||
|
- Each section shows top 5 results with "Show all N" expand link
|
||||||
|
- Note results: content preview (first 200 chars), author name, timestamp, kind badge
|
||||||
|
- Progressive loading: results stream in as relay responds, sections update live
|
||||||
|
- **Shimmer loading**: Custom shimmer via `Brush.linearGradient` + `InfiniteTransition` (reusable, put in commons)
|
||||||
|
- Empty state: "No results found. Try broader terms or fewer filters."
|
||||||
|
- **Stable keys** for LazyColumn items: `key = { "section-${event.id}" }` to prevent recomposition flicker
|
||||||
|
|
||||||
|
**Step 4.4: Relay subscription wiring**
|
||||||
|
|
||||||
|
In `SearchScreen.kt`, use `rememberSubscription()` with the debounced query:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val debouncedQuery by state.debouncedQuery.collectAsState()
|
||||||
|
val configuredRelays by remember {
|
||||||
|
relayManager.relayStatuses
|
||||||
|
.map { it.keys }
|
||||||
|
.distinctUntilChanged() // Prevent churn (FeedScreen pattern)
|
||||||
|
}.collectAsState(emptySet())
|
||||||
|
|
||||||
|
// Create subscriptions from query
|
||||||
|
val filters = remember(debouncedQuery) { SearchFilterFactory.createFilters(debouncedQuery) }
|
||||||
|
// ... wire up rememberSubscription per filter group
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Research Insights: Desktop-Specific Patterns
|
||||||
|
|
||||||
|
**Keyboard shortcuts:**
|
||||||
|
- `Ctrl+K` or `/` → focus search bar. Use `Window.onKeyEvent` for `/` (after children process), `onPreviewKeyEvent` for Escape.
|
||||||
|
- `Escape` → close advanced panel / clear search
|
||||||
|
- `Enter` → execute search immediately (skip debounce)
|
||||||
|
- Register `Ctrl+K` in `MenuBar { Item("Search", KeyShortcut(Key.K, ctrl = true)) { focusSearch() } }`
|
||||||
|
|
||||||
|
**Clipboard:** Support pasting npub/note/nevent directly into search bar — already handled by `QueryParser` treating bech32 as `from:` equivalent.
|
||||||
|
|
||||||
|
#### Phase 5: Search History + Saved Searches
|
||||||
|
|
||||||
|
**Step 5.1: Local persistence**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// desktopApp/src/jvmMain/.../storage/SearchHistoryStore.kt
|
||||||
|
class SearchHistoryStore(private val appDataDir: Path) {
|
||||||
|
private val historyFile = appDataDir / "search_history.json"
|
||||||
|
private val savedFile = appDataDir / "saved_searches.json"
|
||||||
|
|
||||||
|
// In-memory cache, async persist on Dispatchers.IO
|
||||||
|
private var historyCache: MutableList<SearchQuery> = mutableListOf()
|
||||||
|
|
||||||
|
fun addToHistory(query: SearchQuery) // Dedup by serialized text, max 20 entries
|
||||||
|
fun getHistory(): List<SearchQuery>
|
||||||
|
fun clearHistory()
|
||||||
|
|
||||||
|
fun saveSearch(query: SearchQuery, label: String)
|
||||||
|
fun getSavedSearches(): List<SavedSearch>
|
||||||
|
fun deleteSavedSearch(id: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SavedSearch(
|
||||||
|
val id: String, // UUID
|
||||||
|
val label: String,
|
||||||
|
val query: SearchQuery,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON serialization via kotlinx.serialization (already in project).
|
||||||
|
|
||||||
|
**Platform data dirs:** macOS `~/Library/Application Support/Amethyst/`, Linux `~/.config/amethyst/`, Windows `%APPDATA%\Amethyst\`. Use existing `DesktopPreferences.kt` pattern or `java.util.prefs.Preferences`.
|
||||||
|
|
||||||
|
**Step 5.2: History UI**
|
||||||
|
|
||||||
|
When search bar is empty → show recent history + saved searches below the bar.
|
||||||
|
- History items: click to load query into search bar
|
||||||
|
- Saved searches: click to load, X to delete
|
||||||
|
- "Clear history" button at bottom
|
||||||
|
|
||||||
|
#### Phase 6: Integration + Polish
|
||||||
|
|
||||||
|
**Step 6.1: Search hints update**
|
||||||
|
|
||||||
|
Update empty state hints to show operator examples:
|
||||||
|
```
|
||||||
|
from:npub1... Filter by author
|
||||||
|
kind:article Long-form content
|
||||||
|
since:2025-01 After January 2025
|
||||||
|
#bitcoin Hashtag search
|
||||||
|
"exact phrase" Exact match
|
||||||
|
bitcoin OR nostr Either term
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6.2: Keyboard shortcuts**
|
||||||
|
|
||||||
|
- `Ctrl+K` or `/` → focus search bar (desktop convention)
|
||||||
|
- `Escape` → close advanced panel / clear search
|
||||||
|
- `Enter` → execute search immediately (skip debounce)
|
||||||
|
|
||||||
|
**Step 6.3: Search relay configuration**
|
||||||
|
|
||||||
|
- Desktop settings page: list of search relays (editable)
|
||||||
|
- Default: `relay.nostr.band`, `nostr.wine`, `relay.damus.io` (curated, don't auto-probe NIP-11)
|
||||||
|
- Future: read from kind 10007 `SearchRelayListEvent`
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
### Interaction Graph
|
||||||
|
|
||||||
|
1. User types in search bar → `AdvancedSearchBarState.updateFromText()` → `QueryParser.parse()` → `_query` updates
|
||||||
|
2. `_query` change → `displayText` recomputes (serialized, guarded by `changeSource`) → text bar updates
|
||||||
|
3. `_query` change → `debouncedQuery` emits after 300ms → `flatMapLatest` cancels old subscriptions → new relay subscriptions created
|
||||||
|
4. Subscription creation → `relayManager.subscribe()` → relay receives REQ
|
||||||
|
5. Relay responds → `onEvent` callback → events batched (100ms windows) → stored in cache + state
|
||||||
|
6. Post-filter applied at batch emission → results displayed in `SearchResultsList`
|
||||||
|
|
||||||
|
### Error Propagation
|
||||||
|
|
||||||
|
- Relay timeout → `onEose` fires → `isSearching` set to false → "No results" shown
|
||||||
|
- Name resolution failure → name stays in `authorNames` as unresolved → user sees "unresolved: vitor" chip
|
||||||
|
- Parse error → malformed operators treated as literal text → no crash, graceful degradation
|
||||||
|
- Non-NIP-50 relay → relay ignores `search` field, returns nothing useful → handled by showing results from other relays
|
||||||
|
- Individual relay failure → `supervisorScope` isolates failure → other relays continue
|
||||||
|
|
||||||
|
### State Lifecycle Risks
|
||||||
|
|
||||||
|
- **Subscription churn**: Mitigated by `distinctUntilChanged()` on relay statuses (proven pattern from FeedScreen)
|
||||||
|
- **Bidirectional update loop**: Prevented by `sourceOfChange` discriminator — TEXT changes don't trigger re-serialization
|
||||||
|
- **Stale results**: Session-only cache cleared on restart. `flatMapLatest` clears previous subscription results on new query.
|
||||||
|
- **Memory pressure from result batching**: Bounded by LRU cache (500 entries) and batch window (100ms)
|
||||||
|
|
||||||
|
### API Surface Parity
|
||||||
|
|
||||||
|
- `SearchBarState` in commons is used by both Android and Desktop today. `AdvancedSearchBarState` extends this pattern but is new.
|
||||||
|
- `QueryParser`, `SearchQuery`, `KindRegistry`, `SearchResultFilter` placed in commons so Android can adopt later.
|
||||||
|
- Desktop `FilterBuilders` gets new `searchAdvanced()` methods but existing methods unchanged.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
- [x] Query operators parse correctly: `from:`, `kind:`, `since:`, `until:`, `#tag`, `"phrase"`, `-exclude`, `OR`, `lang:`, `domain:`
|
||||||
|
- [x] Kind aliases resolve: `kind:note` → kind 1, `kind:article` → kind 30023, etc.
|
||||||
|
- [x] Pseudo-kinds handled: `kind:reply` and `kind:media` flagged for client-side post-filtering
|
||||||
|
- [x] Advanced panel expands/collapses below search bar with `expandVertically` + `fadeIn` animation
|
||||||
|
- [x] Bidirectional sync: text changes update form, form changes update text, no loops (sourceOfChange guard)
|
||||||
|
- [x] Default search (no kind filter) queries 30+ kinds across 3 filter groups (Android parity)
|
||||||
|
- [x] OR queries (`bitcoin OR lightning`) send parallel subscriptions, merge + dedup results (max 3 terms)
|
||||||
|
- [x] Results grouped by type: People, Notes, Articles, Other with sticky section headers
|
||||||
|
- [x] Note results show content preview, author, timestamp, kind badge
|
||||||
|
- [x] Search history persists last 20 queries locally
|
||||||
|
- [x] Saved searches persist across sessions
|
||||||
|
- [x] Exclusion terms (`-spam`) filtered client-side, not sent to relay
|
||||||
|
- [x] Empty search bar shows history + saved searches + operator hints
|
||||||
|
- [x] `Escape` closes panel / clears search (Ctrl+K deferred — needs window-level handler)
|
||||||
|
|
||||||
|
### Non-Functional
|
||||||
|
|
||||||
|
- [x] Search debounce: 300ms for text, immediate for form toggles
|
||||||
|
- [x] Max 3 OR terms, 10 authors per query
|
||||||
|
- [x] Relay subscription churn prevented via `distinctUntilChanged()`
|
||||||
|
- [x] Result accumulation uses set-based dedup
|
||||||
|
- [x] `@Immutable` SearchQuery with `ImmutableList` fields
|
||||||
|
- [x] Session cache cleared on restart
|
||||||
|
- [x] All query parsing logic unit-tested in commons (roundtrip, edge cases, malformed input)
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
- [x] `QueryParser` + `QuerySerializer` roundtrip tests pass
|
||||||
|
- [x] `KindRegistry` tests for all aliases + pseudo-kinds
|
||||||
|
- [x] `SearchFilterFactory` compiles + filter generation correct
|
||||||
|
- [x] `SearchResultFilter` handles exclusion, reply detection, media detection
|
||||||
|
- [x] Desktop search screen renders results for all kind types
|
||||||
|
- [x] `spotlessApply` passes
|
||||||
|
|
||||||
|
## Dependencies & Prerequisites
|
||||||
|
|
||||||
|
| Dependency | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| `Filter.search` field | Exists | `quartz/.../Filter.kt` |
|
||||||
|
| `SearchRelayListEvent` | Exists | `quartz/.../SearchRelayListEvent.kt` (kind 10007) |
|
||||||
|
| `SearchBarState` | Exists | `commons/.../SearchBarState.kt` — will be extended |
|
||||||
|
| `SearchParser` | Exists | `commons/.../SearchParser.kt` — bech32 parsing, kept as-is |
|
||||||
|
| `FilterBuilders` | Exists | `desktopApp/.../FilterBuilders.kt` — extended |
|
||||||
|
| `rememberSubscription()` | Exists | `desktopApp/.../SubscriptionUtils.kt` |
|
||||||
|
| `DesktopLocalCache` | Exists | Needs `findNotesStartingWith()` for local note search |
|
||||||
|
| kotlinx.serialization | In project | For search history JSON persistence |
|
||||||
|
| kotlinx.collections.immutable | **Add** | For `ImmutableList`/`persistentListOf()` in SearchQuery |
|
||||||
|
|
||||||
|
## Risk Analysis & Mitigation
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Bidirectional sync loops | Medium | High | `sourceOfChange` discriminator, TEXT changes preserve raw text |
|
||||||
|
| Relay subscription explosion (OR + many relays) | Medium | Medium | Cap: 3 OR terms, batch filters per term. Total max ~27 subs |
|
||||||
|
| NIP-50 relay variability | High | Medium | Graceful degradation — show whatever relays return |
|
||||||
|
| Name resolution UX confusion | Medium | Medium | Show "unresolved" indicator, autocomplete dropdown |
|
||||||
|
| O(n^2) result accumulation | Medium | Medium | Batch + set-based dedup via channelFlow |
|
||||||
|
| Large result sets from broad queries | High | Low | Client-side pagination, "Show more" per section |
|
||||||
|
| Android `SearchBarState` compatibility | Low | Medium | New `AdvancedSearchBarState`, old class untouched |
|
||||||
|
|
||||||
|
## Simplicity Guidance (MVP Scoping)
|
||||||
|
|
||||||
|
The full plan is comprehensive. If time-constrained, a minimal viable version can ship with:
|
||||||
|
|
||||||
|
**MVP (Phase 1+2+4 subset, ~350 LOC, 5 files):**
|
||||||
|
- `SearchQuery` data class (no `@Immutable` yet, plain lists)
|
||||||
|
- `QueryParser` with 5 operators: `from:`, `kind:`, `since:`, `until:`, `#tag`
|
||||||
|
- `SearchFilterFactory` for filter generation
|
||||||
|
- Rewritten `SearchScreen.kt` with form panel (no bidirectional sync — form→text only)
|
||||||
|
- Unit tests for parser
|
||||||
|
|
||||||
|
**Cut for MVP:**
|
||||||
|
- OR queries, `lang:`, `domain:`, `-exclude`
|
||||||
|
- `QuerySerializer` (not needed without bidirectional sync)
|
||||||
|
- Saved searches (history only)
|
||||||
|
- Shimmer loading states
|
||||||
|
- Keyboard shortcuts beyond Enter/Escape
|
||||||
|
|
||||||
|
**Add incrementally:** OR queries → bidirectional sync → saved searches → keyboard shortcuts → NIP-50 extensions
|
||||||
|
|
||||||
|
## File Matrix
|
||||||
|
|
||||||
|
| File | Status | Module | Action |
|
||||||
|
|------|--------|--------|--------|
|
||||||
|
| `SearchQuery.kt` | New | commons/commonMain | Create data class with `@Immutable` |
|
||||||
|
| `QueryParser.kt` | New | commons/commonMain | Create recursive descent parser |
|
||||||
|
| `QuerySerializer.kt` | New | commons/commonMain | Create serializer |
|
||||||
|
| `KindRegistry.kt` | New | commons/commonMain | Create kind alias registry |
|
||||||
|
| `AdvancedSearchBarState.kt` | New | commons/commonMain | Create state holder with `sourceOfChange` |
|
||||||
|
| `SearchResultFilter.kt` | New | commons/commonMain | Create post-filter (reusable) |
|
||||||
|
| `QueryParserTest.kt` | New | commons/commonTest | Create tests |
|
||||||
|
| `QuerySerializerTest.kt` | New | commons/commonTest | Create tests |
|
||||||
|
| `KindRegistryTest.kt` | New | commons/commonTest | Create tests |
|
||||||
|
| `SearchFilterFactory.kt` | New | desktopApp | Create filter factory |
|
||||||
|
| `AdvancedSearchPanel.kt` | New | desktopApp | Create form panel composable |
|
||||||
|
| `SearchResultsList.kt` | New | desktopApp | Create results list composable |
|
||||||
|
| `SearchHistoryStore.kt` | New | desktopApp | Create persistence |
|
||||||
|
| `SearchScreen.kt` | Rewrite | desktopApp | Integrate advanced search |
|
||||||
|
| `FeedSubscription.kt` | Extend | desktopApp | Add `createAdvancedSearchSubscription()` |
|
||||||
|
| `FilterBuilders.kt` | Extend | desktopApp | Add search filter methods |
|
||||||
|
| `SearchBarState.kt` | Keep | commons/commonMain | Untouched (backward compat) |
|
||||||
|
| `SearchParser.kt` | Keep | commons/commonMain | Untouched (bech32 parsing still used) |
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- **AI natural language → query** (deferred) — parse "notes about bitcoin from Jack since January" to operators
|
||||||
|
- **Local SQLite FTS5 index** — offline search for desktop
|
||||||
|
- **NIP-90 DVM search** — pay-per-query via Lightning
|
||||||
|
- **Search relay auto-discovery** — NIP-11 `supported_nips` check for NIP-50
|
||||||
|
- **NIP-05 name resolution** — async resolve `from:vitor@nostr.com`
|
||||||
|
- **Saved searches as Nostr events** — portable across devices
|
||||||
|
- **Search analytics** — trending topics, popular queries
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
### Origin
|
||||||
|
|
||||||
|
- **Brainstorm document:** [docs/brainstorms/2026-03-10-advanced-search-brainstorm.md](docs/brainstorms/2026-03-10-advanced-search-brainstorm.md) — Key decisions: relay-first approach, Twitter-style operators + form UI, extensible kind presets, AI deferred, session-only caching
|
||||||
|
|
||||||
|
### Internal References
|
||||||
|
|
||||||
|
- Current search screen: `desktopApp/.../ui/SearchScreen.kt`
|
||||||
|
- Search state: `commons/.../viewmodels/SearchBarState.kt`
|
||||||
|
- Bech32 parser: `commons/.../search/SearchParser.kt`
|
||||||
|
- Filter class: `quartz/.../nip01Core/relay/filters/Filter.kt`
|
||||||
|
- Android kind groups: `amethyst/.../searchCommand/subassemblies/SearchPostsByText.kt`
|
||||||
|
- FilterBuilders: `desktopApp/.../subscriptions/FilterBuilders.kt`
|
||||||
|
- Feed subscriptions: `desktopApp/.../subscriptions/FeedSubscription.kt`
|
||||||
|
- Relay subscription utils: `desktopApp/.../subscriptions/SubscriptionUtils.kt`
|
||||||
|
- Relay churn fix: `desktopApp/.../ui/FeedScreen.kt:163-167` (`distinctUntilChanged()` pattern)
|
||||||
|
|
||||||
|
### External References
|
||||||
|
|
||||||
|
- [NIP-50 Search](https://nips.nostr.com/50)
|
||||||
|
- [NIP-50 extensions](https://github.com/nostr-protocol/nips/blob/master/50.md): `language:`, `domain:`, `sentiment:`, `nsfw:`, `include:spam`
|
||||||
|
- [kotlinx.collections.immutable](https://github.com/Kotlin/kotlinx.collections.immutable) — `ImmutableList`, `persistentListOf()`
|
||||||
|
|
||||||
|
## Unanswered Questions
|
||||||
|
|
||||||
|
None — all resolved in brainstorm. Implementation details clarified by research agents.
|
||||||
Reference in New Issue
Block a user