Files
amethyst/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md
T
nrobi144 5a437ed5ac 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>
2026-03-12 13:23:27 +02:00

963 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.