- 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>
41 KiB
title, type, status, date, deepened, origin
| title | type | status | date | deepened | origin |
|---|---|---|---|---|---|
| feat: Desktop Advanced Search with Query Operators and Form UI | feat | active | 2026-03-10 | 2026-03-10 | 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
- Bidirectional sync loop prevention —
sourceOfChangediscriminator (TEXT/FORM/INIT) breaks parse→serialize→parse cycles - Performance — batch result accumulation via
channelFlow+ 100ms windows, cap OR to 3 terms (not 5),@ImmutableSearchQuery - Parser architecture — hand-written recursive descent tokenizer + parser, error recovery via literal text degradation
- Module corrections — SearchFilterFactory stays in desktopApp (needs SubscriptionConfig); SearchResultFilter and SearchHistoryStore can move to commons
- Compose patterns —
FilterChipfor kind presets,expandVertically(Alignment.Top)+fadeIn, sticky section headers, shimmer loading - Coroutine patterns —
flatMapLatestfor auto-canceling old subscriptions,merge()for OR queries,supervisorScopefor relay isolation - 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 inFeedSubscription.ktbut not connected- No kind filtering, no author filtering, no date ranges
- No query language — users can only type plain text or bech32 identifiers
SearchBarStatein commons only returnsUserresults, 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:
{"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:
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:
@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:
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
// 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 WITHetagkind:media= kind 1 events WITHimetatag 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
// 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:
- Tokenizer (state machine): Walks characters, emits tokens:
OperatorToken(name, value),TextToken(value),OrToken,QuotedToken(value),NegationToken(value),HashtagToken(value) - 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
// 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 toauthors; else add toauthorNames(async resolution)kind:<value>→ resolve viaKindRegistry.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 tohashtags"quoted phrase"→ keep intextas quoted-term→ add toexcludeTerms, strip from relay search stringOR→ 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
// 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
// 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
// desktopApp/src/jvmMain/.../subscriptions/SearchFilterFactory.kt
object SearchFilterFactory {
fun createFilters(query: SearchQuery): List<Filter> { ... }
}
- If
query.kindsspecified → use those kinds directly - If
query.kindsempty → use default searchable kinds (align with Android's 3 groups) - Split kinds into groups of ~10 (relay
max_filterslimit 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
-spamto relay) query.authors→Filter.authors(only resolved hex keys)query.since/query.until→Filter.since/Filter.untilquery.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:
// 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
// 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:
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
// commons/src/commonMain/.../search/SearchResultFilter.kt
object SearchResultFilter {
fun filter(events: List<Event>, query: SearchQuery): List<Event>
}
-termexclusion: checkevent.contentdoesn't contain term (case-insensitive)kind:replydetection: kind 1 withetagkind:mediadetection: kind 1 withimetatag 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:
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.
// 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
// 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):
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
// desktopApp/src/jvmMain/.../ui/search/AdvancedSearchPanel.kt
@Composable
fun AdvancedSearchPanel(
query: SearchQuery,
onKindsChanged: (List<Int>) -> Unit,
onAuthorAdded: (String) -> Unit,
...
)
Components:
- Content type row:
FilterChipper preset fromKindRegistry.presets. Checked state derived fromquery.kinds. - Author field:
OutlinedTextField+DropdownMenuautocomplete dropdown (fromauthorSuggestions). Shows chips for added authors. - Date range: Two date text fields (
yyyy-MM-ddformat). Validate on blur. - Hashtags: Chip group with add button.
- Exclude terms: Chip group with add button.
- Language dropdown:
DropdownMenuwith common ISO 639-1 codes. - Clear / Search buttons: Clear resets
SearchQuery.EMPTY. Search is implicit (debounced). - Tooltips:
TooltipBox+PlainTooltipfor operator hint text on hover.
Step 4.3: SearchResultsList composable
// desktopApp/src/jvmMain/.../ui/search/SearchResultsList.kt
@Composable
fun SearchResultsList(state: AdvancedSearchBarState, ...)
Research Insights: Results Display
- Single
LazyColumnwith 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:
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+Kor/→ focus search bar. UseWindow.onKeyEventfor/(after children process),onPreviewKeyEventfor Escape.Escape→ close advanced panel / clear searchEnter→ execute search immediately (skip debounce)- Register
Ctrl+KinMenuBar { 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
// 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+Kor/→ focus search bar (desktop convention)Escape→ close advanced panel / clear searchEnter→ 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
- User types in search bar →
AdvancedSearchBarState.updateFromText()→QueryParser.parse()→_queryupdates _querychange →displayTextrecomputes (serialized, guarded bychangeSource) → text bar updates_querychange →debouncedQueryemits after 300ms →flatMapLatestcancels old subscriptions → new relay subscriptions created- Subscription creation →
relayManager.subscribe()→ relay receives REQ - Relay responds →
onEventcallback → events batched (100ms windows) → stored in cache + state - Post-filter applied at batch emission → results displayed in
SearchResultsList
Error Propagation
- Relay timeout →
onEosefires →isSearchingset to false → "No results" shown - Name resolution failure → name stays in
authorNamesas unresolved → user sees "unresolved: vitor" chip - Parse error → malformed operators treated as literal text → no crash, graceful degradation
- Non-NIP-50 relay → relay ignores
searchfield, returns nothing useful → handled by showing results from other relays - Individual relay failure →
supervisorScopeisolates failure → other relays continue
State Lifecycle Risks
- Subscription churn: Mitigated by
distinctUntilChanged()on relay statuses (proven pattern from FeedScreen) - Bidirectional update loop: Prevented by
sourceOfChangediscriminator — TEXT changes don't trigger re-serialization - Stale results: Session-only cache cleared on restart.
flatMapLatestclears previous subscription results on new query. - Memory pressure from result batching: Bounded by LRU cache (500 entries) and batch window (100ms)
API Surface Parity
SearchBarStatein commons is used by both Android and Desktop today.AdvancedSearchBarStateextends this pattern but is new.QueryParser,SearchQuery,KindRegistry,SearchResultFilterplaced in commons so Android can adopt later.- Desktop
FilterBuildersgets newsearchAdvanced()methods but existing methods unchanged.
Acceptance Criteria
Functional
- Query operators parse correctly:
from:,kind:,since:,until:,#tag,"phrase",-exclude,OR,lang:,domain: - Kind aliases resolve:
kind:note→ kind 1,kind:article→ kind 30023, etc. - Pseudo-kinds handled:
kind:replyandkind:mediaflagged for client-side post-filtering - Advanced panel expands/collapses below search bar with
expandVertically+fadeInanimation - Bidirectional sync: text changes update form, form changes update text, no loops (sourceOfChange guard)
- Default search (no kind filter) queries 30+ kinds across 3 filter groups (Android parity)
- OR queries (
bitcoin OR lightning) send parallel subscriptions, merge + dedup results (max 3 terms) - Results grouped by type: People, Notes, Articles, Other with sticky section headers
- Note results show content preview, author, timestamp, kind badge
- Search history persists last 20 queries locally
- Saved searches persist across sessions
- Exclusion terms (
-spam) filtered client-side, not sent to relay - Empty search bar shows history + saved searches + operator hints
Escapecloses panel / clears search (Ctrl+K deferred — needs window-level handler)
Non-Functional
- Search debounce: 300ms for text, immediate for form toggles
- Max 3 OR terms, 10 authors per query
- Relay subscription churn prevented via
distinctUntilChanged() - Result accumulation uses set-based dedup
@ImmutableSearchQuery withImmutableListfields- Session cache cleared on restart
- All query parsing logic unit-tested in commons (roundtrip, edge cases, malformed input)
Quality Gates
QueryParser+QuerySerializerroundtrip tests passKindRegistrytests for all aliases + pseudo-kindsSearchFilterFactorycompiles + filter generation correctSearchResultFilterhandles exclusion, reply detection, media detection- Desktop search screen renders results for all kind types
spotlessApplypasses
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):
SearchQuerydata class (no@Immutableyet, plain lists)QueryParserwith 5 operators:from:,kind:,since:,until:,#tagSearchFilterFactoryfor filter generation- Rewritten
SearchScreen.ktwith 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_nipscheck 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 — 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
- NIP-50 extensions:
language:,domain:,sentiment:,nsfw:,include:spam - kotlinx.collections.immutable —
ImmutableList,persistentListOf()
Unanswered Questions
None — all resolved in brainstorm. Implementation details clarified by research agents.