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

41 KiB
Raw Blame History

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

  1. Bidirectional sync loop preventionsourceOfChange 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 patternsFilterChip for kind presets, expandVertically(Alignment.Top) + fadeIn, sticky section headers, shimmer loading
  6. Coroutine patternsflatMapLatest 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.hashtagsFilter.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:

{"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 → QueryParserSearchQuery. 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 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

// 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
// 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 lightningorTerms = ["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.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.authorsFilter.authors (only resolved hex keys)
  • query.since / query.untilFilter.since / Filter.until
  • query.hashtagsFilter.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>
}
  • -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:

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: 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

// 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:

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

// 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

  • 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:reply and kind:media flagged for client-side post-filtering
  • Advanced panel expands/collapses below search bar with expandVertically + fadeIn animation
  • 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
  • Escape closes 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
  • @Immutable SearchQuery with ImmutableList fields
  • Session cache cleared on restart
  • All query parsing logic unit-tested in commons (roundtrip, edge cases, malformed input)

Quality Gates

  • QueryParser + QuerySerializer roundtrip tests pass
  • KindRegistry tests for all aliases + pseudo-kinds
  • SearchFilterFactory compiles + filter generation correct
  • SearchResultFilter handles exclusion, reply detection, media detection
  • Desktop search screen renders results for all kind types
  • 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

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

Unanswered Questions

None — all resolved in brainstorm. Implementation details clarified by research agents.