--- 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:` | `Filter.authors` | Yes | | `kind:` | `Filter.kinds` | Yes | | `since:` | `Filter.since` | Yes | | `until:` | `Filter.until` | Yes | | `#` | `Filter.tags["t"]` | Yes | | `"exact phrase"` | Quoted in `Filter.search` | Yes (relay-dependent) | | `lang:` | NIP-50 extension in search string | Relay-dependent | | `domain:` | NIP-50 extension in search string | Relay-dependent | | `-` | 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) { _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 (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`. 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 = persistentListOf(), val kinds: ImmutableList = 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> = _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 └─ 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 = persistentListOf(), // Hex pubkeys val authorNames: ImmutableList = persistentListOf(), // Unresolved names (for display) val kinds: ImmutableList = persistentListOf(), // Empty = all searchable kinds val since: Long? = null, // Unix timestamp val until: Long? = null, val hashtags: ImmutableList = persistentListOf(), // Without # prefix val excludeTerms: ImmutableList = persistentListOf(), // Client-side exclusion val language: String? = null, // ISO 639-1 val domain: String? = null, // NIP-05 domain val orTerms: ImmutableList = 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` 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> = 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 = setOf("reply", "media") val presets: Map> = 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? = 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 { /* state machine */ } private fun buildQuery(tokens: List): 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:` → if bech32 npub, decode to hex and add to `authors`; else add to `authorNames` (async resolution) - `kind:` → resolve via `KindRegistry.resolve()` or parse as int. Flag pseudo-kinds separately. - `since:` / `until:` → 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 { ... } } ``` - 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, query: SearchQuery, onEvent: ..., onEose: ..., ): List ``` #### Research Insights: Subscription Management **Use `flatMapLatest`** on debouncedQuery to auto-cancel old subscriptions when query changes: ```kotlin val results: Flow> = 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, query: SearchQuery): List } ``` - `-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() // 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 = _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 = _rawText.asStateFlow() // Derived: text representation for the search bar val displayText: StateFlow = 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 = _query .debounce(300) .stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY) // Results val peopleResults: StateFlow> val noteResults: StateFlow> val isSearching: StateFlow // 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) { _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 fun saveSearch(query: SearchQuery, label: String) fun getSavedSearches(): List 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>` 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) -> 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 = mutableListOf() fun addToHistory(query: SearchQuery) // Dedup by serialized text, max 20 entries fun getHistory(): List fun clearHistory() fun saveSearch(query: SearchQuery, label: String) fun getSavedSearches(): List 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.