feat(search): collapsible section headers + sorting + search improvements

- Collapsible sticky section headers with animated chevron
- Full header row clickable to toggle collapse
- SearchResultSorter, SearchSortOrder, pseudo-kind filtering
- TextFieldValue cursor preservation, relay timeout improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-12 13:23:27 +02:00
parent 7180edf162
commit 5a437ed5ac
11 changed files with 1849 additions and 137 deletions
@@ -21,10 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -33,9 +31,10 @@ 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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -57,32 +56,17 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ReactionRowAction
import com.vitorpamplona.amethyst.model.ReactionRowItem
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@Composable
@Preview(device = "spec:width=2100px,height=2340px,dpi=440")
fun ReactionsSettingsScreenPreview() {
ThemeComparisonRow {
ReactionsSettingsScreen(
mockAccountViewModel(),
EmptyNav(),
)
}
}
@Composable
fun ReactionsSettingsScreen(
@@ -103,7 +87,7 @@ fun ReactionsSettingsScreen(
@Composable
fun ReactionsSettingsContent(accountViewModel: AccountViewModel) {
val reactionRowItems by accountViewModel.reactionRowItemsFlow().collectAsStateWithLifecycle()
var items by remember(reactionRowItems) { mutableStateOf(reactionRowItems.toList()) }
var items by remember(reactionRowItems) { mutableStateOf(reactionRowItems.toMutableList()) }
fun save(newItems: List<ReactionRowItem>) {
items = newItems.toMutableList()
@@ -113,14 +97,13 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) {
var draggedItemIndex by remember { mutableIntStateOf(-1) }
var dragOffset by remember { mutableFloatStateOf(0f) }
val itemHeights = remember { mutableStateMapOf<Int, Float>() }
val isDragging = draggedItemIndex >= 0
val scrollState = remember { ScrollState(0) }
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState, enabled = !isDragging),
.padding(horizontal = Size20dp)
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(16.dp))
@@ -128,7 +111,7 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) {
text = stringRes(R.string.reactions_settings_description),
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp),
modifier = Modifier.padding(bottom = 16.dp),
)
items.forEachIndexed { index, item ->
@@ -223,7 +206,7 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) {
.zIndex(if (isDragging) 1f else 0f),
)
if (index < items.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp))
HorizontalDivider()
}
}
@@ -262,18 +245,7 @@ private fun ReactionRowItemCard(
scaleX = 1.02f
scaleY = 1.02f
}
}.padding(vertical = 8.dp, horizontal = Size20dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount.y)
},
onDragEnd = { onDragEnd() },
onDragCancel = { onDragCancel() },
)
},
}.padding(vertical = 8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -296,18 +268,26 @@ private fun ReactionRowItemCard(
)
}
Box(
modifier = Modifier.size(32.dp),
contentAlignment = Alignment.CenterEnd,
) {
Icon(
Icons.Default.DragIndicator,
Icons.Default.DragHandle,
contentDescription = stringRes(R.string.reactions_settings_reorder),
modifier = Modifier.size(28.dp),
modifier =
Modifier
.size(32.dp)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount.y)
},
onDragEnd = { onDragEnd() },
onDragCancel = { onDragCancel() },
)
},
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
@@ -83,6 +83,24 @@ class AdvancedSearchBarState(
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
@@ -112,6 +130,11 @@ class AdvancedSearchBarState(
_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
@@ -228,6 +251,14 @@ class AdvancedSearchBarState(
_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 = ""
@@ -235,6 +266,8 @@ class AdvancedSearchBarState(
_peopleResults.value = persistentListOf()
_noteResults.value = persistentListOf()
_relayStates.value = persistentListOf()
_eventSortOrder.value = SearchSortOrder.DEFAULT_EVENT
_peopleSortOrder.value = SearchSortOrder.DEFAULT_PEOPLE
activeSubIds.value = emptySet()
eventDeduplicator.clear()
}
@@ -275,7 +308,7 @@ class AdvancedSearchBarState(
fun addNoteResults(events: List<Event>) {
if (events.isNotEmpty()) {
val current = _noteResults.value
_noteResults.value = (current + events).sortedByDescending { it.createdAt }.toImmutableList()
_noteResults.value = (current + events).toImmutableList()
}
}
}
@@ -32,6 +32,22 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti
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(
@@ -49,14 +65,14 @@ object KindRegistry {
val pseudoKinds: Set<String> = setOf("reply", "media")
val presets: Map<String, List<Int>> =
val presets: Map<String, ContentPreset> =
mapOf(
"Notes" to listOf(TextNoteEvent.KIND),
"Articles" to listOf(LongTextNoteEvent.KIND),
"Media" to listOf(TextNoteEvent.KIND),
"Channels" to listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
"Communities" to listOf(CommunityDefinitionEvent.KIND),
"Wiki" to listOf(WikiNoteEvent.KIND),
"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()]
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -64,8 +64,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -131,6 +133,14 @@ fun SearchScreen(
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()
@@ -142,12 +152,19 @@ fun SearchScreen(
// Bech32 parsing (immediate, no debounce)
val bech32Results = remember(displayText) { parseSearchInput(displayText) }
// Skip people search when query specifies kinds that don't include profile (kind 0)
val shouldSearchPeople =
(debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
debouncedQuery.kinds.contains(MetadataEvent.KIND)
// Clear results and start loading when query changes
LaunchedEffect(debouncedQuery) {
if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
state.clearResults()
state.initRelayStates(allRelayUrls)
if (shouldSearchPeople) {
state.startSearching("people-search")
}
state.startSearching("adv-search")
// Timeout relays that silently ignore NIP-50 (e.g. strfry)
kotlinx.coroutines.delay(10_000L)
@@ -161,6 +178,10 @@ fun SearchScreen(
return@rememberSubscription null
}
if (bech32Results.isNotEmpty()) return@rememberSubscription null
if (!shouldSearchPeople) {
state.stopSearching("people-search")
return@rememberSubscription null
}
createSearchPeopleSubscription(
relays = allRelayUrls,
@@ -334,12 +355,11 @@ fun SearchScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value =
TextFieldValue(
text = displayText,
selection = TextRange(displayText.length),
),
onValueChange = { state.updateFromText(it.text) },
value = textFieldValue,
onValueChange = {
textFieldValue = it
state.updateFromText(it.text)
},
modifier = Modifier.weight(1f).focusRequester(focusRequester),
placeholder = { Text("Search notes, people, tags... or use operators") },
leadingIcon = {
@@ -386,6 +406,7 @@ fun SearchScreen(
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) },
@@ -56,6 +56,7 @@ 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
@@ -66,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchQuery
fun AdvancedSearchPanel(
query: SearchQuery,
onKindsChanged: (List<Int>) -> Unit,
onPseudoKindsChanged: (List<String>) -> Unit,
onAuthorAdded: (String) -> Unit,
onAuthorRemoved: (String) -> Unit,
onDateRangeChanged: (Long?, Long?) -> Unit,
@@ -99,10 +101,10 @@ fun AdvancedSearchPanel(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
KindRegistry.presets.forEach { (name, kinds) ->
KindRegistry.presets.forEach { (name, preset) ->
FilterChip(
selected = query.kinds.toList().containsAll(kinds),
onClick = { onKindsChanged(toggleKinds(query.kinds.toList(), kinds)) },
selected = preset.isSelected(query.kinds.toList(), query.pseudoKinds.toList()),
onClick = { togglePreset(preset, query, onKindsChanged, onPseudoKindsChanged) },
label = { Text(name) },
)
}
@@ -161,15 +163,29 @@ fun AdvancedSearchPanel(
}
}
private fun toggleKinds(
current: List<Int>,
toggle: List<Int>,
): List<Int> =
if (current.containsAll(toggle)) {
current - toggle.toSet()
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 {
(current + toggle).distinct()
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
@@ -242,19 +258,21 @@ private fun DateRangeFields(
until: Long?,
onChanged: (Long?, Long?) -> Unit,
) {
var sinceText by remember(since) {
mutableStateOf(
since?.let {
DateUtils.timestampToDate(it)
} ?: "",
)
// 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(until) {
mutableStateOf(
until?.let {
DateUtils.timestampToDate(it)
} ?: "",
)
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(
@@ -271,9 +289,11 @@ private fun DateRangeFields(
value = sinceText,
onValueChange = {
sinceText = it
val ts =
QueryParser.parseDateToTimestamp(it)
val ts = QueryParser.parseDateToTimestamp(it)
if (ts != null || it.isBlank()) {
lastSince = ts
onChanged(ts, until)
}
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("YYYY-MM-DD") },
@@ -290,9 +310,11 @@ private fun DateRangeFields(
value = untilText,
onValueChange = {
untilText = it
val ts =
QueryParser.parseDateToTimestamp(it)
val ts = QueryParser.parseDateToTimestamp(it)
if (ts != null || it.isBlank()) {
lastUntil = ts
onChanged(since, ts)
}
},
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("YYYY-MM-DD") },
@@ -20,6 +20,7 @@
*/
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
@@ -41,6 +42,8 @@ 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
@@ -50,17 +53,20 @@ 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
@@ -74,8 +80,10 @@ fun SearchResultsList(
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
val people by state.peopleResults.collectAsState()
val notes by state.noteResults.collectAsState()
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()
@@ -86,6 +94,9 @@ fun SearchResultsList(
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),
@@ -93,9 +104,20 @@ fun SearchResultsList(
) {
// People section
if (people.isNotEmpty()) {
val collapsed = collapsedSections["people"] == true
stickyHeader(key = "header-people") {
SectionHeader("People", people.size, Icons.Default.Person)
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(
@@ -116,15 +138,27 @@ fun SearchResultsList(
}
}
}
}
// 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") {
SectionHeader("Notes", textNotes.size, Icons.Default.Description)
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) })
@@ -139,15 +173,27 @@ fun SearchResultsList(
}
}
}
}
// 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") {
SectionHeader("Articles", articles.size, Icons.Default.Article)
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) })
}
@@ -161,17 +207,30 @@ fun SearchResultsList(
}
}
}
}
// Other section
if (otherNotes.isNotEmpty()) {
item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
val collapsed = collapsedSections["other"] == true
stickyHeader(key = "header-other") {
SectionHeader("Other", otherNotes.size, Icons.Default.Forum)
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)) }
@@ -179,31 +238,67 @@ fun SearchResultsList(
}
@Composable
private fun SectionHeader(
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,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 8.dp),
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.size(18.dp),
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),
)
}
}
}
}
}
}
@@ -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.