diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
index 2615ac2ec6..18df26d779 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt
@@ -88,10 +88,12 @@ fun PrivateMessageEditFieldRow(
nav: INav,
) {
BackHandler {
- accountViewModel.launchSigner {
- channelScreenModel.sendDraftSync()
- channelScreenModel.cancel()
+ if (channelScreenModel.message.text.isNotBlank()) {
+ accountViewModel.launchSigner {
+ channelScreenModel.sendDraftSync()
+ }
}
+ channelScreenModel.cancel()
nav.popBack()
}
diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml
index 6d7eafd38e..95b1bad1c2 100644
--- a/amethyst/src/main/res/values-zh-rCN/strings.xml
+++ b/amethyst/src/main/res/values-zh-rCN/strings.xml
@@ -673,6 +673,7 @@
写入到继电器
向该中继发送事件和过滤请求所使用的数据量
从该中继接收事件和过滤响应所使用的数据量
+ 已保存的事件
尝试从 %1$s 获取中继器信息时出错
机主
使用者
@@ -1052,6 +1053,29 @@
全球
短篇
国际象棋
+ 钱包
+ 余额
+ 发送
+ 接收
+ 交易
+ 未连接钱包
+ 在打闪设置中设置Nostr Wallet Connect (NWC) 连接以使用钱包。
+ 设置钱包
+ 聪
+ 粘贴 BOLT-11 发票
+ 付款
+ 支付成功
+ 正在发送付款…
+ 聪金额
+ 描述(可选)
+ 创建发票
+ 正在创建发票…
+ 复制发票
+ 尚无交易
+ 正在加载…
+ 已收到
+ 已发送
+ 刷新
安全滤镜
导入关注
新帖子
@@ -1099,6 +1123,13 @@
取消打闪拆分
添加内容警告
移除内容警告
+ 添加过期日期
+ 删除过期日期
+ 过期日期
+ 帖子将在此日期后被客户端隐藏 (NIP-40)
+ 选择到期日期和时间
+ 在 %1$s 过期
+ 到期时间
将 npub 显示为二维码
以二维码显示 nprofile
地址无效
@@ -1168,6 +1199,9 @@
中继黑名单
中继黑名单
应用永远不会连接的中继
+ 导出中继设置
+ 导出为文本
+ 导出为 ZIP (JSON)
打闪开发人员!
你的捐赠帮助我们做出不同的贡献。每个聪都很重要!
立即捐款
@@ -1295,6 +1329,7 @@
找到了崩溃报告
要用私信将最近的崩溃报告发送给 Amethyst 吗?不会分享个人信息
发送它
+ 此消息将在 %1$s 后消失
此消息将在 %1$d 天内消失
选择签名者
已经在列表中
@@ -1523,4 +1558,9 @@
全选
%1$d%% 运行时间
Namecoin 设置
+ 比特币资源管理器 (OTS)
+ 事件
+ 私信
+ 个人资料
+ 中继设置
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt
new file mode 100644
index 0000000000..88702439c3
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt
@@ -0,0 +1,314 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import com.vitorpamplona.amethyst.commons.chess.RelaySyncState
+import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
+import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+
+enum class ChangeSource {
+ TEXT,
+ FORM,
+ INIT,
+}
+
+@OptIn(FlowPreview::class)
+class AdvancedSearchBarState(
+ private val scope: CoroutineScope,
+ private val debounceMs: Long = 300L,
+) {
+ private val _query = MutableStateFlow(SearchQuery.EMPTY)
+ val query: StateFlow = _query.asStateFlow()
+
+ private var _changeSource: ChangeSource = ChangeSource.INIT
+ val changeSource get() = _changeSource
+
+ private val _rawText = MutableStateFlow("")
+ val rawText: StateFlow = _rawText.asStateFlow()
+
+ val displayText: StateFlow =
+ combine(_query, _rawText) { query, raw ->
+ if (_changeSource == ChangeSource.TEXT) {
+ raw
+ } else {
+ QuerySerializer.serialize(query)
+ }
+ }.stateIn(scope, SharingStarted.Eagerly, "")
+
+ val debouncedQuery: StateFlow =
+ _query
+ .debounce(debounceMs)
+ .stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY)
+
+ // People search results (from cache + relay)
+ private val _peopleResults = MutableStateFlow>(persistentListOf())
+ val peopleResults: StateFlow> = _peopleResults.asStateFlow()
+
+ // Note/event results (from relay subscriptions)
+ private val _noteResults = MutableStateFlow>(persistentListOf())
+ val noteResults: StateFlow> = _noteResults.asStateFlow()
+
+ // Sort orders
+ private val _eventSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_EVENT)
+ val eventSortOrder: StateFlow = _eventSortOrder.asStateFlow()
+
+ private val _peopleSortOrder = MutableStateFlow(SearchSortOrder.DEFAULT_PEOPLE)
+ val peopleSortOrder: StateFlow = _peopleSortOrder.asStateFlow()
+
+ // Derived sorted results
+ val sortedNoteResults: StateFlow> =
+ combine(_noteResults, _eventSortOrder, _rawText) { notes, order, text ->
+ SearchResultSorter.sortEvents(notes, order, text).toImmutableList()
+ }.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
+
+ val sortedPeopleResults: StateFlow> =
+ combine(_peopleResults, _peopleSortOrder) { people, order ->
+ SearchResultSorter.sortPeople(people, order).toImmutableList()
+ }.stateIn(scope, SharingStarted.Eagerly, persistentListOf())
+
+ private val activeSubIds = MutableStateFlow>(emptySet())
+ val isSearching: StateFlow =
+ activeSubIds
+ .map { it.isNotEmpty() }
+ .stateIn(scope, SharingStarted.Eagerly, false)
+
+ private val eventDeduplicator = EventDeduplicator()
+
+ // Expanded panel state
+ private val _panelExpanded = MutableStateFlow(false)
+ val panelExpanded: StateFlow = _panelExpanded.asStateFlow()
+
+ // Per-relay sync status
+ private val _relayStates = MutableStateFlow>(persistentListOf())
+ val relayStates: StateFlow> = _relayStates.asStateFlow()
+
+ // Text bar input
+ fun updateFromText(rawText: String) {
+ _changeSource = ChangeSource.TEXT
+ _rawText.value = rawText
+ _query.value = QueryParser.parse(rawText)
+ }
+
+ // Form panel inputs
+ fun updateKinds(kinds: List) {
+ _changeSource = ChangeSource.FORM
+ _query.value = _query.value.copy(kinds = kinds.toImmutableList())
+ }
+
+ fun updatePseudoKinds(pseudoKinds: List) {
+ _changeSource = ChangeSource.FORM
+ _query.value = _query.value.copy(pseudoKinds = pseudoKinds.toImmutableList())
+ }
+
+ fun addAuthor(hexOrName: String) {
+ _changeSource = ChangeSource.FORM
+ val current = _query.value
+ val hex =
+ com.vitorpamplona.quartz.nip19Bech32
+ .decodePublicKeyAsHexOrNull(hexOrName)
+ if (hex != null) {
+ if (hex !in current.authors) {
+ _query.value = current.copy(authors = (current.authors + hex).toImmutableList())
+ }
+ } else {
+ if (hexOrName !in current.authorNames) {
+ _query.value = current.copy(authorNames = (current.authorNames + hexOrName).toImmutableList())
+ }
+ }
+ }
+
+ fun removeAuthor(hex: String) {
+ _changeSource = ChangeSource.FORM
+ val current = _query.value
+ _query.value =
+ current.copy(
+ authors = current.authors.filter { it != hex }.toImmutableList(),
+ authorNames = current.authorNames.filter { it != hex }.toImmutableList(),
+ )
+ }
+
+ fun updateDateRange(
+ since: Long?,
+ until: Long?,
+ ) {
+ _changeSource = ChangeSource.FORM
+ _query.value = _query.value.copy(since = since, until = until)
+ }
+
+ fun addHashtag(tag: String) {
+ _changeSource = ChangeSource.FORM
+ val current = _query.value
+ val cleaned = tag.removePrefix("#")
+ if (cleaned !in current.hashtags) {
+ _query.value = current.copy(hashtags = (current.hashtags + cleaned).toImmutableList())
+ }
+ }
+
+ fun removeHashtag(tag: String) {
+ _changeSource = ChangeSource.FORM
+ val current = _query.value
+ _query.value = current.copy(hashtags = current.hashtags.filter { it != tag }.toImmutableList())
+ }
+
+ fun addExcludeTerm(term: String) {
+ _changeSource = ChangeSource.FORM
+ val current = _query.value
+ if (term !in current.excludeTerms) {
+ _query.value = current.copy(excludeTerms = (current.excludeTerms + term).toImmutableList())
+ }
+ }
+
+ fun removeExcludeTerm(term: String) {
+ _changeSource = ChangeSource.FORM
+ val current = _query.value
+ _query.value = current.copy(excludeTerms = current.excludeTerms.filter { it != term }.toImmutableList())
+ }
+
+ fun updateLanguage(lang: String?) {
+ _changeSource = ChangeSource.FORM
+ _query.value = _query.value.copy(language = lang)
+ }
+
+ fun initRelayStates(relays: Set) {
+ _relayStates.value =
+ relays
+ .map {
+ RelaySyncState(
+ url = it.url,
+ displayName = it.displayUrl(),
+ status = RelaySyncStatus.WAITING,
+ )
+ }.toImmutableList()
+ }
+
+ fun updateRelayState(
+ relayUrl: String,
+ status: RelaySyncStatus,
+ eventsDelta: Int = 0,
+ ) {
+ _relayStates.update { states ->
+ states
+ .map {
+ if (it.url == relayUrl) {
+ it.copy(status = status, eventsReceived = it.eventsReceived + eventsDelta)
+ } else {
+ it
+ }
+ }.toImmutableList()
+ }
+ }
+
+ fun timeoutWaitingRelays() {
+ _relayStates.update { states ->
+ states
+ .map {
+ if (it.status == RelaySyncStatus.WAITING || it.status == RelaySyncStatus.CONNECTING) {
+ it.copy(status = RelaySyncStatus.FAILED)
+ } else {
+ it
+ }
+ }.toImmutableList()
+ }
+ activeSubIds.value = emptySet()
+ }
+
+ fun togglePanel() {
+ _panelExpanded.value = !_panelExpanded.value
+ }
+
+ fun updateEventSortOrder(order: SearchSortOrder) {
+ _eventSortOrder.value = order
+ }
+
+ fun updatePeopleSortOrder(order: SearchSortOrder) {
+ _peopleSortOrder.value = order
+ }
+
+ fun clearSearch() {
+ _changeSource = ChangeSource.INIT
+ _rawText.value = ""
+ _query.value = SearchQuery.EMPTY
+ _peopleResults.value = persistentListOf()
+ _noteResults.value = persistentListOf()
+ _relayStates.value = persistentListOf()
+ _eventSortOrder.value = SearchSortOrder.DEFAULT_EVENT
+ _peopleSortOrder.value = SearchSortOrder.DEFAULT_PEOPLE
+ activeSubIds.value = emptySet()
+ eventDeduplicator.clear()
+ }
+
+ // Results management (called from subscription callbacks)
+ fun startSearching(subId: String) {
+ activeSubIds.update { it + subId }
+ }
+
+ fun stopSearching(subId: String) {
+ activeSubIds.update { it - subId }
+ }
+
+ fun trackRelayEvent(
+ relayUrl: String,
+ eventId: String,
+ ): Boolean {
+ val isNew = eventDeduplicator.tryAdd(eventId)
+ if (isNew) {
+ updateRelayState(relayUrl, RelaySyncStatus.RECEIVING, eventsDelta = 1)
+ }
+ return isNew
+ }
+
+ fun clearResults() {
+ _peopleResults.value = persistentListOf()
+ _noteResults.value = persistentListOf()
+ eventDeduplicator.clear()
+ }
+
+ fun addPeopleResult(user: User) {
+ val current = _peopleResults.value
+ if (current.none { it.pubkeyHex == user.pubkeyHex }) {
+ _peopleResults.value = (current + user).toImmutableList()
+ }
+ }
+
+ fun addNoteResults(events: List) {
+ if (events.isNotEmpty()) {
+ val current = _noteResults.value
+ _noteResults.value = (current + events).toImmutableList()
+ }
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt
new file mode 100644
index 0000000000..e7b1d053bf
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+object DateUtils {
+ fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
+
+ fun dateToUnix(
+ year: Int,
+ month: Int,
+ day: Int,
+ ): Long {
+ var totalDays = 0L
+
+ for (y in 1970 until year) {
+ totalDays += if (isLeapYear(y)) 366 else 365
+ }
+
+ val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ if (isLeapYear(year)) daysInMonth[2] = 29
+ for (m in 1 until month) {
+ totalDays += daysInMonth[m]
+ }
+
+ totalDays += (day - 1)
+
+ return totalDays * 86400L
+ }
+
+ fun timestampToDate(timestamp: Long): String {
+ var remaining = timestamp
+ var year = 1970
+ while (true) {
+ val daysInYear = if (isLeapYear(year)) 366L else 365L
+ val secondsInYear = daysInYear * 86400L
+ if (remaining < secondsInYear) break
+ remaining -= secondsInYear
+ year++
+ }
+
+ val daysInMonth = intArrayOf(0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ if (isLeapYear(year)) daysInMonth[2] = 29
+
+ var dayOfYear = (remaining / 86400).toInt() + 1
+ var month = 1
+ while (month <= 12 && dayOfYear > daysInMonth[month]) {
+ dayOfYear -= daysInMonth[month]
+ month++
+ }
+
+ return "$year-${month.toString().padStart(2, '0')}-${dayOfYear.toString().padStart(2, '0')}"
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt
new file mode 100644
index 0000000000..8a843955ae
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+class EventDeduplicator {
+ private val lock = Any()
+ private val seenIds = mutableSetOf()
+
+ fun tryAdd(id: String): Boolean = synchronized(lock) { seenIds.add(id) }
+
+ fun contains(id: String): Boolean = synchronized(lock) { id in seenIds }
+
+ fun clear() = synchronized(lock) { seenIds.clear() }
+
+ val size: Int get() = synchronized(lock) { seenIds.size }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt
new file mode 100644
index 0000000000..e914be2941
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
+import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
+import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
+import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
+import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
+import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
+import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
+import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
+
+data class ContentPreset(
+ val kinds: List = emptyList(),
+ val pseudoKind: String? = null,
+) {
+ /** Check if this preset is active given current query state. */
+ fun isSelected(
+ queryKinds: List,
+ queryPseudoKinds: List,
+ ): Boolean =
+ if (pseudoKind != null) {
+ pseudoKind in queryPseudoKinds
+ } else {
+ kinds.isNotEmpty() && queryKinds.containsAll(kinds)
+ }
+}
+
+object KindRegistry {
+ val aliases: Map> =
+ mapOf(
+ "note" to listOf(TextNoteEvent.KIND),
+ "article" to listOf(LongTextNoteEvent.KIND),
+ "repost" to listOf(RepostEvent.KIND),
+ "profile" to listOf(MetadataEvent.KIND),
+ "channel" to listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND),
+ "live" to listOf(LiveActivitiesEvent.KIND),
+ "community" to listOf(CommunityDefinitionEvent.KIND),
+ "wiki" to listOf(WikiNoteEvent.KIND),
+ "classified" to listOf(ClassifiedsEvent.KIND),
+ "highlight" to listOf(HighlightEvent.KIND),
+ )
+
+ val pseudoKinds: Set = setOf("reply", "media")
+
+ val presets: Map =
+ mapOf(
+ "Notes" to ContentPreset(kinds = listOf(TextNoteEvent.KIND)),
+ "Articles" to ContentPreset(kinds = listOf(LongTextNoteEvent.KIND)),
+ "Media" to ContentPreset(pseudoKind = "media"),
+ "Channels" to ContentPreset(kinds = listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND)),
+ "Communities" to ContentPreset(kinds = listOf(CommunityDefinitionEvent.KIND)),
+ "Wiki" to ContentPreset(kinds = listOf(WikiNoteEvent.KIND)),
+ )
+
+ fun resolve(alias: String): List? = 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
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt
new file mode 100644
index 0000000000..073e9cc41f
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt
@@ -0,0 +1,323 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toPersistentList
+
+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
+}
+
+object QueryParser {
+ private val KNOWN_OPERATORS = setOf("from", "kind", "since", "until", "lang", "domain")
+
+ fun parse(input: String): SearchQuery {
+ if (input.isBlank()) return SearchQuery.EMPTY
+ val tokens = tokenize(input)
+ return buildQuery(tokens)
+ }
+
+ internal fun tokenize(input: String): List {
+ val tokens = mutableListOf()
+ var i = 0
+ val len = input.length
+
+ while (i < len) {
+ // Skip whitespace
+ if (input[i].isWhitespace()) {
+ i++
+ continue
+ }
+
+ // Quoted phrase
+ if (input[i] == '"') {
+ val start = i
+ i++ // skip opening quote
+ val sb = StringBuilder()
+ while (i < len && input[i] != '"') {
+ sb.append(input[i])
+ i++
+ }
+ if (i < len) i++ // skip closing quote
+ val value = sb.toString()
+ tokens.add(Token.Quoted(value, input.substring(start, i)))
+ continue
+ }
+
+ // Negation
+ if (input[i] == '-' && i + 1 < len && !input[i + 1].isWhitespace()) {
+ i++ // skip -
+ val word = readWord(input, i)
+ i += word.length
+ if (word.isNotEmpty()) {
+ tokens.add(Token.Negation(word))
+ }
+ continue
+ }
+
+ // Hashtag
+ if (input[i] == '#' && i + 1 < len && !input[i + 1].isWhitespace()) {
+ i++ // skip #
+ val tag = readWord(input, i)
+ i += tag.length
+ if (tag.isNotEmpty()) {
+ tokens.add(Token.Hashtag(tag))
+ }
+ continue
+ }
+
+ // Read a word (may be operator:value, OR, or plain text)
+ val word = readWord(input, i)
+ i += word.length
+
+ if (word.isEmpty()) {
+ i++
+ continue
+ }
+
+ // Check for OR keyword
+ if (word == "OR") {
+ tokens.add(Token.Or)
+ continue
+ }
+
+ // Check for operator pattern (word:value)
+ val colonIdx = word.indexOf(':')
+ if (colonIdx > 0) {
+ val opName = word.substring(0, colonIdx).lowercase()
+ val opValue = word.substring(colonIdx + 1)
+ if (opName in KNOWN_OPERATORS && opValue.isNotEmpty()) {
+ tokens.add(Token.Operator(opName, opValue, word))
+ continue
+ }
+ // Malformed operator (no value or unknown) → treat as text
+ }
+
+ tokens.add(Token.Text(word))
+ }
+
+ return tokens
+ }
+
+ private fun readWord(
+ input: String,
+ start: Int,
+ ): String {
+ var i = start
+ while (i < input.length && !input[i].isWhitespace()) {
+ i++
+ }
+ return input.substring(start, i)
+ }
+
+ private fun buildQuery(tokens: List): SearchQuery {
+ val authors = mutableListOf()
+ val authorNames = mutableListOf()
+ val kinds = mutableListOf()
+ val hashtags = mutableListOf()
+ val excludeTerms = mutableListOf()
+ val pseudoKinds = mutableListOf()
+ val textParts = mutableListOf()
+ val orTerms = mutableListOf()
+ var since: Long? = null
+ var until: Long? = null
+ var language: String? = null
+ var domain: String? = null
+
+ // Collect OR groups: text terms separated by OR
+ var i = 0
+ while (i < tokens.size) {
+ when (val token = tokens[i]) {
+ is Token.Operator -> {
+ when (token.name) {
+ "from" -> {
+ val hex = decodePublicKeyAsHexOrNull(token.value)
+ if (hex != null) {
+ authors.add(hex)
+ } else {
+ authorNames.add(token.value)
+ }
+ }
+
+ "kind" -> {
+ if (KindRegistry.isPseudoKind(token.value)) {
+ pseudoKinds.add(token.value.lowercase())
+ } else {
+ val resolved = KindRegistry.resolve(token.value)
+ if (resolved != null) {
+ kinds.addAll(resolved)
+ } else {
+ token.value.toIntOrNull()?.let { kinds.add(it) }
+ ?: textParts.add(token.raw)
+ }
+ }
+ }
+
+ "since" -> {
+ val ts = parseDateToTimestamp(token.value)
+ if (ts != null) {
+ since = ts
+ } else {
+ textParts.add(token.raw)
+ }
+ }
+
+ "until" -> {
+ val ts = parseDateToTimestamp(token.value)
+ if (ts != null) {
+ until = ts
+ } else {
+ textParts.add(token.raw)
+ }
+ }
+
+ "lang" -> {
+ language = token.value.lowercase()
+ }
+
+ "domain" -> {
+ domain = token.value.lowercase()
+ }
+ }
+ }
+
+ is Token.Text -> {
+ // Check if this is part of an OR chain
+ if (i + 2 < tokens.size && tokens[i + 1] is Token.Or && tokens[i + 2] is Token.Text) {
+ // Start of OR chain: collect all terms
+ orTerms.add(token.value)
+ i++ // skip to OR
+ while (i < tokens.size && tokens[i] is Token.Or && i + 1 < tokens.size && tokens[i + 1] is Token.Text) {
+ i++ // skip OR
+ orTerms.add((tokens[i] as Token.Text).value)
+ i++ // skip text
+ }
+ continue
+ } else {
+ textParts.add(token.value)
+ }
+ }
+
+ is Token.Quoted -> {
+ textParts.add(token.raw)
+ }
+
+ is Token.Negation -> {
+ excludeTerms.add(token.term)
+ }
+
+ is Token.Hashtag -> {
+ hashtags.add(token.tag)
+ }
+
+ is Token.Or -> {
+ // Orphaned OR (no adjacent text terms) → treat as text
+ textParts.add("OR")
+ }
+ }
+ i++
+ }
+
+ // Cap OR terms at 3
+ val cappedOrTerms = orTerms.take(3)
+
+ return SearchQuery(
+ text = textParts.joinToString(" "),
+ authors = authors.distinct().toImmutableList(),
+ authorNames = authorNames.distinct().toImmutableList(),
+ kinds = kinds.distinct().toImmutableList(),
+ since = since,
+ until = until,
+ hashtags = hashtags.distinct().toImmutableList(),
+ excludeTerms = excludeTerms.distinct().toImmutableList(),
+ language = language,
+ domain = domain,
+ orTerms = cappedOrTerms.toPersistentList(),
+ pseudoKinds = pseudoKinds.distinct().toImmutableList(),
+ )
+ }
+
+ fun parseDateToTimestamp(dateStr: String): Long? {
+ // ISO 8601 formats: YYYY, YYYY-MM, YYYY-MM-DD
+ return try {
+ val parts = dateStr.split("-")
+ when (parts.size) {
+ 1 -> {
+ val year = parts[0].toIntOrNull() ?: return null
+ if (year < 1970 || year > 2100) return null
+ dateToUnix(year, 1, 1)
+ }
+
+ 2 -> {
+ val year = parts[0].toIntOrNull() ?: return null
+ val month = parts[1].toIntOrNull() ?: return null
+ if (year < 1970 || year > 2100 || month < 1 || month > 12) return null
+ dateToUnix(year, month, 1)
+ }
+
+ 3 -> {
+ val year = parts[0].toIntOrNull() ?: return null
+ val month = parts[1].toIntOrNull() ?: return null
+ val day = parts[2].toIntOrNull() ?: return null
+ if (year < 1970 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null
+ dateToUnix(year, month, day)
+ }
+
+ else -> {
+ null
+ }
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun dateToUnix(
+ year: Int,
+ month: Int,
+ day: Int,
+ ): Long = DateUtils.dateToUnix(year, month, day)
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt
new file mode 100644
index 0000000000..3cd27a0178
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
+
+object QuerySerializer {
+ fun serialize(query: SearchQuery): String {
+ if (query.isEmpty) return ""
+
+ val parts = mutableListOf()
+
+ // Operators first
+ query.authors.forEach { hex ->
+ val npub =
+ try {
+ NPub.create(hex)
+ } catch (_: Exception) {
+ null
+ }
+ parts.add("from:${npub ?: hex}")
+ }
+ query.authorNames.forEach { name ->
+ parts.add("from:$name")
+ }
+ query.kinds.forEach { kind ->
+ val name = KindRegistry.nameFor(kind)
+ parts.add("kind:${name ?: kind}")
+ }
+ query.pseudoKinds.forEach { pseudo ->
+ parts.add("kind:$pseudo")
+ }
+ query.since?.let { ts ->
+ parts.add("since:${timestampToDate(ts)}")
+ }
+ query.until?.let { ts ->
+ parts.add("until:${timestampToDate(ts)}")
+ }
+ query.language?.let { lang ->
+ parts.add("lang:$lang")
+ }
+ query.domain?.let { dom ->
+ parts.add("domain:$dom")
+ }
+
+ // Hashtags
+ query.hashtags.forEach { tag ->
+ parts.add("#$tag")
+ }
+
+ // Free text
+ if (query.text.isNotBlank()) {
+ parts.add(query.text)
+ }
+
+ // OR terms
+ if (query.orTerms.isNotEmpty()) {
+ parts.add(query.orTerms.joinToString(" OR "))
+ }
+
+ // Exclusions last
+ query.excludeTerms.forEach { term ->
+ parts.add("-$term")
+ }
+
+ return parts.joinToString(" ")
+ }
+
+ fun timestampToDate(timestamp: Long): String = DateUtils.timestampToDate(timestamp)
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt
new file mode 100644
index 0000000000..20302fba73
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+data class SavedSearch(
+ val id: String,
+ val label: String,
+ val query: SearchQuery,
+ val createdAt: Long,
+)
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt
new file mode 100644
index 0000000000..fff17c1df5
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import androidx.compose.runtime.Immutable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Immutable
+data class SearchQuery(
+ val text: String = "",
+ val authors: ImmutableList = persistentListOf(),
+ val authorNames: ImmutableList = persistentListOf(),
+ val kinds: ImmutableList = persistentListOf(),
+ val since: Long? = null,
+ val until: Long? = null,
+ val hashtags: ImmutableList = persistentListOf(),
+ val excludeTerms: ImmutableList = persistentListOf(),
+ val language: String? = null,
+ val domain: String? = null,
+ val orTerms: ImmutableList = persistentListOf(),
+ val pseudoKinds: ImmutableList = persistentListOf(),
+) {
+ val isEmpty
+ get() =
+ text.isBlank() &&
+ authors.isEmpty() &&
+ authorNames.isEmpty() &&
+ kinds.isEmpty() &&
+ since == null &&
+ until == null &&
+ hashtags.isEmpty() &&
+ orTerms.isEmpty() &&
+ excludeTerms.isEmpty() &&
+ pseudoKinds.isEmpty() &&
+ language == null &&
+ domain == null
+
+ companion object {
+ val EMPTY = SearchQuery()
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt
index dc77eba392..21cd3a0a24 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.commons.search
-import com.vitorpamplona.amethyst.commons.model.User
-
/**
* Represents a parsed search result from Bech32/hex input.
* Shared between Android and Desktop for consistent search behavior.
@@ -35,13 +33,6 @@ sealed class SearchResult {
val displayId: String,
) : SearchResult()
- /**
- * User from local cache with full metadata.
- */
- data class CachedUserResult(
- val user: User,
- ) : SearchResult()
-
/**
* Note lookup from note1 or nevent.
*/
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt
new file mode 100644
index 0000000000..6cff2d8cf4
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import com.vitorpamplona.quartz.nip01Core.core.Event
+
+object SearchResultFilter {
+ fun filter(
+ events: List,
+ query: SearchQuery,
+ ): List {
+ var result = events
+
+ // Dedup by event ID
+ result = result.distinctBy { it.id }
+
+ // Exclusion terms (client-side)
+ if (query.excludeTerms.isNotEmpty()) {
+ result =
+ result.filter { event ->
+ query.excludeTerms.none { term ->
+ event.content.contains(term, ignoreCase = true)
+ }
+ }
+ }
+
+ // Pseudo-kind: reply (kind 1 with e tag)
+ if ("reply" in query.pseudoKinds) {
+ result = result.filter { event -> isReply(event) }
+ }
+
+ // Pseudo-kind: media (kind 1 with imeta tag or image URLs)
+ if ("media" in query.pseudoKinds) {
+ result = result.filter { event -> isMedia(event) }
+ }
+
+ // Sort by createdAt descending
+ return result.sortedByDescending { it.createdAt }
+ }
+
+ fun isReply(event: Event): Boolean = event.kind == 1 && event.tags.any { it.size >= 2 && it[0] == "e" }
+
+ fun isMedia(event: Event): Boolean {
+ if (event.kind != 1) return false
+ // Check for imeta tag
+ if (event.tags.any { it.size >= 2 && it[0] == "imeta" }) return true
+ // Check for image/video URLs in content
+ return IMAGE_URL_PATTERN.containsMatchIn(event.content)
+ }
+
+ private val IMAGE_URL_PATTERN =
+ Regex(
+ """https?://\S+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)""",
+ RegexOption.IGNORE_CASE,
+ )
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt
new file mode 100644
index 0000000000..bc1ea81770
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+import com.vitorpamplona.quartz.utils.currentTimeSeconds
+
+object SearchResultSorter {
+ fun sortEvents(
+ events: List,
+ order: SearchSortOrder,
+ searchText: String,
+ ): List =
+ when (order) {
+ SearchSortOrder.NEWEST -> {
+ events.sortedByDescending { it.createdAt }
+ }
+
+ SearchSortOrder.OLDEST -> {
+ events.sortedBy { it.createdAt }
+ }
+
+ SearchSortOrder.RELEVANCE -> {
+ if (searchText.isBlank()) {
+ events.sortedByDescending { it.createdAt }
+ } else {
+ events.sortedByDescending { scoreEvent(it, searchText) }
+ }
+ }
+
+ else -> {
+ events
+ }
+ }
+
+ fun sortPeople(
+ people: List,
+ order: SearchSortOrder,
+ ): List =
+ when (order) {
+ SearchSortOrder.NAME_AZ -> people.sortedBy { it.toBestDisplayName().lowercase() }
+ SearchSortOrder.NAME_ZA -> people.sortedByDescending { it.toBestDisplayName().lowercase() }
+ else -> people
+ }
+
+ fun scoreEvent(
+ event: Event,
+ searchText: String,
+ ): Double {
+ val query = searchText.trim().lowercase()
+ if (query.isEmpty()) return event.createdAt.toDouble()
+
+ var score = 0.0
+ val content = event.content.lowercase()
+ val tokens = query.split("\\s+".toRegex())
+
+ // Exact phrase match in content
+ if (content.contains(query)) {
+ score += 10.0
+ }
+
+ // Per-token scoring
+ for (token in tokens) {
+ if (token.isEmpty()) continue
+ val wordBoundary = "\\b${Regex.escape(token)}\\b".toRegex()
+ if (wordBoundary.containsMatchIn(content)) {
+ score += 5.0
+ } else if (content.contains(token)) {
+ score += 2.0
+ }
+ }
+
+ // Article title boost
+ if (event is LongTextNoteEvent) {
+ val title = event.title()?.lowercase()
+ if (title != null) {
+ if (title.contains(query)) {
+ score += 15.0
+ }
+ for (token in tokens) {
+ if (token.isEmpty()) continue
+ if (title.contains(token)) {
+ score += 3.0
+ }
+ }
+ }
+ }
+
+ // Recency tiebreaker (normalized 0..1)
+ val now = currentTimeSeconds()
+ val age = (now - event.createdAt).coerceAtLeast(1)
+ val maxAge = 365L * 24 * 3600 // 1 year
+ score += (1.0 - (age.toDouble() / maxAge).coerceIn(0.0, 1.0))
+
+ return score
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt
new file mode 100644
index 0000000000..1b43507870
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+enum class SearchSortOrder(
+ val label: String,
+) {
+ NEWEST("Newest"),
+ OLDEST("Oldest"),
+ RELEVANCE("Relevance"),
+ NAME_AZ("A → Z"),
+ NAME_ZA("Z → A"),
+ ;
+
+ companion object {
+ val EVENT_OPTIONS = listOf(NEWEST, OLDEST, RELEVANCE)
+ val PEOPLE_OPTIONS = listOf(NAME_AZ, NAME_ZA)
+ val DEFAULT_EVENT = NEWEST
+ val DEFAULT_PEOPLE = NAME_AZ
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt
new file mode 100644
index 0000000000..5a03ab61c8
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class KindRegistryTest {
+ @Test
+ fun resolveNote() {
+ assertEquals(listOf(1), KindRegistry.resolve("note"))
+ }
+
+ @Test
+ fun resolveArticle() {
+ assertEquals(listOf(30023), KindRegistry.resolve("article"))
+ }
+
+ @Test
+ fun resolveChannel() {
+ val kinds = KindRegistry.resolve("channel")!!
+ assertTrue(40 in kinds)
+ assertTrue(41 in kinds)
+ }
+
+ @Test
+ fun resolveCaseInsensitive() {
+ assertEquals(KindRegistry.resolve("NOTE"), KindRegistry.resolve("note"))
+ }
+
+ @Test
+ fun resolveUnknown() {
+ assertNull(KindRegistry.resolve("unknown"))
+ }
+
+ @Test
+ fun isPseudoKindReply() {
+ assertTrue(KindRegistry.isPseudoKind("reply"))
+ assertTrue(KindRegistry.isPseudoKind("Reply"))
+ }
+
+ @Test
+ fun isPseudoKindMedia() {
+ assertTrue(KindRegistry.isPseudoKind("media"))
+ }
+
+ @Test
+ fun isNotPseudoKind() {
+ assertFalse(KindRegistry.isPseudoKind("note"))
+ assertFalse(KindRegistry.isPseudoKind("article"))
+ }
+
+ @Test
+ fun nameForKind1() {
+ assertEquals("note", KindRegistry.nameFor(1))
+ }
+
+ @Test
+ fun nameForKind30023() {
+ assertEquals("article", KindRegistry.nameFor(30023))
+ }
+
+ @Test
+ fun nameForUnknownKind() {
+ assertNull(KindRegistry.nameFor(99999))
+ }
+
+ @Test
+ fun allAliasesResolve() {
+ KindRegistry.aliases.forEach { (alias, kinds) ->
+ assertEquals(kinds, KindRegistry.resolve(alias))
+ }
+ }
+
+ @Test
+ fun presetsContainExpectedEntries() {
+ assertTrue(KindRegistry.presets.containsKey("Notes"))
+ assertTrue(KindRegistry.presets.containsKey("Articles"))
+ assertTrue(KindRegistry.presets.containsKey("Media"))
+ assertTrue(KindRegistry.presets.containsKey("Channels"))
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt
new file mode 100644
index 0000000000..4cae7f2c30
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt
@@ -0,0 +1,284 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class QueryParserTest {
+ @Test
+ fun emptyInput() {
+ val q = QueryParser.parse("")
+ assertTrue(q.isEmpty)
+ assertEquals(SearchQuery.EMPTY, q)
+ }
+
+ @Test
+ fun whitespaceOnly() {
+ val q = QueryParser.parse(" ")
+ assertTrue(q.isEmpty)
+ }
+
+ @Test
+ fun plainText() {
+ val q = QueryParser.parse("bitcoin lightning")
+ assertEquals("bitcoin lightning", q.text)
+ assertTrue(q.authors.isEmpty())
+ assertTrue(q.kinds.isEmpty())
+ }
+
+ @Test
+ fun kindOperatorAlias() {
+ val q = QueryParser.parse("kind:note")
+ assertEquals(listOf(1), q.kinds.toList())
+ assertTrue(q.text.isBlank())
+ }
+
+ @Test
+ fun kindOperatorNumeric() {
+ val q = QueryParser.parse("kind:30023")
+ assertEquals(listOf(30023), q.kinds.toList())
+ }
+
+ @Test
+ fun kindOperatorArticle() {
+ val q = QueryParser.parse("kind:article")
+ assertEquals(listOf(30023), q.kinds.toList())
+ }
+
+ @Test
+ fun kindOperatorInvalid() {
+ val q = QueryParser.parse("kind:invalid")
+ // Unresolvable kind → treated as text
+ assertEquals("kind:invalid", q.text)
+ assertTrue(q.kinds.isEmpty())
+ }
+
+ @Test
+ fun pseudoKindReply() {
+ val q = QueryParser.parse("kind:reply")
+ assertTrue(q.kinds.isEmpty())
+ assertEquals(listOf("reply"), q.pseudoKinds.toList())
+ }
+
+ @Test
+ fun pseudoKindMedia() {
+ val q = QueryParser.parse("kind:media")
+ assertTrue(q.kinds.isEmpty())
+ assertEquals(listOf("media"), q.pseudoKinds.toList())
+ }
+
+ @Test
+ fun multipleKinds() {
+ val q = QueryParser.parse("kind:note kind:article")
+ assertEquals(listOf(1, 30023), q.kinds.toList())
+ }
+
+ @Test
+ fun sinceDate() {
+ val q = QueryParser.parse("since:2025-01-01")
+ // 2025-01-01 00:00:00 UTC
+ assertEquals(1735689600L, q.since)
+ }
+
+ @Test
+ fun sinceDateYearOnly() {
+ val q = QueryParser.parse("since:2025")
+ // 2025-01-01 00:00:00 UTC
+ assertEquals(1735689600L, q.since)
+ }
+
+ @Test
+ fun sinceDateYearMonth() {
+ val q = QueryParser.parse("since:2025-06")
+ // 2025-06-01 00:00:00 UTC
+ val q2 = QueryParser.parse("since:2025-06-01")
+ assertEquals(q2.since, q.since)
+ }
+
+ @Test
+ fun sinceInvalidDate() {
+ val q = QueryParser.parse("since:not-a-date")
+ assertNull(q.since)
+ assertEquals("since:not-a-date", q.text)
+ }
+
+ @Test
+ fun untilDate() {
+ val q = QueryParser.parse("until:2025-12-31")
+ assertNull(q.since)
+ assertTrue(q.until != null && q.until!! > 0)
+ }
+
+ @Test
+ fun hashtag() {
+ val q = QueryParser.parse("#bitcoin")
+ assertEquals(listOf("bitcoin"), q.hashtags.toList())
+ assertTrue(q.text.isBlank())
+ }
+
+ @Test
+ fun multipleHashtags() {
+ val q = QueryParser.parse("#bitcoin #nostr")
+ assertEquals(listOf("bitcoin", "nostr"), q.hashtags.toList())
+ }
+
+ @Test
+ fun negationTerm() {
+ val q = QueryParser.parse("-spam")
+ assertEquals(listOf("spam"), q.excludeTerms.toList())
+ assertTrue(q.text.isBlank())
+ }
+
+ @Test
+ fun multipleNegations() {
+ val q = QueryParser.parse("-spam -scam")
+ assertEquals(listOf("spam", "scam"), q.excludeTerms.toList())
+ }
+
+ @Test
+ fun quotedPhrase() {
+ val q = QueryParser.parse("\"exact phrase\"")
+ assertEquals("\"exact phrase\"", q.text)
+ }
+
+ @Test
+ fun orTerms() {
+ val q = QueryParser.parse("bitcoin OR lightning")
+ assertEquals(listOf("bitcoin", "lightning"), q.orTerms.toList())
+ assertTrue(q.text.isBlank())
+ }
+
+ @Test
+ fun orTermsWithOperators() {
+ val q = QueryParser.parse("from:vitor bitcoin OR lightning kind:note")
+ assertEquals(listOf("bitcoin", "lightning"), q.orTerms.toList())
+ assertEquals(listOf(1), q.kinds.toList())
+ // from:vitor → authorNames since it's not a valid npub
+ assertEquals(listOf("vitor"), q.authorNames.toList())
+ }
+
+ @Test
+ fun orTermsCappedAtThree() {
+ val q = QueryParser.parse("a OR b OR c OR d OR e")
+ assertEquals(3, q.orTerms.size)
+ assertEquals(listOf("a", "b", "c"), q.orTerms.toList())
+ }
+
+ @Test
+ fun orphanedOr() {
+ val q = QueryParser.parse("OR")
+ assertEquals("OR", q.text)
+ assertTrue(q.orTerms.isEmpty())
+ }
+
+ @Test
+ fun languageOperator() {
+ val q = QueryParser.parse("lang:en bitcoin")
+ assertEquals("en", q.language)
+ assertEquals("bitcoin", q.text)
+ }
+
+ @Test
+ fun domainOperator() {
+ val q = QueryParser.parse("domain:nostr.com bitcoin")
+ assertEquals("nostr.com", q.domain)
+ assertEquals("bitcoin", q.text)
+ }
+
+ @Test
+ fun combinedQuery() {
+ val q = QueryParser.parse("kind:note since:2025-01-01 #bitcoin -spam lightning")
+ assertEquals(listOf(1), q.kinds.toList())
+ assertEquals(1735689600L, q.since)
+ assertEquals(listOf("bitcoin"), q.hashtags.toList())
+ assertEquals(listOf("spam"), q.excludeTerms.toList())
+ assertEquals("lightning", q.text)
+ }
+
+ @Test
+ fun caseInsensitiveOperators() {
+ val q = QueryParser.parse("FROM:vitor KIND:Note")
+ assertEquals(listOf("vitor"), q.authorNames.toList())
+ assertEquals(listOf(1), q.kinds.toList())
+ }
+
+ @Test
+ fun operatorWithNoValue() {
+ val q = QueryParser.parse("from:")
+ // Malformed → treated as text
+ assertEquals("from:", q.text)
+ assertTrue(q.authors.isEmpty())
+ }
+
+ @Test
+ fun fromWithAuthorName() {
+ val q = QueryParser.parse("from:vitor")
+ assertEquals(listOf("vitor"), q.authorNames.toList())
+ assertTrue(q.authors.isEmpty())
+ }
+
+ @Test
+ fun multipleFromAuthors() {
+ val q = QueryParser.parse("from:alice from:bob")
+ assertEquals(listOf("alice", "bob"), q.authorNames.toList())
+ }
+
+ @Test
+ fun duplicateAuthorsDeduped() {
+ val q = QueryParser.parse("from:vitor from:vitor")
+ assertEquals(1, q.authorNames.size)
+ }
+
+ @Test
+ fun parseDateToTimestamp_validDates() {
+ // 1970-01-01 = 0
+ assertEquals(0L, QueryParser.parseDateToTimestamp("1970-01-01"))
+ // 2000-01-01
+ assertEquals(946684800L, QueryParser.parseDateToTimestamp("2000-01-01"))
+ }
+
+ @Test
+ fun parseDateToTimestamp_invalidDates() {
+ assertNull(QueryParser.parseDateToTimestamp("not-a-date"))
+ assertNull(QueryParser.parseDateToTimestamp("1800-01-01"))
+ assertNull(QueryParser.parseDateToTimestamp("2025-13-01"))
+ assertNull(QueryParser.parseDateToTimestamp("2025-01-32"))
+ }
+
+ @Test
+ fun unicodeInFreeText() {
+ val q = QueryParser.parse("bitcoin 日本語 🚀")
+ assertFalse(q.isEmpty)
+ assertTrue(q.text.contains("日本語"))
+ assertTrue(q.text.contains("🚀"))
+ }
+
+ @Test
+ fun veryLongQuery() {
+ val longText = "word ".repeat(100).trim()
+ val q = QueryParser.parse(longText)
+ assertFalse(q.isEmpty)
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt
new file mode 100644
index 0000000000..0534e50138
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt
@@ -0,0 +1,177 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import kotlinx.collections.immutable.persistentListOf
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class QuerySerializerTest {
+ @Test
+ fun emptyQuery() {
+ assertEquals("", QuerySerializer.serialize(SearchQuery.EMPTY))
+ }
+
+ @Test
+ fun textOnly() {
+ val q = SearchQuery(text = "bitcoin")
+ assertEquals("bitcoin", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun kindOnly() {
+ val q = SearchQuery(kinds = persistentListOf(1))
+ assertEquals("kind:note", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun kindUnknown() {
+ val q = SearchQuery(kinds = persistentListOf(99999))
+ assertEquals("kind:99999", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun multipleKinds() {
+ val q = SearchQuery(kinds = persistentListOf(1, 30023))
+ assertEquals("kind:note kind:article", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun pseudoKinds() {
+ val q = SearchQuery(pseudoKinds = persistentListOf("reply", "media"))
+ assertEquals("kind:reply kind:media", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun sinceDate() {
+ val q = SearchQuery(since = 1735689600L) // 2025-01-01
+ assertEquals("since:2025-01-01", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun untilDate() {
+ val q = SearchQuery(until = 1735689600L)
+ assertEquals("until:2025-01-01", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun hashtags() {
+ val q = SearchQuery(hashtags = persistentListOf("bitcoin", "nostr"))
+ assertEquals("#bitcoin #nostr", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun excludeTerms() {
+ val q = SearchQuery(excludeTerms = persistentListOf("spam", "scam"))
+ assertEquals("-spam -scam", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun orTerms() {
+ val q = SearchQuery(orTerms = persistentListOf("bitcoin", "lightning"))
+ assertEquals("bitcoin OR lightning", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun language() {
+ val q = SearchQuery(language = "en", text = "bitcoin")
+ assertEquals("lang:en bitcoin", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun domain() {
+ val q = SearchQuery(domain = "nostr.com", text = "hello")
+ assertEquals("domain:nostr.com hello", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun authorNames() {
+ val q = SearchQuery(authorNames = persistentListOf("vitor"))
+ assertEquals("from:vitor", QuerySerializer.serialize(q))
+ }
+
+ @Test
+ fun combinedQuery() {
+ val q =
+ SearchQuery(
+ authorNames = persistentListOf("vitor"),
+ kinds = persistentListOf(1),
+ since = 1735689600L,
+ hashtags = persistentListOf("bitcoin"),
+ text = "lightning",
+ excludeTerms = persistentListOf("spam"),
+ )
+ val result = QuerySerializer.serialize(q)
+ assertTrue(result.contains("from:vitor"))
+ assertTrue(result.contains("kind:note"))
+ assertTrue(result.contains("since:2025-01-01"))
+ assertTrue(result.contains("#bitcoin"))
+ assertTrue(result.contains("lightning"))
+ assertTrue(result.contains("-spam"))
+ }
+
+ @Test
+ fun orderingOperatorsFirst() {
+ val q =
+ SearchQuery(
+ authorNames = persistentListOf("alice"),
+ kinds = persistentListOf(1),
+ hashtags = persistentListOf("nostr"),
+ text = "hello",
+ excludeTerms = persistentListOf("bad"),
+ )
+ val result = QuerySerializer.serialize(q)
+ val fromIdx = result.indexOf("from:")
+ val kindIdx = result.indexOf("kind:")
+ val hashIdx = result.indexOf("#nostr")
+ val textIdx = result.indexOf("hello")
+ val excludeIdx = result.indexOf("-bad")
+ assertTrue(fromIdx < kindIdx)
+ assertTrue(kindIdx < hashIdx)
+ assertTrue(hashIdx < textIdx)
+ assertTrue(textIdx < excludeIdx)
+ }
+
+ @Test
+ fun timestampToDateEpoch() {
+ assertEquals("1970-01-01", QuerySerializer.timestampToDate(0L))
+ }
+
+ @Test
+ fun timestampToDate2025() {
+ assertEquals("2025-01-01", QuerySerializer.timestampToDate(1735689600L))
+ }
+
+ @Test
+ fun roundtrip() {
+ // Parse a complex query, serialize, parse again — should produce equivalent SearchQuery
+ val input = "kind:note since:2025-01-01 #bitcoin lightning -spam"
+ val q1 = QueryParser.parse(input)
+ val serialized = QuerySerializer.serialize(q1)
+ val q2 = QueryParser.parse(serialized)
+ assertEquals(q1.kinds, q2.kinds)
+ assertEquals(q1.since, q2.since)
+ assertEquals(q1.hashtags, q2.hashtags)
+ assertEquals(q1.excludeTerms, q2.excludeTerms)
+ assertEquals(q1.text, q2.text)
+ }
+}
diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt
new file mode 100644
index 0000000000..bdf3b3cf6e
--- /dev/null
+++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.commons.search
+
+import com.vitorpamplona.amethyst.commons.model.Note
+import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
+import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
+import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class SearchResultSorterTest {
+ private fun event(
+ id: String,
+ createdAt: Long,
+ content: String = "",
+ kind: Int = 1,
+ ) = Event(
+ id = id,
+ pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
+ createdAt = createdAt,
+ kind = kind,
+ tags = emptyArray(),
+ content = content,
+ sig = "sig",
+ )
+
+ private fun article(
+ id: String,
+ createdAt: Long,
+ content: String = "",
+ title: String? = null,
+ ): LongTextNoteEvent {
+ val tags =
+ if (title != null) {
+ arrayOf(arrayOf("title", title))
+ } else {
+ emptyArray()
+ }
+ return LongTextNoteEvent(
+ id = id,
+ pubKey = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
+ createdAt = createdAt,
+ tags = tags,
+ content = content,
+ sig = "sig",
+ )
+ }
+
+ private fun user(
+ hex: String,
+ displayName: String,
+ ): User {
+ val u = User(hex, Note("r1-$hex"), Note("r2-$hex"))
+ val meta = UserMetadata().apply { this.displayName = displayName }
+ val metaEvent =
+ MetadataEvent(
+ id = "meta-$hex",
+ pubKey = hex,
+ createdAt = 0L,
+ tags = emptyArray(),
+ content = "{}",
+ sig = "sig",
+ )
+ u.updateUserInfo(meta, metaEvent)
+ return u
+ }
+
+ // --- Event sorting ---
+
+ @Test
+ fun newestSortsDescending() {
+ val events = listOf(event("a", 100), event("b", 300), event("c", 200))
+ val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.NEWEST, "")
+ assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
+ }
+
+ @Test
+ fun oldestSortsAscending() {
+ val events = listOf(event("a", 300), event("b", 100), event("c", 200))
+ val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.OLDEST, "")
+ assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
+ }
+
+ @Test
+ fun relevanceEmptyQueryFallsBackToRecency() {
+ val events = listOf(event("a", 100), event("b", 300), event("c", 200))
+ val sorted = SearchResultSorter.sortEvents(events, SearchSortOrder.RELEVANCE, "")
+ assertEquals(listOf("b", "c", "a"), sorted.map { it.id })
+ }
+
+ @Test
+ fun relevanceExactMatchBeatsPartial() {
+ val exact = event("exact", 100, content = "bitcoin is great")
+ val partial = event("partial", 100, content = "bit of something")
+ val sorted = SearchResultSorter.sortEvents(listOf(partial, exact), SearchSortOrder.RELEVANCE, "bitcoin")
+ assertEquals("exact", sorted.first().id)
+ }
+
+ @Test
+ fun relevanceWordBoundaryBeatsSubstring() {
+ val boundary = event("boundary", 100, content = "I love bitcoin and lightning")
+ val substring = event("substr", 100, content = "bitcoinery is not a word")
+ val sorted = SearchResultSorter.sortEvents(listOf(substring, boundary), SearchSortOrder.RELEVANCE, "bitcoin")
+ assertEquals("boundary", sorted.first().id)
+ }
+
+ @Test
+ fun relevanceArticleTitleBoost() {
+ val withTitle = article("titled", 100, content = "some content", title = "Bitcoin Guide")
+ val withoutTitle = event("notitle", 100, content = "bitcoin bitcoin bitcoin")
+ val sorted = SearchResultSorter.sortEvents(listOf(withoutTitle, withTitle), SearchSortOrder.RELEVANCE, "bitcoin")
+ assertEquals("titled", sorted.first().id)
+ }
+
+ @Test
+ fun relevanceMultipleTokensAddUp() {
+ val multi = event("multi", 100, content = "bitcoin and lightning network")
+ val single = event("single", 100, content = "bitcoin only here")
+ val sorted =
+ SearchResultSorter.sortEvents(
+ listOf(single, multi),
+ SearchSortOrder.RELEVANCE,
+ "bitcoin lightning",
+ )
+ assertEquals("multi", sorted.first().id)
+ }
+
+ // --- People sorting ---
+
+ @Test
+ fun nameAzSortsAlphabetically() {
+ val people =
+ listOf(
+ user("cc00000000000000000000000000000000000000000000000000000000000000", "Charlie"),
+ user("aa00000000000000000000000000000000000000000000000000000000000000", "Alice"),
+ user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"),
+ )
+ val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_AZ)
+ assertEquals(listOf("Alice", "Bob", "Charlie"), sorted.map { it.toBestDisplayName() })
+ }
+
+ @Test
+ fun nameZaSortsReverseAlphabetically() {
+ val people =
+ listOf(
+ user("aa00000000000000000000000000000000000000000000000000000000000000", "Alice"),
+ user("cc00000000000000000000000000000000000000000000000000000000000000", "Charlie"),
+ user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"),
+ )
+ val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_ZA)
+ assertEquals(listOf("Charlie", "Bob", "Alice"), sorted.map { it.toBestDisplayName() })
+ }
+
+ @Test
+ fun nameSortIsCaseInsensitive() {
+ val people =
+ listOf(
+ user("aa00000000000000000000000000000000000000000000000000000000000000", "alice"),
+ user("bb00000000000000000000000000000000000000000000000000000000000000", "Bob"),
+ )
+ val sorted = SearchResultSorter.sortPeople(people, SearchSortOrder.NAME_AZ)
+ assertEquals("alice", sorted.first().toBestDisplayName())
+ }
+
+ // --- Score function ---
+
+ @Test
+ fun scoreExactPhraseHigherThanTokens() {
+ val exactEvent = event("e", 100, content = "bitcoin lightning network")
+ val tokenEvent = event("t", 100, content = "lightning and bitcoin elsewhere network")
+ val exactScore = SearchResultSorter.scoreEvent(exactEvent, "bitcoin lightning")
+ val tokenScore = SearchResultSorter.scoreEvent(tokenEvent, "bitcoin lightning")
+ assertTrue(exactScore > tokenScore)
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt
new file mode 100644
index 0000000000..9c3e3b01ad
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop
+
+import com.vitorpamplona.amethyst.commons.search.QueryParser
+import com.vitorpamplona.amethyst.commons.search.QuerySerializer
+import com.vitorpamplona.amethyst.commons.search.SavedSearch
+import com.vitorpamplona.amethyst.commons.search.SearchQuery
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.util.prefs.Preferences
+
+object SearchHistoryStore {
+ private val prefs: Preferences = Preferences.userNodeForPackage(SearchHistoryStore::class.java)
+
+ private const val KEY_HISTORY = "search_history"
+ private const val KEY_SAVED = "saved_searches"
+ private const val SEPARATOR = "\n"
+ private const val SAVED_SEPARATOR = "\t"
+ private const val MAX_HISTORY = 20
+
+ private val _history = MutableStateFlow>(emptyList())
+ val history: StateFlow> = _history.asStateFlow()
+
+ private val _savedSearches = MutableStateFlow>(emptyList())
+ val savedSearches: StateFlow> = _savedSearches.asStateFlow()
+
+ init {
+ _history.value = loadHistory()
+ _savedSearches.value = loadSaved()
+ }
+
+ fun addToHistory(query: SearchQuery) {
+ if (query.isEmpty) return
+ val serialized = QuerySerializer.serialize(query)
+ val current = _history.value.toMutableList()
+ current.removeAll { QuerySerializer.serialize(it) == serialized }
+ current.add(0, query)
+ if (current.size > MAX_HISTORY) {
+ current.subList(MAX_HISTORY, current.size).clear()
+ }
+ _history.value = current.toList()
+ persistHistory(current)
+ }
+
+ fun clearHistory() {
+ _history.value = emptyList()
+ prefs.remove(KEY_HISTORY)
+ }
+
+ fun saveSearch(
+ query: SearchQuery,
+ label: String,
+ ) {
+ if (query.isEmpty) return
+ val saved =
+ SavedSearch(
+ id = System.currentTimeMillis().toString(),
+ label = label,
+ query = query,
+ createdAt = System.currentTimeMillis() / 1000,
+ )
+ val current = _savedSearches.value + saved
+ _savedSearches.value = current
+ persistSaved(current)
+ }
+
+ fun deleteSavedSearch(id: String) {
+ val current = _savedSearches.value.filter { it.id != id }
+ _savedSearches.value = current
+ persistSaved(current)
+ }
+
+ private fun loadHistory(): List {
+ val raw = prefs.get(KEY_HISTORY, "")
+ if (raw.isBlank()) return emptyList()
+ return raw
+ .split(SEPARATOR)
+ .filter { it.isNotBlank() }
+ .mapNotNull { line ->
+ val parsed = QueryParser.parse(line)
+ if (parsed.isEmpty) null else parsed
+ }
+ }
+
+ private fun persistHistory(queries: List) {
+ val raw = queries.joinToString(SEPARATOR) { QuerySerializer.serialize(it) }
+ prefs.put(KEY_HISTORY, raw)
+ }
+
+ private fun loadSaved(): List {
+ val raw = prefs.get(KEY_SAVED, "")
+ if (raw.isBlank()) return emptyList()
+ return raw
+ .split(SEPARATOR)
+ .filter { it.isNotBlank() }
+ .mapNotNull { line ->
+ val parts = line.split(SAVED_SEPARATOR)
+ if (parts.size < 4) return@mapNotNull null
+ val id = parts[0]
+ val label = parts[1]
+ val createdAt = parts[2].toLongOrNull() ?: return@mapNotNull null
+ val queryText = parts[3]
+ val query = QueryParser.parse(queryText)
+ if (query.isEmpty) return@mapNotNull null
+ SavedSearch(id = id, label = label, query = query, createdAt = createdAt)
+ }
+ }
+
+ private fun persistSaved(searches: List) {
+ val raw =
+ searches.joinToString(SEPARATOR) { s ->
+ listOf(s.id, s.label, s.createdAt.toString(), QuerySerializer.serialize(s.query))
+ .joinToString(SAVED_SEPARATOR)
+ }
+ prefs.put(KEY_SAVED, raw)
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt
index 5266f9621a..e31000e7e4 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt
@@ -45,6 +45,7 @@ object DefaultRelays {
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
+ "wss://relay.noswhere.com",
"wss://relay.primal.net",
)
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt
index d4d3bac207..a7d4a633a3 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt
@@ -145,6 +145,7 @@ fun createSearchPeopleSubscription(
limit: Int = 50,
onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> },
+ onClosed: (NormalizedRelayUrl, String, List?) -> Unit = { _, _, _ -> },
): SubscriptionConfig? {
if (searchQuery.isBlank()) return null
@@ -154,6 +155,7 @@ fun createSearchPeopleSubscription(
relays = relays,
onEvent = onEvent,
onEose = onEose,
+ onClosed = onClosed,
)
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt
new file mode 100644
index 0000000000..6a61b000d7
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.subscriptions
+
+import com.vitorpamplona.amethyst.commons.search.SearchQuery
+import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
+import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
+import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
+import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
+import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
+import com.vitorpamplona.quartz.experimental.nns.NNSEvent
+import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
+import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
+import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import com.vitorpamplona.quartz.nip22Comments.CommentEvent
+import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
+import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
+import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
+import com.vitorpamplona.quartz.nip51Lists.PinListEvent
+import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
+import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
+import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
+import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
+import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
+import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
+import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
+import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
+import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
+import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
+import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
+
+object SearchFilterFactory {
+ // Default kind groups (ported from Android SearchPostsByText)
+ private val defaultKindGroup1 =
+ listOf(
+ TextNoteEvent.KIND,
+ LongTextNoteEvent.KIND,
+ BadgeDefinitionEvent.KIND,
+ PeopleListEvent.KIND,
+ BookmarkListEvent.KIND,
+ AudioHeaderEvent.KIND,
+ AudioTrackEvent.KIND,
+ PinListEvent.KIND,
+ PollNoteEvent.KIND,
+ ChannelCreateEvent.KIND,
+ )
+
+ private val defaultKindGroup2 =
+ listOf(
+ ChannelMetadataEvent.KIND,
+ ClassifiedsEvent.KIND,
+ CommunityDefinitionEvent.KIND,
+ EmojiPackEvent.KIND,
+ HighlightEvent.KIND,
+ LiveActivitiesEvent.KIND,
+ PublicMessageEvent.KIND,
+ NNSEvent.KIND,
+ WikiNoteEvent.KIND,
+ CommentEvent.KIND,
+ )
+
+ private val defaultKindGroup3 =
+ listOf(
+ InteractiveStoryPrologueEvent.KIND,
+ InteractiveStorySceneEvent.KIND,
+ FollowListEvent.KIND,
+ NipTextEvent.KIND,
+ PollEvent.KIND,
+ PollResponseEvent.KIND,
+ )
+
+ fun createFilters(
+ query: SearchQuery,
+ limit: Int = 100,
+ ): List {
+ if (query.isEmpty) return emptyList()
+
+ val searchString = buildSearchString(query)
+ val tags = buildTags(query)
+ val authors = query.authors.takeIf { it.isNotEmpty() }
+
+ if (query.kinds.isNotEmpty()) {
+ // User specified kinds — single filter (no group splitting needed)
+ return listOf(
+ Filter(
+ kinds = query.kinds.toList(),
+ search = searchString,
+ authors = authors,
+ tags = tags,
+ since = query.since,
+ until = query.until,
+ limit = limit,
+ ),
+ )
+ }
+
+ // No kinds specified — use default 3-group search (Android parity)
+ return listOf(defaultKindGroup1, defaultKindGroup2, defaultKindGroup3).map { kindGroup ->
+ Filter(
+ kinds = kindGroup,
+ search = searchString,
+ authors = authors,
+ tags = tags,
+ since = query.since,
+ until = query.until,
+ limit = limit,
+ )
+ }
+ }
+
+ private fun buildSearchString(query: SearchQuery): String? {
+ val parts = mutableListOf()
+
+ // Free text (exclude negation terms — those are client-side only)
+ if (query.text.isNotBlank()) {
+ parts.add(query.text)
+ }
+
+ // NIP-50 inline extensions
+ query.language?.let { parts.add("language:$it") }
+ query.domain?.let { parts.add("domain:$it") }
+
+ return parts.joinToString(" ").takeIf { it.isNotBlank() }
+ }
+
+ private fun buildTags(query: SearchQuery): Map>? {
+ if (query.hashtags.isEmpty()) return null
+ return mapOf("t" to query.hashtags.toList())
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt
index 8aecc2f2d0..f7ce8727d5 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt
@@ -46,6 +46,7 @@ data class SubscriptionConfig(
val relays: Set,
val onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit,
val onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> },
+ val onClosed: (NormalizedRelayUrl, String, List?) -> Unit = { _, _, _ -> },
)
/**
@@ -95,6 +96,14 @@ fun rememberSubscription(
) {
cfg.onEose(relay, forFilters)
}
+
+ override fun onClosed(
+ message: String,
+ relay: NormalizedRelayUrl,
+ forFilters: List?,
+ ) {
+ cfg.onClosed(relay, message, forFilters)
+ }
},
)
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt
index 1caa34d945..da405b6c4a 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt
@@ -20,6 +20,11 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -37,39 +42,68 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Tag
+import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
+import com.vitorpamplona.amethyst.commons.model.User
+import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
+import com.vitorpamplona.amethyst.commons.search.QuerySerializer
+import com.vitorpamplona.amethyst.commons.search.SavedSearch
+import com.vitorpamplona.amethyst.commons.search.SearchQuery
import com.vitorpamplona.amethyst.commons.search.SearchResult
-import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
-import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState
+import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
+import com.vitorpamplona.amethyst.commons.search.parseSearchInput
+import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
+import com.vitorpamplona.amethyst.desktop.subscriptions.SearchFilterFactory
+import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription
+import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
+import com.vitorpamplona.amethyst.desktop.ui.search.AdvancedSearchPanel
+import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
+import com.vitorpamplona.amethyst.desktop.ui.search.SearchSyncBanner
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
@@ -85,61 +119,140 @@ fun SearchScreen(
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
- val searchState = remember { SearchBarState(localCache, scope) }
+ val state = remember { AdvancedSearchBarState(scope) }
val focusRequester = remember { FocusRequester() }
- // Pre-fill initial query (e.g., hashtag column)
+ // Pre-fill initial query
LaunchedEffect(initialQuery) {
if (initialQuery.isNotBlank()) {
- searchState.updateSearchText(initialQuery)
+ state.updateFromText(initialQuery)
}
}
+
val connectedRelays by relayManager.connectedRelays.collectAsState()
+ val relayStatuses by relayManager.relayStatuses.collectAsState()
+ val allRelayUrls = remember(relayStatuses) { relayStatuses.keys }
+ val displayText by state.displayText.collectAsState()
+ // Track TextFieldValue locally to preserve cursor position
+ var textFieldValue by remember { mutableStateOf(TextFieldValue(displayText)) }
+ // Sync from flow only when text changes externally (form-driven updates)
+ LaunchedEffect(displayText) {
+ if (textFieldValue.text != displayText) {
+ textFieldValue = TextFieldValue(text = displayText, selection = TextRange(displayText.length))
+ }
+ }
+ val query by state.query.collectAsState()
+ val debouncedQuery by state.debouncedQuery.collectAsState()
+ val panelExpanded by state.panelExpanded.collectAsState()
+ val isSearching by state.isSearching.collectAsState()
+ val peopleResults by state.peopleResults.collectAsState()
+ val noteResults by state.noteResults.collectAsState()
+ val relayStates by state.relayStates.collectAsState()
- // Collect state from SearchBarState
- val searchText by searchState.searchText.collectAsState()
- val bech32Results by searchState.bech32Results.collectAsState()
- val cachedUserResults by searchState.cachedUserResults.collectAsState()
- val relaySearchResults by searchState.relaySearchResults.collectAsState()
- val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
+ // Bech32 parsing (immediate, no debounce)
+ val bech32Results = remember(displayText) { parseSearchInput(displayText) }
- // NIP-50 relay search when local cache has few/no results
- rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) {
- if (connectedRelays.isEmpty()) return@rememberSubscription null
+ // Skip people search when query specifies kinds that don't include profile (kind 0)
+ val shouldSearchPeople =
+ (debouncedQuery.kinds.isEmpty() && debouncedQuery.pseudoKinds.isEmpty()) ||
+ debouncedQuery.kinds.contains(MetadataEvent.KIND)
- // Only search relays if we have a real query and limited local results
- if (searchState.shouldSearchRelays) {
- searchState.startRelaySearch()
- createSearchPeopleSubscription(
- relays = connectedRelays,
- searchQuery = searchText,
- limit = 20,
- onEvent = { event, _, _, _ ->
- if (event is MetadataEvent) {
- localCache.consumeMetadata(event)
- val user = localCache.getUserIfExists(event.pubKey)
- if (user != null) {
- searchState.addRelaySearchResult(user)
- }
- }
- },
- onEose = { _, _ ->
- searchState.endRelaySearch()
- },
- )
- } else {
- null
+ // Clear results and start loading when query changes
+ LaunchedEffect(debouncedQuery) {
+ if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) {
+ state.clearResults()
+ state.initRelayStates(allRelayUrls)
+ if (shouldSearchPeople) {
+ state.startSearching("people-search")
+ }
+ state.startSearching("adv-search")
+ // Timeout relays that silently ignore NIP-50 (e.g. strfry)
+ kotlinx.coroutines.delay(10_000L)
+ state.timeoutWaitingRelays()
}
}
- // Subscribe to metadata for searched users (to populate cache)
- rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
- if (connectedRelays.isEmpty() || searchText.length < 2) {
+ // NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect)
+ rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
+ if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
+ return@rememberSubscription null
+ }
+ if (bech32Results.isNotEmpty()) return@rememberSubscription null
+ if (!shouldSearchPeople) {
+ state.stopSearching("people-search")
return@rememberSubscription null
}
- // If it's a specific pubkey search, fetch that user's metadata
- val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
+ createSearchPeopleSubscription(
+ relays = allRelayUrls,
+ searchQuery =
+ debouncedQuery.text.ifBlank {
+ QuerySerializer.serialize(debouncedQuery)
+ },
+ limit = 20,
+ onEvent = { event, _, relay, _ ->
+ if (state.trackRelayEvent(relay.url, event.id)) {
+ if (event is MetadataEvent) {
+ localCache.consumeMetadata(event)
+ @Suppress("UNCHECKED_CAST")
+ val user = localCache.getUserIfExists(event.pubKey) as? User
+ if (user != null) {
+ state.addPeopleResult(user)
+ }
+ }
+ }
+ },
+ onEose = { relay, _ ->
+ state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
+ state.stopSearching("people-search")
+ },
+ onClosed = { relay, _, _ ->
+ state.updateRelayState(relay.url, RelaySyncStatus.FAILED)
+ state.stopSearching("people-search")
+ },
+ )
+ }
+
+ // NIP-50 advanced note search subscription (use allRelayUrls)
+ rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) {
+ if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) {
+ return@rememberSubscription null
+ }
+ if (bech32Results.isNotEmpty()) return@rememberSubscription null
+
+ val filters = SearchFilterFactory.createFilters(debouncedQuery)
+ if (filters.isEmpty()) return@rememberSubscription null
+
+ SubscriptionConfig(
+ subId = generateSubId("adv-search"),
+ filters = filters,
+ relays = allRelayUrls,
+ onEvent = { event, _, relay, _ ->
+ if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig
+ if (state.trackRelayEvent(relay.url, event.id)) {
+ val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery)
+ if (filtered.isNotEmpty()) {
+ state.addNoteResults(filtered)
+ }
+ }
+ },
+ onEose = { relay, _ ->
+ state.updateRelayState(relay.url, RelaySyncStatus.EOSE_RECEIVED)
+ state.stopSearching("adv-search")
+ },
+ onClosed = { relay, _, _ ->
+ state.updateRelayState(relay.url, RelaySyncStatus.FAILED)
+ state.stopSearching("adv-search")
+ },
+ )
+ }
+
+ // Metadata subscription for bech32 pubkey lookups
+ rememberSubscription(connectedRelays, displayText, relayManager = relayManager) {
+ if (connectedRelays.isEmpty() || displayText.length < 2) {
+ return@rememberSubscription null
+ }
+ val pubkeyHex = decodePublicKeyAsHexOrNull(displayText)
if (pubkeyHex != null) {
createMetadataSubscription(
relays = connectedRelays,
@@ -155,14 +268,67 @@ fun SearchScreen(
}
}
- // Auto-focus the search field
+ // Save to history when search completes (snapshotFlow avoids LaunchedEffect race)
+ LaunchedEffect(Unit) {
+ snapshotFlow { isSearching to debouncedQuery }
+ .collect { (searching, query) ->
+ if (!searching && !query.isEmpty) {
+ SearchHistoryStore.addToHistory(query)
+ }
+ }
+ }
+
+ // History state
+ val historyItems by SearchHistoryStore.history.collectAsState()
+ val savedSearches by SearchHistoryStore.savedSearches.collectAsState()
+
+ // Auto-focus
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
- modifier = modifier.fillMaxSize(),
+ modifier =
+ modifier
+ .fillMaxSize()
+ .onPreviewKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (event.key) {
+ Key.Escape -> {
+ if (panelExpanded) {
+ state.togglePanel()
+ } else if (displayText.isNotEmpty()) {
+ state.clearSearch()
+ }
+ true
+ }
+
+ else -> {
+ false
+ }
+ }
+ },
) {
+ // Progress bar at very top
+ AnimatedVisibility(
+ visible = isSearching,
+ enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
+ exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
+ ) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ }
+
+ // Relay status banner
+ SearchSyncBanner(
+ relayStates = relayStates,
+ isSearching = isSearching,
+ )
+
+ // Title row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -182,180 +348,265 @@ fun SearchScreen(
Spacer(Modifier.height(16.dp))
- // Search input field
- OutlinedTextField(
- value = searchText,
- onValueChange = { searchState.updateSearchText(it) },
- modifier =
- Modifier
- .fillMaxWidth()
- .focusRequester(focusRequester),
- placeholder = { Text("Search by name, npub, nevent, or #hashtag") },
- leadingIcon = {
- Icon(
- Icons.Default.Search,
- contentDescription = "Search",
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- },
- trailingIcon = {
- if (searchText.isNotEmpty()) {
- IconButton(onClick = { searchState.clearSearch() }) {
- Icon(
- Icons.Default.Clear,
- contentDescription = "Clear",
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
+ // Search bar with advanced toggle
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedTextField(
+ value = textFieldValue,
+ onValueChange = {
+ textFieldValue = it
+ state.updateFromText(it.text)
+ },
+ modifier = Modifier.weight(1f).focusRequester(focusRequester),
+ placeholder = { Text("Search notes, people, tags... or use operators") },
+ leadingIcon = {
+ Icon(
+ Icons.Default.Search,
+ contentDescription = "Search",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ trailingIcon = {
+ if (displayText.isNotEmpty()) {
+ IconButton(onClick = { state.clearSearch() }) {
+ Icon(
+ Icons.Default.Clear,
+ contentDescription = "Clear",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
- }
- },
- singleLine = true,
- shape = RoundedCornerShape(12.dp),
- )
+ },
+ singleLine = true,
+ shape = RoundedCornerShape(12.dp),
+ )
+ IconButton(onClick = { state.togglePanel() }) {
+ Icon(
+ Icons.Default.Tune,
+ contentDescription = "Advanced Search",
+ tint =
+ if (panelExpanded) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+ }
+
+ // 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) },
+ onPseudoKindsChanged = { state.updatePseudoKinds(it) },
+ onAuthorAdded = { state.addAuthor(it) },
+ onAuthorRemoved = { state.removeAuthor(it) },
+ onDateRangeChanged = { since, until -> state.updateDateRange(since, until) },
+ onHashtagAdded = { state.addHashtag(it) },
+ onHashtagRemoved = { state.removeHashtag(it) },
+ onExcludeAdded = { state.addExcludeTerm(it) },
+ onExcludeRemoved = { state.removeExcludeTerm(it) },
+ onLanguageChanged = { state.updateLanguage(it) },
+ onClear = { state.clearSearch() },
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
Spacer(Modifier.height(16.dp))
// Results
- val hasResults = bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty() || relaySearchResults.isNotEmpty()
+ val hasAnyResults =
+ bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty()
- if (!hasResults && searchText.isNotEmpty() && searchText.length >= 2 && !isSearchingRelays) {
+ if (bech32Results.isNotEmpty()) {
+ // Show bech32 results (exact lookup)
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ "Direct lookup",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 4.dp),
+ )
+ bech32Results.forEach { result ->
+ SearchResultCard(
+ result = result,
+ onNavigateToProfile = onNavigateToProfile,
+ onNavigateToThread = onNavigateToThread,
+ onNavigateToHashtag = onNavigateToHashtag,
+ )
+ }
+ }
+ } else if (hasAnyResults) {
+ SearchResultsList(
+ state = state,
+ onNavigateToProfile = onNavigateToProfile,
+ onNavigateToThread = onNavigateToThread,
+ )
+ } else if (!debouncedQuery.isEmpty && !isSearching) {
Text(
- "No matches found. Try a name, npub, nevent, or #hashtag.",
+ "No results found. Try broader terms or fewer filters.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
- } else if (isSearchingRelays && !hasResults) {
- Text(
- "Searching relays...",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.bodyMedium,
+ } else if (!isSearching) {
+ // Empty state: show history + saved searches + operator hints
+ SearchEmptyState(
+ historyItems = historyItems,
+ savedSearches = savedSearches,
+ onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) },
+ onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) },
+ onClearHistory = { SearchHistoryStore.clearHistory() },
)
- } else if (hasResults) {
- LazyColumn(
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- // Bech32/hex results first
- if (bech32Results.isNotEmpty()) {
- item {
- Text(
- "Direct lookup",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(vertical = 4.dp),
- )
- }
- items(bech32Results) { result ->
- SearchResultCard(
- result = result,
- onNavigateToProfile = onNavigateToProfile,
- onNavigateToThread = onNavigateToThread,
- onNavigateToHashtag = onNavigateToHashtag,
- )
- }
- }
-
- // Cached user results
- if (cachedUserResults.isNotEmpty()) {
- if (bech32Results.isNotEmpty()) {
- item {
- HorizontalDivider(Modifier.padding(vertical = 8.dp))
- }
- }
- item {
- Text(
- "Cached users (${cachedUserResults.size})",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(vertical = 4.dp),
- )
- }
- items(cachedUserResults, key = { "cached-${it.pubkeyHex}" }) { user ->
- UserSearchCard(
- user = user,
- onClick = { onNavigateToProfile(user.pubkeyHex) },
- )
- }
- }
-
- // Relay search results (NIP-50)
- if (relaySearchResults.isNotEmpty()) {
- if (bech32Results.isNotEmpty() || cachedUserResults.isNotEmpty()) {
- item {
- HorizontalDivider(Modifier.padding(vertical = 8.dp))
- }
- }
- item {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Text(
- "From relays (${relaySearchResults.size})",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(vertical = 4.dp),
- )
- if (isSearchingRelays) {
- Text(
- "searching...",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- }
- }
- items(relaySearchResults, key = { "relay-${it.pubkeyHex}" }) { user ->
- UserSearchCard(
- user = user,
- onClick = { onNavigateToProfile(user.pubkeyHex) },
- )
- }
- } else if (isSearchingRelays && cachedUserResults.isEmpty()) {
- item {
- Text(
- "Searching relays...",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(vertical = 8.dp),
- )
- }
- }
- }
- } else {
- // Empty state
- Column(
- modifier = Modifier.fillMaxWidth().padding(top = 32.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Text(
- "Search for users or notes",
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Spacer(Modifier.height(8.dp))
- Text(
- "Enter a name or Nostr identifier:",
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.bodyMedium,
- )
- Spacer(Modifier.height(16.dp))
- Column(
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- SearchHint("vitor", "Search by name")
- SearchHint("npub1...", "User profile")
- SearchHint("note1...", "Single note")
- SearchHint("nevent1...", "Note with metadata")
- SearchHint("#hashtag", "Hashtag search")
- }
- }
}
}
}
+@Composable
+private fun SearchEmptyState(
+ historyItems: List,
+ savedSearches: List,
+ onLoadQuery: (SearchQuery) -> Unit,
+ onDeleteSaved: (String) -> Unit,
+ onClearHistory: () -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ // Saved searches
+ if (savedSearches.isNotEmpty()) {
+ item {
+ Text(
+ "Saved Searches",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 4.dp),
+ )
+ }
+ items(savedSearches, key = { "saved-${it.id}" }) { saved ->
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable { onLoadQuery(saved.query) }
+ .padding(vertical = 6.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.Star,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ saved.label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ QuerySerializer.serialize(saved.query),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+ IconButton(onClick = { onDeleteSaved(saved.id) }) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
+ }
+
+ // Recent history
+ if (historyItems.isNotEmpty()) {
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ "Recent",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ TextButton(onClick = onClearHistory) {
+ Text("Clear", style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ items(historyItems.take(10), key = { "history-${QuerySerializer.serialize(it)}" }) { query ->
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable { onLoadQuery(query) }
+ .padding(vertical = 6.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(
+ Icons.Default.History,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ QuerySerializer.serialize(query),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
+ }
+
+ // Operator hints
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(top = if (historyItems.isEmpty() && savedSearches.isEmpty()) 32.dp else 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ "Search operators",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+ item { SearchHint("from:npub1...", "Filter by author") }
+ item { SearchHint("kind:article", "Long-form content") }
+ item { SearchHint("since:2025-01", "After January 2025") }
+ item { SearchHint("#bitcoin", "Hashtag search") }
+ item { SearchHint("\"exact phrase\"", "Exact match") }
+ item { SearchHint("bitcoin OR nostr", "Either term") }
+ item { SearchHint("-spam", "Exclude term") }
+ item { SearchHint("lang:en", "Language filter") }
+ }
+}
+
@Composable
private fun SearchHint(
- identifier: String,
+ example: String,
description: String,
) {
Row(
@@ -363,7 +614,7 @@ private fun SearchHint(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
- identifier,
+ example,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.primary,
@@ -390,25 +641,10 @@ private fun SearchResultCard(
.fillMaxWidth()
.clickable {
when (result) {
- is SearchResult.UserResult -> {
- onNavigateToProfile(result.pubKeyHex)
- }
-
- is SearchResult.CachedUserResult -> {
- onNavigateToProfile(result.user.pubkeyHex)
- }
-
- is SearchResult.NoteResult -> {
- onNavigateToThread(result.noteIdHex)
- }
-
- is SearchResult.AddressResult -> {
- onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
- }
-
- is SearchResult.HashtagResult -> {
- onNavigateToHashtag(result.hashtag)
- }
+ is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex)
+ is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex)
+ is SearchResult.AddressResult -> onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
+ is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag)
}
},
colors =
@@ -425,7 +661,6 @@ private fun SearchResultCard(
imageVector =
when (result) {
is SearchResult.UserResult -> Icons.Default.Person
- is SearchResult.CachedUserResult -> Icons.Default.Person
is SearchResult.NoteResult -> Icons.Default.Description
is SearchResult.AddressResult -> Icons.Default.Description
is SearchResult.HashtagResult -> Icons.Default.Tag
@@ -439,7 +674,6 @@ private fun SearchResultCard(
Text(
when (result) {
is SearchResult.UserResult -> "User Profile"
- is SearchResult.CachedUserResult -> result.user.toBestDisplayName()
is SearchResult.NoteResult -> "Note"
is SearchResult.AddressResult -> "Event (kind ${result.kind})"
is SearchResult.HashtagResult -> "#${result.hashtag}"
@@ -450,7 +684,6 @@ private fun SearchResultCard(
Text(
when (result) {
is SearchResult.UserResult -> result.displayId
- is SearchResult.CachedUserResult -> result.user.pubkeyDisplayHex()
is SearchResult.NoteResult -> result.displayId
is SearchResult.AddressResult -> result.displayId
is SearchResult.HashtagResult -> "Search posts with this hashtag"
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt
index ce90b7ef6c..b59abab0fe 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -120,37 +122,42 @@ fun DeckColumnContainer(
Box(
modifier = Modifier.fillMaxSize().padding(12.dp),
) {
+ // Always keep RootContent composed so state (e.g. search results) survives navigation
+ RootContent(
+ columnType = column.type,
+ relayManager = relayManager,
+ localCache = localCache,
+ accountManager = accountManager,
+ account = account,
+ nwcConnection = nwcConnection,
+ subscriptionsCoordinator = subscriptionsCoordinator,
+ appScope = appScope,
+ onShowComposeDialog = onShowComposeDialog,
+ onShowReplyDialog = onShowReplyDialog,
+ onZapFeedback = onZapFeedback,
+ onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
+ onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
+ )
if (currentOverlay != null) {
- OverlayContent(
- screen = currentOverlay,
- relayManager = relayManager,
- localCache = localCache,
- account = account,
- nwcConnection = nwcConnection,
- subscriptionsCoordinator = subscriptionsCoordinator,
- onShowComposeDialog = onShowComposeDialog,
- onShowReplyDialog = onShowReplyDialog,
- onZapFeedback = onZapFeedback,
- onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
- onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
- onBack = { navState.pop() },
- )
- } else {
- RootContent(
- columnType = column.type,
- relayManager = relayManager,
- localCache = localCache,
- accountManager = accountManager,
- account = account,
- nwcConnection = nwcConnection,
- subscriptionsCoordinator = subscriptionsCoordinator,
- appScope = appScope,
- onShowComposeDialog = onShowComposeDialog,
- onShowReplyDialog = onShowReplyDialog,
- onZapFeedback = onZapFeedback,
- onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
- onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
- )
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ OverlayContent(
+ screen = currentOverlay,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ subscriptionsCoordinator = subscriptionsCoordinator,
+ onShowComposeDialog = onShowComposeDialog,
+ onShowReplyDialog = onShowReplyDialog,
+ onZapFeedback = onZapFeedback,
+ onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
+ onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
+ onBack = { navState.pop() },
+ )
+ }
}
}
}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt
index e58734588c..a00dc029a8 100644
--- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt
@@ -20,20 +20,16 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Email
@@ -43,12 +39,11 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
@@ -57,7 +52,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
@@ -156,90 +150,47 @@ fun SinglePaneLayout(
VerticalDivider()
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
- // Show header with back button when navigated into overlay
- if (navStack.isNotEmpty()) {
- SinglePaneHeader(
- title =
- when (currentOverlay) {
- is DesktopScreen.UserProfile -> "Profile"
- is DesktopScreen.Thread -> "Thread"
- else -> currentColumnType.title()
- },
- onBack = { navState.pop() },
- )
- HorizontalDivider()
- }
-
Box(
modifier = Modifier.fillMaxSize().padding(12.dp),
) {
+ // Always keep RootContent composed so state (e.g. search results) survives navigation
+ RootContent(
+ columnType = currentColumnType,
+ relayManager = relayManager,
+ localCache = localCache,
+ accountManager = accountManager,
+ account = account,
+ nwcConnection = nwcConnection,
+ subscriptionsCoordinator = subscriptionsCoordinator,
+ appScope = appScope,
+ onShowComposeDialog = onShowComposeDialog,
+ onShowReplyDialog = onShowReplyDialog,
+ onZapFeedback = onZapFeedback,
+ onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
+ onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
+ )
if (currentOverlay != null) {
- OverlayContent(
- screen = currentOverlay,
- relayManager = relayManager,
- localCache = localCache,
- account = account,
- nwcConnection = nwcConnection,
- subscriptionsCoordinator = subscriptionsCoordinator,
- onShowComposeDialog = onShowComposeDialog,
- onShowReplyDialog = onShowReplyDialog,
- onZapFeedback = onZapFeedback,
- onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
- onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
- onBack = { navState.pop() },
- )
- } else {
- RootContent(
- columnType = currentColumnType,
- relayManager = relayManager,
- localCache = localCache,
- accountManager = accountManager,
- account = account,
- nwcConnection = nwcConnection,
- subscriptionsCoordinator = subscriptionsCoordinator,
- appScope = appScope,
- onShowComposeDialog = onShowComposeDialog,
- onShowReplyDialog = onShowReplyDialog,
- onZapFeedback = onZapFeedback,
- onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
- onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
- )
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ OverlayContent(
+ screen = currentOverlay,
+ relayManager = relayManager,
+ localCache = localCache,
+ account = account,
+ nwcConnection = nwcConnection,
+ subscriptionsCoordinator = subscriptionsCoordinator,
+ onShowComposeDialog = onShowComposeDialog,
+ onShowReplyDialog = onShowReplyDialog,
+ onZapFeedback = onZapFeedback,
+ onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) },
+ onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) },
+ onBack = { navState.pop() },
+ )
+ }
}
}
}
}
}
-
-@Composable
-private fun SinglePaneHeader(
- title: String,
- onBack: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Row(
- modifier =
- modifier
- .fillMaxWidth()
- .height(40.dp)
- .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
- .padding(horizontal = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) {
- Icon(
- Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = "Back",
- modifier = Modifier.size(16.dp),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Spacer(Modifier.width(8.dp))
- Text(
- text = title,
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
-}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt
new file mode 100644
index 0000000000..4b18918c42
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt
@@ -0,0 +1,429 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.ui.search
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.search.ContentPreset
+import com.vitorpamplona.amethyst.commons.search.DateUtils
+import com.vitorpamplona.amethyst.commons.search.KindRegistry
+import com.vitorpamplona.amethyst.commons.search.QueryParser
+import com.vitorpamplona.amethyst.commons.search.SearchQuery
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun AdvancedSearchPanel(
+ query: SearchQuery,
+ onKindsChanged: (List) -> Unit,
+ onPseudoKindsChanged: (List) -> Unit,
+ onAuthorAdded: (String) -> Unit,
+ onAuthorRemoved: (String) -> Unit,
+ onDateRangeChanged: (Long?, Long?) -> Unit,
+ onHashtagAdded: (String) -> Unit,
+ onHashtagRemoved: (String) -> Unit,
+ onExcludeAdded: (String) -> Unit,
+ onExcludeRemoved: (String) -> Unit,
+ onLanguageChanged: (String?) -> Unit,
+ onClear: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ // Content type presets
+ Text(
+ "Content Type",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ KindRegistry.presets.forEach { (name, preset) ->
+ FilterChip(
+ selected = preset.isSelected(query.kinds.toList(), query.pseudoKinds.toList()),
+ onClick = { togglePreset(preset, query, onKindsChanged, onPseudoKindsChanged) },
+ label = { Text(name) },
+ )
+ }
+ }
+
+ // Author field
+ AuthorInputField(
+ authors = query.authors.toList() + query.authorNames.toList(),
+ onAuthorAdded = onAuthorAdded,
+ onAuthorRemoved = onAuthorRemoved,
+ )
+
+ // Date range
+ DateRangeFields(
+ since = query.since,
+ until = query.until,
+ onChanged = onDateRangeChanged,
+ )
+
+ // Hashtags
+ ChipGroupWithInput(
+ label = "Tags",
+ items = query.hashtags.toList(),
+ prefix = "#",
+ placeholder = "Add tag...",
+ onAdd = onHashtagAdded,
+ onRemove = onHashtagRemoved,
+ )
+
+ // Exclude terms
+ ChipGroupWithInput(
+ label = "Exclude",
+ items = query.excludeTerms.toList(),
+ prefix = "-",
+ placeholder = "Exclude term...",
+ onAdd = onExcludeAdded,
+ onRemove = onExcludeRemoved,
+ )
+
+ // Language
+ LanguageSelector(
+ selected = query.language,
+ onChanged = onLanguageChanged,
+ )
+
+ // Clear button
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ OutlinedButton(onClick = onClear) {
+ Text("Clear All")
+ }
+ }
+ }
+ }
+}
+
+private fun togglePreset(
+ preset: ContentPreset,
+ query: SearchQuery,
+ onKindsChanged: (List) -> Unit,
+ onPseudoKindsChanged: (List) -> Unit,
+) {
+ val pseudo = preset.pseudoKind
+ if (pseudo != null) {
+ val current = query.pseudoKinds.toList()
+ if (pseudo in current) {
+ onPseudoKindsChanged(current - pseudo)
+ } else {
+ onPseudoKindsChanged(current + pseudo)
+ }
+ } else {
+ val current = query.kinds.toList()
+ if (current.containsAll(preset.kinds)) {
+ onKindsChanged(current - preset.kinds.toSet())
+ } else {
+ onKindsChanged((current + preset.kinds).distinct())
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun AuthorInputField(
+ authors: List,
+ onAuthorAdded: (String) -> Unit,
+ onAuthorRemoved: (String) -> Unit,
+) {
+ Column {
+ Text(
+ "Author",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (authors.isNotEmpty()) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ authors.forEach { author ->
+ AssistChip(
+ onClick = { onAuthorRemoved(author) },
+ label = {
+ Text(
+ if (author.length > 16) author.take(8) + "..." + author.takeLast(4) else author,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ },
+ trailingIcon = {
+ Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
+ },
+ )
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+ }
+ var authorInput by remember { mutableStateOf("") }
+ OutlinedTextField(
+ value = authorInput,
+ onValueChange = { authorInput = it },
+ modifier =
+ Modifier.fillMaxWidth().onKeyEvent {
+ if (it.key == Key.Enter && authorInput.isNotBlank()) {
+ onAuthorAdded(authorInput.trim())
+ authorInput = ""
+ true
+ } else {
+ false
+ }
+ },
+ placeholder = { Text("npub or name...") },
+ singleLine = true,
+ trailingIcon = {
+ if (authorInput.isNotBlank()) {
+ IconButton(onClick = {
+ onAuthorAdded(authorInput.trim())
+ authorInput = ""
+ }) {
+ Icon(Icons.Default.Add, contentDescription = "Add author")
+ }
+ }
+ },
+ )
+ }
+}
+
+@Composable
+private fun DateRangeFields(
+ since: Long?,
+ until: Long?,
+ onChanged: (Long?, Long?) -> Unit,
+) {
+ // Local text is source of truth while typing.
+ // Only propagate valid timestamps (or null when cleared).
+ // Only sync from external when the timestamp changes to something we didn't produce.
+ var sinceText by remember { mutableStateOf(since?.let { DateUtils.timestampToDate(it) } ?: "") }
+ var lastSince by remember { mutableStateOf(since) }
+ if (since != lastSince) {
+ sinceText = since?.let { DateUtils.timestampToDate(it) } ?: ""
+ lastSince = since
+ }
+
+ var untilText by remember { mutableStateOf(until?.let { DateUtils.timestampToDate(it) } ?: "") }
+ var lastUntil by remember { mutableStateOf(until) }
+ if (until != lastUntil) {
+ untilText = until?.let { DateUtils.timestampToDate(it) } ?: ""
+ lastUntil = until
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ "Since",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = sinceText,
+ onValueChange = {
+ sinceText = it
+ val ts = QueryParser.parseDateToTimestamp(it)
+ if (ts != null || it.isBlank()) {
+ lastSince = ts
+ onChanged(ts, until)
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("YYYY-MM-DD") },
+ singleLine = true,
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ "Until",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = untilText,
+ onValueChange = {
+ untilText = it
+ val ts = QueryParser.parseDateToTimestamp(it)
+ if (ts != null || it.isBlank()) {
+ lastUntil = ts
+ onChanged(since, ts)
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("YYYY-MM-DD") },
+ singleLine = true,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun ChipGroupWithInput(
+ label: String,
+ items: List,
+ prefix: String,
+ placeholder: String,
+ onAdd: (String) -> Unit,
+ onRemove: (String) -> Unit,
+) {
+ Column {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items.forEach { item ->
+ AssistChip(
+ onClick = { onRemove(item) },
+ label = { Text("$prefix$item", style = MaterialTheme.typography.bodySmall) },
+ trailingIcon = {
+ Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
+ },
+ )
+ }
+ }
+ if (items.isNotEmpty()) Spacer(Modifier.height(4.dp))
+ var inputText by remember { mutableStateOf("") }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedTextField(
+ value = inputText,
+ onValueChange = { inputText = it },
+ modifier =
+ Modifier.weight(1f).onKeyEvent {
+ if (it.key == Key.Enter && inputText.isNotBlank()) {
+ onAdd(inputText.trim())
+ inputText = ""
+ true
+ } else {
+ false
+ }
+ },
+ placeholder = { Text(placeholder) },
+ singleLine = true,
+ )
+ TextButton(
+ onClick = {
+ if (inputText.isNotBlank()) {
+ onAdd(inputText.trim())
+ inputText = ""
+ }
+ },
+ ) {
+ Text("Add")
+ }
+ }
+ }
+}
+
+@Composable
+private fun LanguageSelector(
+ selected: String?,
+ onChanged: (String?) -> Unit,
+) {
+ val languages =
+ listOf(
+ null to "Any",
+ "en" to "English",
+ "es" to "Spanish",
+ "pt" to "Portuguese",
+ "ja" to "Japanese",
+ "zh" to "Chinese",
+ "de" to "German",
+ "fr" to "French",
+ )
+
+ Column {
+ Text(
+ "Language",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ languages.forEach { (code, name) ->
+ FilterChip(
+ selected = selected == code,
+ onClick = { onChanged(code) },
+ label = { Text(name) },
+ )
+ }
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt
new file mode 100644
index 0000000000..bb43400f96
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt
@@ -0,0 +1,379 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.ui.search
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Article
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Forum
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
+import com.vitorpamplona.amethyst.commons.search.KindRegistry
+import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
+import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
+import com.vitorpamplona.amethyst.commons.util.toTimeAgo
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
+
+@Composable
+fun SearchResultsList(
+ state: AdvancedSearchBarState,
+ onNavigateToProfile: (String) -> Unit,
+ onNavigateToThread: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ listState: LazyListState = rememberLazyListState(),
+) {
+ val people by state.sortedPeopleResults.collectAsState()
+ val notes by state.sortedNoteResults.collectAsState()
+ val eventSortOrder by state.eventSortOrder.collectAsState()
+ val peopleSortOrder by state.peopleSortOrder.collectAsState()
+
+ val hasResults = people.isNotEmpty() || notes.isNotEmpty()
+
+ if (!hasResults) return
+
+ // Group notes by kind
+ val textNotes = notes.filter { it.kind == 1 }
+ val articles = notes.filter { it.kind == LongTextNoteEvent.KIND }
+ val otherNotes = notes.filter { it.kind != 1 && it.kind != LongTextNoteEvent.KIND }
+
+ // Per-section collapsed state (absent = expanded)
+ val collapsedSections = remember { mutableStateMapOf() }
+
+ LazyColumn(
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier,
+ ) {
+ // People section
+ if (people.isNotEmpty()) {
+ val collapsed = collapsedSections["people"] == true
+ stickyHeader(key = "header-people") {
+ SortableHeader(
+ title = "People",
+ count = people.size,
+ icon = Icons.Default.Person,
+ options = SearchSortOrder.PEOPLE_OPTIONS,
+ selected = peopleSortOrder,
+ onSelect = { state.updatePeopleSortOrder(it) },
+ collapsed = collapsed,
+ onToggleCollapse = { collapsedSections["people"] = !collapsed },
+ )
+ }
+ if (!collapsed) {
+ val displayPeople = people.take(5)
+ items(displayPeople, key = { "person-${it.pubkeyHex}" }) { user ->
+ UserSearchCard(
+ user = user,
+ onClick = { onNavigateToProfile(user.pubkeyHex) },
+ )
+ }
+ if (people.size > 5) {
+ item(key = "people-expand") {
+ ExpandableSection(
+ remaining = people.drop(5),
+ ) { user ->
+ UserSearchCard(
+ user = user,
+ onClick = { onNavigateToProfile(user.pubkeyHex) },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // Notes section
+ if (textNotes.isNotEmpty()) {
+ if (people.isNotEmpty()) {
+ item(key = "divider-notes") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
+ }
+ val collapsed = collapsedSections["notes"] == true
+ stickyHeader(key = "header-notes") {
+ SortableHeader(
+ title = "Notes",
+ count = textNotes.size,
+ icon = Icons.Default.Description,
+ options = SearchSortOrder.EVENT_OPTIONS,
+ selected = eventSortOrder,
+ onSelect = { state.updateEventSortOrder(it) },
+ collapsed = collapsed,
+ onToggleCollapse = { collapsedSections["notes"] = !collapsed },
+ )
+ }
+ if (!collapsed) {
+ val displayNotes = textNotes.take(5)
+ items(displayNotes, key = { "note-${it.id}" }) { event ->
+ NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
+ }
+ if (textNotes.size > 5) {
+ item(key = "notes-expand") {
+ ExpandableSection(
+ remaining = textNotes.drop(5),
+ ) { event ->
+ NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
+ }
+ }
+ }
+ }
+ }
+
+ // Articles section
+ if (articles.isNotEmpty()) {
+ if (people.isNotEmpty() || textNotes.isNotEmpty()) {
+ item(key = "divider-articles") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
+ }
+ val collapsed = collapsedSections["articles"] == true
+ stickyHeader(key = "header-articles") {
+ SortableHeader(
+ title = "Articles",
+ count = articles.size,
+ icon = Icons.Default.Article,
+ options = SearchSortOrder.EVENT_OPTIONS,
+ selected = eventSortOrder,
+ onSelect = { state.updateEventSortOrder(it) },
+ collapsed = collapsed,
+ onToggleCollapse = { collapsedSections["articles"] = !collapsed },
+ )
+ }
+ if (!collapsed) {
+ items(articles.take(5), key = { "article-${it.id}" }) { event ->
+ NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
+ }
+ if (articles.size > 5) {
+ item(key = "articles-expand") {
+ ExpandableSection(
+ remaining = articles.drop(5),
+ ) { event ->
+ NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
+ }
+ }
+ }
+ }
+ }
+
+ // Other section
+ if (otherNotes.isNotEmpty()) {
+ item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) }
+ val collapsed = collapsedSections["other"] == true
+ stickyHeader(key = "header-other") {
+ SortableHeader(
+ title = "Other",
+ count = otherNotes.size,
+ icon = Icons.Default.Forum,
+ options = SearchSortOrder.EVENT_OPTIONS,
+ selected = eventSortOrder,
+ onSelect = { state.updateEventSortOrder(it) },
+ collapsed = collapsed,
+ onToggleCollapse = { collapsedSections["other"] = !collapsed },
+ )
+ }
+ if (!collapsed) {
+ items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
+ NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) })
+ }
+ }
+ }
+
+ // Bottom padding
+ item(key = "bottom-spacer") { Spacer(Modifier.height(16.dp)) }
+ }
+}
+
+@Composable
+private fun SortableHeader(
+ title: String,
+ count: Int,
+ icon: ImageVector,
+ options: List,
+ selected: SearchSortOrder,
+ onSelect: (SearchSortOrder) -> Unit,
+ collapsed: Boolean = false,
+ onToggleCollapse: () -> Unit = {},
+) {
+ val chevronRotation by animateFloatAsState(if (collapsed) -90f else 0f)
+
+ Surface(
+ color = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clickable(onClick = onToggleCollapse).padding(vertical = 4.dp),
+ ) {
+ Icon(
+ Icons.Default.ExpandMore,
+ contentDescription = if (collapsed) "Expand $title" else "Collapse $title",
+ modifier = Modifier.size(18.dp).rotate(chevronRotation),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.padding(start = 4.dp).size(18.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ "$title ($count)",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ Spacer(Modifier.weight(1f))
+ if (!collapsed) {
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ options.forEach { option ->
+ FilterChip(
+ selected = option == selected,
+ onClick = { onSelect(option) },
+ label = {
+ Text(
+ option.label,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ },
+ colors =
+ FilterChipDefaults.filterChipColors(
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ modifier = Modifier.height(28.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NotePreviewCard(
+ event: Event,
+ onClick: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
+ colors =
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ ),
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Kind badge
+ val kindName = KindRegistry.nameFor(event.kind) ?: "kind ${event.kind}"
+ Text(
+ kindName,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ // Author (hex truncated)
+ Text(
+ event.pubKey.take(8) + "..." + event.pubKey.takeLast(4),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.weight(1f))
+ // Timestamp
+ Text(
+ event.createdAt.toTimeAgo(withDot = false),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ // Content preview
+ Text(
+ event.content.take(200),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ExpandableSection(
+ remaining: List,
+ content: @Composable (T) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ if (expanded) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ remaining.forEach { item ->
+ content(item)
+ }
+ }
+ } else {
+ TextButton(
+ onClick = { expanded = true },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(Icons.Default.ExpandMore, contentDescription = null, modifier = Modifier.size(16.dp))
+ Text("Show all ${remaining.size} more")
+ }
+ }
+}
diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt
new file mode 100644
index 0000000000..348dcaf0fe
--- /dev/null
+++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.desktop.ui.search
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.HourglassEmpty
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.chess.RelaySyncState
+import com.vitorpamplona.amethyst.commons.chess.RelaySyncStatus
+import kotlinx.collections.immutable.ImmutableList
+
+@Composable
+fun SearchSyncBanner(
+ relayStates: ImmutableList,
+ isSearching: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val isVisible = isSearching || relayStates.isNotEmpty()
+ var isExpanded by remember { mutableStateOf(false) }
+
+ AnimatedVisibility(
+ visible = isVisible,
+ enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
+ exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
+ modifier = modifier,
+ ) {
+ Column {
+ // Collapsed summary row
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable { isExpanded = !isExpanded }
+ .padding(horizontal = 4.dp, vertical = 6.dp),
+ ) {
+ val responded = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED }
+ val total = relayStates.size
+ val totalEvents = relayStates.sumOf { it.eventsReceived }
+
+ Text(
+ text =
+ if (total > 0) {
+ "$responded/$total relays responded \u00B7 $totalEvents events"
+ } else {
+ "Connecting to relays..."
+ },
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+
+ Icon(
+ imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = if (isExpanded) "Collapse" else "Expand",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+
+ // Expanded per-relay details
+ AnimatedVisibility(
+ visible = isExpanded && relayStates.isNotEmpty(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ Column {
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant,
+ thickness = 0.5.dp,
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
+ ) {
+ relayStates.forEach { relay ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(
+ imageVector = relayStatusIcon(relay.status),
+ contentDescription = null,
+ tint = relayStatusColor(relay.status),
+ modifier = Modifier.size(14.dp),
+ )
+ Text(
+ text = relay.displayName,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = "${relay.eventsReceived} events",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun relayStatusIcon(status: RelaySyncStatus): ImageVector =
+ when (status) {
+ RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty
+ RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty
+ RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload
+ RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle
+ RelaySyncStatus.FAILED -> Icons.Default.Error
+ }
+
+@Composable
+private fun relayStatusColor(status: RelaySyncStatus): Color =
+ when (status) {
+ RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary
+ RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary
+ RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary
+ RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary
+ RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error
+ }
diff --git a/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md b/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md
new file mode 100644
index 0000000000..609a0fdfb6
--- /dev/null
+++ b/docs/brainstorms/2026-03-10-advanced-search-brainstorm.md
@@ -0,0 +1,230 @@
+# Brainstorm: Advanced Search for Desktop
+
+**Date:** 2026-03-10
+**Status:** Draft
+**Branch:** TBD (`feat/desktop-advanced-search`)
+
+## What We're Building
+
+Full-featured advanced search for Amethyst Desktop with:
+1. **Twitter-style query operators** (`from:`, `kind:`, `since:`, etc.) that map to NIP-50 Filter fields
+2. **Form-based UI** (expandable panel below search bar) for users who don't want to learn syntax
+3. **Bidirectional sync** between text operators and form controls — editing one updates the other
+4. **Extensible kind presets** — toggle groups like Notes, Articles, Media, Communities
+5. **Future AI bridge** (follow-up) — natural language → structured query conversion
+
+**Philosophy:** Relay-first, pragmatic. Desktop users have resources; we prioritize completeness over privacy. Privacy controls available but not default friction.
+
+## Why This Approach
+
+- **No standard Nostr query language exists** — we define one that maps cleanly to `Filter` fields
+- **Dual interface (text + form)** — power users get speed, casual users get discoverability
+- **Current desktop search is minimal** — only kind 0 + 1, no operators, no kind filtering
+- **Android has 30+ kinds** but no query language — desktop leapfrogs with both
+- **AI deferred** — core query system must work standalone first; AI is a parsing layer on top
+
+## How Other Clients Do Search
+
+| Client | Approach | Operators | Notable |
+|--------|----------|-----------|---------|
+| **Amethyst Android** | Local cache + NIP-50, 30+ kinds | None (plain text) | Search relay list (kind 10007) |
+| **Primal** | Proprietary caching server | Form UI only | Event type, time range, scope dropdowns |
+| **Coracle** | NIP-50 + configurable relays | None | Also supports DVM requests (NIP-90) |
+| **Damus** | NIP-50 relay search | None | User/profile focused |
+| **noStrudel** | Local relay + NIP-50 | None | IndexedDB local indexing |
+| **Gossip** | NIP-50 relay search | None | Desktop Rust client |
+| **Noogle.lol** | NIP-90 DVM search | `from:npub` `from:me` | Pay-per-query via Lightning |
+
+**Key insight:** No client ships a query operator language. This is greenfield.
+
+## Proposed Query Schema
+
+Operators map directly to `Filter` fields and NIP-50 extensions:
+
+### Core Operators
+
+| Operator | Maps To | Example | Notes |
+|----------|---------|---------|-------|
+| `from:` | `Filter.authors` | `from:npub1abc...` | Resolve names via NIP-05/local cache |
+| `kind:` | `Filter.kinds` | `kind:note` or `kind:1` | Named aliases for common kinds |
+| `since:` | `Filter.since` | `since:2025-01-01` | ISO 8601 date parsing |
+| `until:` | `Filter.until` | `until:2025-06-30` | ISO 8601 date parsing |
+| `#` | `Filter.tags["t"]` | `#bitcoin` | Hashtag filter |
+| `"exact phrase"` | Quoted in `Filter.search` | `"lightning network"` | Relay-dependent support |
+| `-` | Client-side exclusion | `-spam` | Post-filter after relay results |
+
+### NIP-50 Extension Operators
+
+| Operator | NIP-50 Extension | Example |
+|----------|-----------------|---------|
+| `lang:` | `language:xx` | `lang:en` |
+| `domain:` | `domain:xx` | `domain:nostr.com` |
+| `nsfw:` | `nsfw:xx` | `nsfw:false` |
+
+### Kind Name Aliases
+
+| Alias | Kind(s) | Description |
+|-------|---------|-------------|
+| `note` | 1 | Short text note |
+| `article` | 30023 | Long-form content |
+| `repost` | 6 | Reposts |
+| `reply` | 1 (with `e` tag) | Replies (client-side filter) |
+| `media` | 1 (with `imeta` tag) | Notes with media |
+| `channel` | 40, 41, 42 | Public channels |
+| `live` | 30311 | Live activities |
+| `community` | 34550 | Communities |
+| `wiki` | 30818 | Wiki pages |
+| `video` | 34235 | Video events |
+| `classified` | 30402 | Classifieds |
+| `profile` | 0 | Metadata/profiles |
+
+### Boolean Logic
+
+| Syntax | Behavior |
+|--------|----------|
+| `bitcoin lightning` | AND (default) |
+| `bitcoin OR lightning` | OR — requires multiple relay queries |
+| `-spam` | NOT — client-side exclusion |
+
+## UX Design
+
+### Search Bar (Default State)
+```
+[ Search notes, people, tags... ] [Advanced v]
+```
+
+### Expanded Advanced Panel
+```
+[ from:npub1abc kind:note since:2025-01 bitcoin ] [Advanced ^]
++------------------------------------------------------------------+
+| Content Type: [x] Notes [ ] Articles [ ] Media [ ] All |
+| Author: [ npub or name... ] [+ Add] |
+| Date Range: [ 2025-01-01 ] to [ today ] |
+| Language: [ Any v ] |
+| Hashtags: [ #bitcoin ] [+ Add] |
+| Exclude: [ spam, nsfw... ] [+ Add] |
+| |
+| [Clear Filters] [Search] |
++------------------------------------------------------------------+
+```
+
+### Bidirectional Sync
+- Typing `from:npub1abc` in search bar → Author field populates in panel
+- Selecting "Articles" checkbox → `kind:article` appears in search bar
+- Editing either side updates the other in real-time
+- Form is the "visual representation" of the query string
+
+### Result Display
+- Results grouped by type: People, Notes, Articles, Channels
+- Each result shows: content preview, author, timestamp, kind badge
+- Infinite scroll with "Load more from relays" button
+- Sort: Relevance (default, relay-determined) or Chronological
+
+## Architecture
+
+### Query Pipeline
+
+```
+User Input (text or form)
+ |
+ v
+QueryParser (commons/commonMain)
+ |-- Tokenize operators: from:, kind:, since:, etc.
+ |-- Resolve names → hex pubkeys (local cache + NIP-05)
+ |-- Parse dates → unix timestamps
+ |-- Map kind aliases → kind numbers
+ |
+ v
+SearchQuery (data class in commons)
+ |-- text: String (free text for NIP-50 search field)
+ |-- authors: List
+ |-- kinds: List
+ |-- since: Long?
+ |-- until: Long?
+ |-- tags: Map>
+ |-- excludeTerms: List
+ |-- language: String?
+ |-- nip50Extensions: Map
+ |
+ v
+FilterBuilder (desktop)
+ |-- Convert SearchQuery → List
+ |-- Split by kind groups (like Android: 3 filters, ~10 kinds each)
+ |-- Inject NIP-50 extensions into search string
+ |
+ v
+Relay Subscription
+ |-- Use search relay list (kind 10007) or connected relays
+ |-- Send REQ with filters
+ |-- Aggregate results
+ |
+ v
+Client-side Post-filter
+ |-- Apply exclusions (-term)
+ |-- Apply "reply" detection (has e tag)
+ |-- Apply "media" detection (has imeta tag)
+ |
+ v
+Results Display
+```
+
+### Module Placement
+
+| Component | Module | Rationale |
+|-----------|--------|-----------|
+| `QueryParser` | `commons/commonMain` | Reusable for Android later |
+| `SearchQuery` | `commons/commonMain` | Shared data model |
+| `QuerySerializer` | `commons/commonMain` | SearchQuery ↔ string conversion |
+| `AdvancedSearchPanel` | `desktopApp` | Desktop-specific UI |
+| `SearchScreen` (updated) | `desktopApp` | Desktop layout |
+| `FilterBuilder` (updated) | `desktopApp` | Desktop filter assembly |
+| Kind alias registry | `commons/commonMain` | Shared kind name mapping |
+
+### Search Relay Management
+
+- Use existing `SearchRelayListEvent` (kind 10007) from quartz
+- Desktop UI to configure search relays (settings page)
+- Default fallback: `relay.nostr.band`, `nostr.wine`, `relay.damus.io`
+- Future: auto-discover NIP-50 capable relays via NIP-11
+
+## Privacy Considerations
+
+| Concern | Mitigation | Default |
+|---------|-----------|---------|
+| Relay sees search queries | User-configurable search relay list | On (kind 10007) |
+| Relay sees IP + query | VPN/Tor support (system-level) | Not enforced |
+| Query history stored | No server-side history; client-side optional | Off |
+| NIP-05 resolution leaks interest | Cache NIP-05 lookups locally | On |
+| Author search reveals social graph | Already visible via follow lists | N/A |
+
+**Stance:** Relay-first, pragmatic. Desktop users accept relay visibility for better results. Advanced users can configure search relays or use Tor.
+
+## Key Decisions
+
+1. **Query language is NOT a Nostr standard** — it's a client-side UX convention that maps to `Filter` fields
+2. **Bidirectional sync** between text bar and form panel — single source of truth (`SearchQuery` data class)
+3. **Kind presets with extensibility** — start with core groups, users can toggle individual kinds
+4. **Relay-first execution** — NIP-50 search as primary, local cache as supplement
+5. **AI deferred** — follow-up feature, will parse natural language → `SearchQuery`
+6. **Query parser in commons** — shared module so Android can adopt later
+7. **Client-side post-filtering** for operators relays can't handle (exclusions, reply detection, media detection)
+
+## Resolved Questions
+
+1. **Name resolution** — Async + refine. Search immediately with text, resolve `from:name` in background, refine results when pubkey resolved. No blocking.
+2. **OR queries** — Yes in v1. `bitcoin OR lightning` sends parallel relay subscriptions, merges results.
+3. **Search history** — Recent history (last 20) stored locally.
+4. **Saved searches** — Yes in v1. Pin/save queries. Future: persist as Nostr events.
+5. **Result caching** — Session cache only (in-memory). Cleared on app restart. No disk persistence.
+
+## Open Questions
+
+None — all resolved.
+
+## Follow-up Features (Out of Scope)
+
+- AI natural language → query parsing (local Ollama / cloud API / NIP-90 DVM)
+- Local SQLite FTS5 index for offline search
+- NIP-90 DVM search integration
+- Search analytics / trending topics
+- Collaborative search (shared saved searches via Nostr events)
diff --git a/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md b/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md
new file mode 100644
index 0000000000..244213ebac
--- /dev/null
+++ b/docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md
@@ -0,0 +1,962 @@
+---
+title: "feat: Desktop Advanced Search with Query Operators and Form UI"
+type: feat
+status: active
+date: 2026-03-10
+deepened: 2026-03-10
+origin: docs/brainstorms/2026-03-10-advanced-search-brainstorm.md
+---
+
+# Desktop Advanced Search
+
+## Enhancement Summary
+
+**Deepened on:** 2026-03-10
+**Agents used:** kotlin-expert, compose-expert, kotlin-coroutines, nostr-expert, desktop-expert, kmp-expert, best-practices-researcher, architecture-strategist, performance-oracle, code-simplicity-reviewer, security-sentinel
+
+### Key Improvements
+1. **Bidirectional sync loop prevention** — `sourceOfChange` discriminator (TEXT/FORM/INIT) breaks parse→serialize→parse cycles
+2. **Performance** — batch result accumulation via `channelFlow` + 100ms windows, cap OR to 3 terms (not 5), `@Immutable` SearchQuery
+3. **Parser architecture** — hand-written recursive descent tokenizer + parser, error recovery via literal text degradation
+4. **Module corrections** — SearchFilterFactory stays in desktopApp (needs SubscriptionConfig); SearchResultFilter and SearchHistoryStore can move to commons
+5. **Compose patterns** — `FilterChip` for kind presets, `expandVertically(Alignment.Top)` + `fadeIn`, sticky section headers, shimmer loading
+6. **Coroutine patterns** — `flatMapLatest` for auto-canceling old subscriptions, `merge()` for OR queries, `supervisorScope` for relay isolation
+7. **Simplicity guidance** — MVP can cut OR queries, lang:/domain:, saved searches to ~350 LOC / 5 files. Full plan phases appropriately.
+
+### New Considerations Discovered
+- NIP-50 extensions go inline in search string (`"bitcoin language:en"`), not as separate filter fields
+- Pseudo-kinds (reply, media) need separate handling from real kinds — they're client-side post-filters, not relay filters
+- `TextFieldValue` (not raw String) needed for cursor position stability during bidirectional sync
+- Use `query.hashtags` → `Filter.tags["t"]` (more reliable than putting hashtags in search string)
+- OR cap: 3 terms max (not 5) — 5 terms × 3 groups × 3 relays = 45 subs is too many
+
+---
+
+## Overview
+
+Full-featured search for Amethyst Desktop: Twitter-style query operators (`from:`, `kind:`, `since:`, etc.), expandable form panel below search bar, bidirectional sync between text and form, extensible kind presets, OR queries, search history + saved searches. Relay-first via NIP-50.
+
+Current desktop search only handles kind 0 (people) + kind 1 (notes) with plain text. Android searches 30+ kinds. This closes that gap and adds capabilities neither platform has.
+
+## Problem Statement
+
+Desktop search (`SearchScreen.kt`) is minimal:
+- Only `searchPeople()` (kind 0) wired to relay subscription
+- `searchNotes()` exists in `FeedSubscription.kt` but not connected
+- No kind filtering, no author filtering, no date ranges
+- No query language — users can only type plain text or bech32 identifiers
+- `SearchBarState` in commons only returns `User` results, no notes/channels
+
+Users can't find content they've seen, discover new content by topic, or filter by author/type/date.
+
+## Proposed Solution
+
+### Query Operator Language
+
+Client-side query language that maps to `Filter` fields. Not a Nostr standard — a UX convention.
+
+```
+from:npub1abc kind:note since:2025-01-01 bitcoin OR lightning -spam #nostr
+```
+
+| Operator | Maps To | Relay-side? |
+|----------|---------|-------------|
+| `from:` | `Filter.authors` | Yes |
+| `kind:` | `Filter.kinds` | Yes |
+| `since:` | `Filter.since` | Yes |
+| `until:` | `Filter.until` | Yes |
+| `#` | `Filter.tags["t"]` | Yes |
+| `"exact phrase"` | Quoted in `Filter.search` | Yes (relay-dependent) |
+| `lang:` | NIP-50 extension in search string | Relay-dependent |
+| `domain:` | NIP-50 extension in search string | Relay-dependent |
+| `-` | Client-side exclusion post-filter | No |
+| `OR` | Parallel subscriptions, merged | Multiple queries |
+
+#### Research Insights: NIP-50 Protocol Details
+
+**NIP-50 extension placement:** Extensions go *inline in the search string*, not as separate filter fields. The relay parses them out:
+```json
+{"kinds": [1], "search": "bitcoin language:en domain:nostr.com"}
+```
+
+**Hashtag handling:** Use `tags = {"t": ["bitcoin"]}` in the filter (more reliable across relays) rather than putting `#bitcoin` in the search string. Hashtags in `Filter.tags` are protocol-level, not NIP-50 dependent.
+
+**Quoted phrase search:** Not standardized — relay-dependent. Some relays treat quotes literally, others ignore them. Degrade gracefully.
+
+**All filter fields AND together** within a single filter. OR requires separate subscriptions.
+
+### Dual UI: Text Bar + Expandable Form Panel
+
+```
+[ from:npub1abc kind:note bitcoin ] [Advanced v]
++----------------------------------------------------------+
+| Content: [x] Notes [ ] Articles [ ] Media [ ] All |
+| Author: [ npub or name... ] [+ Add] |
+| Since: [ 2025-01-01 ] Until: [ today ] |
+| Tags: [ #bitcoin ] [+ Add] |
+| Exclude: [ spam ] [+ Add] |
+| Language:[ Any v ] |
+| |
+| [Clear] [Search] |
++----------------------------------------------------------+
+```
+
+Bidirectional: typing `kind:article` checks "Articles"; checking "Notes" inserts `kind:note`.
+
+#### Research Insights: Bidirectional Sync
+
+**Critical: `sourceOfChange` discriminator.** Without this, parse→serialize→parse loops will occur. Track who initiated the change:
+
+```kotlin
+enum class ChangeSource { TEXT, FORM, INIT }
+
+fun updateFromText(rawText: String) {
+ _changeSource = ChangeSource.TEXT
+ _query.value = QueryParser.parse(rawText)
+}
+
+fun updateKinds(kinds: List) {
+ _changeSource = ChangeSource.FORM
+ _query.value = _query.value.copy(kinds = kinds)
+}
+
+// In the composable, only update text field when source != TEXT
+val displayText by remember {
+ state.query.map { query ->
+ if (state.changeSource != ChangeSource.TEXT) {
+ QuerySerializer.serialize(query)
+ } else {
+ // Keep user's raw text as-is
+ state.rawText
+ }
+ }
+}
+```
+
+**Use `TextFieldValue` (not raw String)** for the text bar to preserve cursor position during form-driven updates. When form changes update the serialized text, set `TextFieldValue(text = newText, selection = TextRange(newText.length))`.
+
+## Technical Approach
+
+### Architecture
+
+```
+Text Bar ──parse──> SearchQuery <──serialize── Form Panel
+ |
+ FilterBuilder
+ |
+ List (split by kind groups, ~10 kinds each)
+ |
+ Relay Subscriptions (NIP-50)
+ |
+ Client-side Post-filter (exclusions, reply/media detection)
+ |
+ Results Display (grouped: People, Notes, Articles, Channels)
+```
+
+**Single source of truth:** `MutableStateFlow`. Both text bar and form read from it. Text bar changes → `QueryParser` → `SearchQuery`. Form changes → mutate `SearchQuery` directly. `QuerySerializer` regenerates text string. `sourceOfChange` discriminator prevents update loops.
+
+**Debounce strategy:** Text input debounced 300ms (existing pattern). Form toggle changes trigger immediate search (no debounce).
+
+#### Research Insights: Kotlin State Patterns
+
+**`@Immutable` on SearchQuery** — enables Compose to skip recomposition when query hasn't changed:
+```kotlin
+@Immutable
+data class SearchQuery(
+ val text: String = "",
+ val authors: ImmutableList = persistentListOf(),
+ val kinds: ImmutableList = persistentListOf(),
+ // ...
+) {
+ companion object {
+ val EMPTY = SearchQuery()
+ }
+}
+```
+
+Use `kotlinx.collections.immutable` (`ImmutableList`, `ImmutableSet`, `persistentListOf()`) for all collection fields. This gives Compose structural stability guarantees.
+
+**Granular derived StateFlows** with `distinctUntilChanged()` to prevent unnecessary recomposition:
+```kotlin
+val kindsForUI: StateFlow> = _query
+ .map { it.kinds }
+ .distinctUntilChanged()
+ .stateIn(scope, SharingStarted.WhileSubscribed(5000), persistentListOf())
+```
+
+### Data Flow Detail
+
+```
+SearchQuery (SSOT)
+ │
+ ├─ text bar reads: QuerySerializer.serialize(query) → displayed string
+ │ └─ on text change: QueryParser.parse(rawText) → new SearchQuery
+ │ └─ GUARD: only serialize→display when changeSource != TEXT
+ │
+ ├─ form panel reads: query.kinds, query.authors, query.since, etc.
+ │ └─ on form change: query.copy(kinds = ...) → new SearchQuery
+ │
+ └─ relay layer reads: SearchFilterFactory.createFilters(query) → List
+ └─ subscription created per filter group
+ └─ OR queries: parallel subscriptions via merge(), results deduped by event ID
+```
+
+### Implementation Phases
+
+#### Phase 1: Query Engine (commons/commonMain) — Foundation
+
+Pure Kotlin, no UI, exhaustively unit-tested.
+
+**Step 1.1: `SearchQuery` data class**
+
+```kotlin
+// commons/src/commonMain/.../search/SearchQuery.kt
+@Immutable
+data class SearchQuery(
+ val text: String = "", // Free text for NIP-50 search field
+ val authors: ImmutableList = persistentListOf(), // Hex pubkeys
+ val authorNames: ImmutableList = persistentListOf(), // Unresolved names (for display)
+ val kinds: ImmutableList = persistentListOf(), // Empty = all searchable kinds
+ val since: Long? = null, // Unix timestamp
+ val until: Long? = null,
+ val hashtags: ImmutableList = persistentListOf(), // Without # prefix
+ val excludeTerms: ImmutableList = persistentListOf(), // Client-side exclusion
+ val language: String? = null, // ISO 639-1
+ val domain: String? = null, // NIP-05 domain
+ val orTerms: ImmutableList = persistentListOf(), // Terms joined by OR
+) {
+ val isEmpty get() = text.isBlank() && authors.isEmpty() && kinds.isEmpty()
+ && since == null && until == null && hashtags.isEmpty()
+ && orTerms.isEmpty()
+
+ companion object {
+ val EMPTY = SearchQuery()
+ }
+}
+```
+
+#### Research Insights: Pseudo-Kinds
+
+**Separate pseudo-kinds from real kinds.** `kind:reply` and `kind:media` are NOT relay filter kinds — they require client-side post-filtering:
+- `kind:reply` = kind 1 events WITH `e` tag
+- `kind:media` = kind 1 events WITH `imeta` tag or image URLs
+
+The `SearchQuery` should track these separately or the `KindRegistry` should flag them. Recommended: a `pseudoKinds: Set` field or handle in `SearchResultFilter`.
+
+**Step 1.2: Kind alias registry**
+
+```kotlin
+// commons/src/commonMain/.../search/KindRegistry.kt
+object KindRegistry {
+ // Import quartz KIND constants instead of hardcoding numbers
+ val aliases: Map> = mapOf(
+ "note" to listOf(1),
+ "article" to listOf(30023),
+ "repost" to listOf(6),
+ "profile" to listOf(0),
+ "channel" to listOf(40, 41, 42),
+ "live" to listOf(30311),
+ "community" to listOf(34550),
+ "wiki" to listOf(30818),
+ "video" to listOf(34235),
+ "classified" to listOf(30402),
+ "highlight" to listOf(9802),
+ "poll" to listOf(6969),
+ )
+
+ // Pseudo-kinds: client-side post-filters, not relay kinds
+ val pseudoKinds: Set = setOf("reply", "media")
+
+ val presets: Map> = mapOf(
+ "Notes" to listOf(1),
+ "Articles" to listOf(30023),
+ "Media" to listOf(1), // post-filtered for imeta tag
+ "Channels" to listOf(40, 41, 42),
+ "Communities" to listOf(34550),
+ "Wiki" to listOf(30818),
+ )
+
+ fun resolve(alias: String): List? = aliases[alias.lowercase()]
+ fun isPseudoKind(alias: String): Boolean = alias.lowercase() in pseudoKinds
+ fun nameFor(kind: Int): String? = aliases.entries.find { kind in it.value }?.key
+}
+```
+
+**Step 1.3: `QueryParser`**
+
+#### Research Insights: Parser Architecture
+
+**Hand-written recursive descent parser** (not regex, not parser generators). Two-phase:
+
+1. **Tokenizer** (state machine): Walks characters, emits tokens: `OperatorToken(name, value)`, `TextToken(value)`, `OrToken`, `QuotedToken(value)`, `NegationToken(value)`, `HashtagToken(value)`
+2. **Parser** (recursive descent): Consumes tokens, builds `SearchQuery`
+
+**Key principles:**
+- **Preserve raw text in tokens** for roundtrip fidelity (`serialize(parse(input))` ≈ `input`)
+- **Error recovery**: malformed operators degrade to literal text, never throw
+- **OR precedence**: OR binds to adjacent text terms only. Operators are always AND.
+ - `from:vitor bitcoin OR lightning kind:note` = `from:vitor AND kind:note AND (bitcoin OR lightning)`
+- **Performance**: sub-microsecond parsing, not a concern
+
+```kotlin
+// commons/src/commonMain/.../search/QueryParser.kt
+object QueryParser {
+ fun parse(input: String): SearchQuery {
+ val tokens = tokenize(input)
+ return buildQuery(tokens)
+ }
+
+ private fun tokenize(input: String): List { /* state machine */ }
+ private fun buildQuery(tokens: List): SearchQuery { /* recursive descent */ }
+}
+
+sealed interface Token {
+ data class Operator(val name: String, val value: String, val raw: String) : Token
+ data class Text(val value: String) : Token
+ data object Or : Token
+ data class Quoted(val value: String, val raw: String) : Token
+ data class Negation(val term: String) : Token
+ data class Hashtag(val tag: String) : Token
+}
+```
+
+Rules:
+- Case-insensitive operator matching (`FROM:` = `from:`)
+- `from:` → if bech32 npub, decode to hex and add to `authors`; else add to `authorNames` (async resolution)
+- `kind:` → resolve via `KindRegistry.resolve()` or parse as int. Flag pseudo-kinds separately.
+- `since:` / `until:` → parse ISO 8601 (`2025-01-01`, `2025-01`, `2025`) to unix timestamp
+- `#tag` → add to `hashtags`
+- `"quoted phrase"` → keep in `text` as quoted
+- `-term` → add to `excludeTerms`, strip from relay search string
+- `OR` → split adjacent free text terms. `bitcoin OR lightning` → `orTerms = ["bitcoin", "lightning"]`
+- Multiple `from:` → AND (multiple authors)
+- Multiple `kind:` → union (combined kinds)
+- Incomplete operators (`from:` with no value) → treat as literal text
+
+**Step 1.4: `QuerySerializer`**
+
+```kotlin
+// commons/src/commonMain/.../search/QuerySerializer.kt
+object QuerySerializer {
+ fun serialize(query: SearchQuery): String { ... }
+}
+```
+
+Regenerates the canonical text representation from `SearchQuery`. Used to update text bar when form changes. Ordering: operators first (`from:`, `kind:`, `since:`, `until:`, `lang:`, `domain:`), then hashtags, then free text / OR terms, then exclusions.
+
+**Step 1.5: Unit tests**
+
+```kotlin
+// commons/src/commonTest/.../search/QueryParserTest.kt
+// commons/src/commonTest/.../search/QuerySerializerTest.kt
+// commons/src/commonTest/.../search/KindRegistryTest.kt
+```
+
+Test matrix:
+- Single operator of each type
+- Combined operators
+- OR with operators
+- Malformed/incomplete (`from:`, `kind:invalid`, `since:not-a-date`)
+- Special characters, emoji, unicode in free text
+- Roundtrip: `serialize(parse(input)) == normalized(input)`
+- Multiple `from:` authors
+- Multiple `kind:` (union)
+- Quoted phrases
+- Exclusion terms
+- Pseudo-kind detection (`kind:reply`, `kind:media`)
+- Edge: empty string, whitespace only, very long query
+- OR precedence: `from:x a OR b kind:note` → operators AND, text OR
+
+**Consider property-based testing** with Kotest for roundtrip fidelity.
+
+#### Phase 2: Filter Factory + Relay Integration (desktopApp)
+
+**Step 2.1: `SearchFilterFactory`**
+
+```kotlin
+// desktopApp/src/jvmMain/.../subscriptions/SearchFilterFactory.kt
+object SearchFilterFactory {
+ fun createFilters(query: SearchQuery): List { ... }
+}
+```
+
+- If `query.kinds` specified → use those kinds directly
+- If `query.kinds` empty → use default searchable kinds (align with Android's 3 groups)
+- Split kinds into groups of ~10 (relay `max_filters` limit safety)
+- Build NIP-50 search string: `query.text` + inline NIP-50 extensions (`language:en`, `domain:x`)
+- Strip exclusion terms from search string (don't send `-spam` to relay)
+- `query.authors` → `Filter.authors` (only resolved hex keys)
+- `query.since` / `query.until` → `Filter.since` / `Filter.until`
+- `query.hashtags` → `Filter.tags["t"]` (not in search string — more reliable)
+- OR queries: return separate filter lists per OR term
+
+#### Research Insights: Module Placement
+
+**SearchFilterFactory stays in desktopApp** — it depends on `SubscriptionConfig` and relay topology, which are desktop-specific. Correct as planned.
+
+**SearchResultFilter can move to commons/commonMain** — pure Kotlin, no platform dependencies. Android can reuse it later.
+
+**SearchHistoryStore can move to commons as expect/actual** — follows `SecureKeyStorage` pattern. `expect class SearchHistoryStore`, with `actual` implementations using `java.util.prefs.Preferences` on desktop and SharedPreferences/DataStore on Android.
+
+**Step 2.2: Default searchable kind groups**
+
+Port from Android's `SearchPostsByText.kt` to desktop. Reference the same kinds:
+
+```kotlin
+// Group 1: TextNote, LongText, Badge, PeopleList, BookmarkList, AudioHeader, AudioTrack, PinList, PollNote, ChannelCreate
+// Group 2: ChannelMetadata, Classifieds, Community, EmojiPack, Highlight, LiveActivities, PublicMessage, NNS, Wiki, Comment
+// Group 3: InteractiveStory (2 kinds), FollowList, NipText, Poll, PollResponse
+```
+
+Kind group splitting is relay-imposed (`max_filters` limits), not protocol. Use the quartz KIND constants, don't hardcode numbers.
+
+**Step 2.3: Search subscription factory**
+
+```kotlin
+// desktopApp/src/jvmMain/.../subscriptions/FeedSubscription.kt (extend)
+fun createAdvancedSearchSubscription(
+ relays: Set,
+ query: SearchQuery,
+ onEvent: ...,
+ onEose: ...,
+): List
+```
+
+#### Research Insights: Subscription Management
+
+**Use `flatMapLatest`** on debouncedQuery to auto-cancel old subscriptions when query changes:
+```kotlin
+val results: Flow> = debouncedQuery
+ .flatMapLatest { query ->
+ if (query.isEmpty) flowOf(emptyList())
+ else channelFlow {
+ supervisorScope {
+ val filters = SearchFilterFactory.createFilters(query)
+ // Launch independent subscription per filter group
+ filters.forEach { filter ->
+ launch { subscribeAndEmit(filter, relays) }
+ }
+ }
+ }
+ }
+```
+
+**`supervisorScope`** for independent relay subscription failure isolation — one relay failure doesn't cancel others.
+
+**`merge()` (not `combine()`)** for OR query result flows — emit results as they arrive from any term.
+
+**Batch filters per OR term** in a single REQ (not per kind group), reducing subscription count:
+- 3 OR terms × 1 batched REQ × 3 relays = 9 subscriptions (vs 45 if unbatched)
+
+**Cap: max 3 OR terms** (not 5) — subscription fan-out gets expensive.
+
+**Step 2.4: Client-side post-filter**
+
+```kotlin
+// commons/src/commonMain/.../search/SearchResultFilter.kt
+object SearchResultFilter {
+ fun filter(events: List, query: SearchQuery): List
+}
+```
+
+- `-term` exclusion: check `event.content` doesn't contain term (case-insensitive)
+- `kind:reply` detection: kind 1 with `e` tag
+- `kind:media` detection: kind 1 with `imeta` tag or URL patterns
+- Deduplication by event ID (for OR query merges)
+
+#### Research Insights: Performance
+
+**Batch result accumulation** — current Amethyst pattern of `_results.value = _results.value + item` is O(n^2). Use channel-based batching:
+
+```kotlin
+channelFlow {
+ val batch = mutableSetOf() // Set for O(1) dedup
+ var lastEmit = 0L
+
+ onEvent = { event ->
+ batch.add(event)
+ val now = System.currentTimeMillis()
+ if (now - lastEmit > 100) { // 100ms batch window
+ send(batch.toList())
+ lastEmit = now
+ }
+ }
+}
+```
+
+**Apply post-filter at batch emission time**, not per-event.
+
+**Result ordering:** dedup by event ID, sort by `createdAt` descending (match Amethyst Android behavior).
+
+#### Phase 3: Advanced Search State (commons/commonMain)
+
+**Step 3.1: `AdvancedSearchBarState`**
+
+New state holder that extends/replaces `SearchBarState`. Manages the `SearchQuery` as SSOT.
+
+```kotlin
+// commons/src/commonMain/.../viewmodels/AdvancedSearchBarState.kt
+class AdvancedSearchBarState(
+ private val cache: ICacheProvider,
+ private val scope: CoroutineScope,
+) {
+ private val _query = MutableStateFlow(SearchQuery.EMPTY)
+ val query: StateFlow = _query.asStateFlow()
+
+ // Track who initiated the change (prevents sync loops)
+ private var _changeSource: ChangeSource = ChangeSource.INIT
+ val changeSource get() = _changeSource
+
+ // Raw text from user typing (preserved when source=TEXT)
+ private val _rawText = MutableStateFlow("")
+ val rawText: StateFlow = _rawText.asStateFlow()
+
+ // Derived: text representation for the search bar
+ val displayText: StateFlow = combine(_query, _rawText) { query, raw ->
+ if (_changeSource == ChangeSource.TEXT) raw
+ else QuerySerializer.serialize(query)
+ }.stateIn(scope, SharingStarted.Eagerly, "")
+
+ // For relay subscriptions to observe (300ms debounce)
+ val debouncedQuery: StateFlow = _query
+ .debounce(300)
+ .stateIn(scope, SharingStarted.Eagerly, SearchQuery.EMPTY)
+
+ // Results
+ val peopleResults: StateFlow>
+ val noteResults: StateFlow>
+ val isSearching: StateFlow
+
+ // Text bar input (parses into SearchQuery)
+ fun updateFromText(rawText: String) {
+ _changeSource = ChangeSource.TEXT
+ _rawText.value = rawText
+ _query.value = QueryParser.parse(rawText)
+ }
+
+ // Form panel input (mutates SearchQuery directly)
+ fun updateKinds(kinds: List) {
+ _changeSource = ChangeSource.FORM
+ _query.value = _query.value.copy(kinds = kinds.toImmutableList())
+ }
+
+ fun addAuthor(hexOrName: String) { ... }
+ fun removeAuthor(hex: String) { ... }
+ fun updateDateRange(since: Long?, until: Long?) { ... }
+ fun addHashtag(tag: String) { ... }
+ fun removeHashtag(tag: String) { ... }
+ fun addExcludeTerm(term: String) { ... }
+ fun updateLanguage(lang: String?) { ... }
+
+ // Name resolution (async)
+ fun resolveAuthorName(name: String, onResolved: (String) -> Unit)
+
+ // History
+ fun addToHistory(query: SearchQuery)
+ fun getHistory(): List
+ fun saveSearch(query: SearchQuery, label: String)
+ fun getSavedSearches(): List
+ fun deleteSavedSearch(id: String)
+}
+
+enum class ChangeSource { TEXT, FORM, INIT }
+```
+
+**Step 3.2: Name resolution**
+
+- Check local cache first: `cache.findUsersStartingWith(name, 5)`
+- If multiple matches → expose as `authorSuggestions: StateFlow>` for autocomplete dropdown
+- If single match → auto-resolve to hex key
+- If no local match → keep as `authorNames` (display in form as "unresolved: vitor")
+- Keep name resolution **out of QueryParser** — parser returns raw strings, platform layer resolves
+- No NIP-05 resolution in v1. Follow-up.
+
+#### Phase 4: Desktop UI (desktopApp)
+
+**Step 4.1: Rewrite `SearchScreen.kt`**
+
+```kotlin
+// desktopApp/src/jvmMain/.../ui/SearchScreen.kt
+@Composable
+fun SearchScreen(
+ localCache: DesktopLocalCache,
+ relayManager: DesktopRelayConnectionManager,
+ ...
+) {
+ val state = remember { AdvancedSearchBarState(localCache, scope) }
+ val query by state.query.collectAsState()
+ val displayText by state.displayText.collectAsState()
+ var panelExpanded by remember { mutableStateOf(false) }
+
+ Column {
+ // Search bar row
+ Row {
+ OutlinedTextField(
+ value = TextFieldValue(
+ text = displayText,
+ selection = TextRange(displayText.length),
+ ),
+ onValueChange = { state.updateFromText(it.text) },
+ placeholder = { Text("Search notes, people, tags... or use operators") },
+ ...
+ )
+ TextButton(onClick = { panelExpanded = !panelExpanded }) {
+ Text(if (panelExpanded) "Advanced ^" else "Advanced v")
+ }
+ }
+
+ // Expandable advanced panel
+ AnimatedVisibility(
+ visible = panelExpanded,
+ enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
+ exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(),
+ ) {
+ AdvancedSearchPanel(
+ query = query,
+ onKindsChanged = { state.updateKinds(it) },
+ onAuthorAdded = { state.addAuthor(it) },
+ onAuthorRemoved = { state.removeAuthor(it) },
+ onDateRangeChanged = { since, until -> state.updateDateRange(since, until) },
+ ...
+ )
+ }
+
+ // Results
+ SearchResultsList(state = state, ...)
+ }
+}
+```
+
+#### Research Insights: Compose UI Patterns
+
+**Panel animation:** `expandVertically(expandFrom = Alignment.Top)` + `fadeIn()` — panel slides down from search bar, feels natural.
+
+**Kind preset chips:** Use `FilterChip` (not ElevatedFilterChip or AssistChip):
+```kotlin
+KindRegistry.presets.forEach { (name, kinds) ->
+ FilterChip(
+ selected = query.kinds.containsAll(kinds),
+ onClick = { onKindsChanged(toggleKinds(query.kinds, kinds)) },
+ label = { Text(name) },
+ )
+}
+```
+
+**Author autocomplete:** `DropdownMenu` (not `Popup`) — handles dismissal, positioning, focus correctly.
+
+**Date input:** Text fields with `YYYY-MM-DD` format (no native date picker on desktop). Validate on blur.
+
+**Keyboard events:** `onPreviewKeyEvent` for Escape (before children), `onKeyEvent` for `/` (after children).
+
+**Step 4.2: `AdvancedSearchPanel` composable**
+
+```kotlin
+// desktopApp/src/jvmMain/.../ui/search/AdvancedSearchPanel.kt
+@Composable
+fun AdvancedSearchPanel(
+ query: SearchQuery,
+ onKindsChanged: (List) -> Unit,
+ onAuthorAdded: (String) -> Unit,
+ ...
+)
+```
+
+Components:
+- **Content type row**: `FilterChip` per preset from `KindRegistry.presets`. Checked state derived from `query.kinds`.
+- **Author field**: `OutlinedTextField` + `DropdownMenu` autocomplete dropdown (from `authorSuggestions`). Shows chips for added authors.
+- **Date range**: Two date text fields (`yyyy-MM-dd` format). Validate on blur.
+- **Hashtags**: Chip group with add button.
+- **Exclude terms**: Chip group with add button.
+- **Language dropdown**: `DropdownMenu` with common ISO 639-1 codes.
+- **Clear / Search buttons**: Clear resets `SearchQuery.EMPTY`. Search is implicit (debounced).
+- **Tooltips**: `TooltipBox` + `PlainTooltip` for operator hint text on hover.
+
+**Step 4.3: `SearchResultsList` composable**
+
+```kotlin
+// desktopApp/src/jvmMain/.../ui/search/SearchResultsList.kt
+@Composable
+fun SearchResultsList(state: AdvancedSearchBarState, ...)
+```
+
+#### Research Insights: Results Display
+
+- Single `LazyColumn` with **sticky section headers**: `stickyHeader { Surface(color = background) { ... } }`
+- Sections: **People** (kind 0), **Notes** (kind 1), **Articles** (kind 30023), **Other** (everything else)
+- Each section shows top 5 results with "Show all N" expand link
+- Note results: content preview (first 200 chars), author name, timestamp, kind badge
+- Progressive loading: results stream in as relay responds, sections update live
+- **Shimmer loading**: Custom shimmer via `Brush.linearGradient` + `InfiniteTransition` (reusable, put in commons)
+- Empty state: "No results found. Try broader terms or fewer filters."
+- **Stable keys** for LazyColumn items: `key = { "section-${event.id}" }` to prevent recomposition flicker
+
+**Step 4.4: Relay subscription wiring**
+
+In `SearchScreen.kt`, use `rememberSubscription()` with the debounced query:
+
+```kotlin
+val debouncedQuery by state.debouncedQuery.collectAsState()
+val configuredRelays by remember {
+ relayManager.relayStatuses
+ .map { it.keys }
+ .distinctUntilChanged() // Prevent churn (FeedScreen pattern)
+}.collectAsState(emptySet())
+
+// Create subscriptions from query
+val filters = remember(debouncedQuery) { SearchFilterFactory.createFilters(debouncedQuery) }
+// ... wire up rememberSubscription per filter group
+```
+
+#### Research Insights: Desktop-Specific Patterns
+
+**Keyboard shortcuts:**
+- `Ctrl+K` or `/` → focus search bar. Use `Window.onKeyEvent` for `/` (after children process), `onPreviewKeyEvent` for Escape.
+- `Escape` → close advanced panel / clear search
+- `Enter` → execute search immediately (skip debounce)
+- Register `Ctrl+K` in `MenuBar { Item("Search", KeyShortcut(Key.K, ctrl = true)) { focusSearch() } }`
+
+**Clipboard:** Support pasting npub/note/nevent directly into search bar — already handled by `QueryParser` treating bech32 as `from:` equivalent.
+
+#### Phase 5: Search History + Saved Searches
+
+**Step 5.1: Local persistence**
+
+```kotlin
+// desktopApp/src/jvmMain/.../storage/SearchHistoryStore.kt
+class SearchHistoryStore(private val appDataDir: Path) {
+ private val historyFile = appDataDir / "search_history.json"
+ private val savedFile = appDataDir / "saved_searches.json"
+
+ // In-memory cache, async persist on Dispatchers.IO
+ private var historyCache: MutableList = mutableListOf()
+
+ fun addToHistory(query: SearchQuery) // Dedup by serialized text, max 20 entries
+ fun getHistory(): List
+ fun clearHistory()
+
+ fun saveSearch(query: SearchQuery, label: String)
+ fun getSavedSearches(): List
+ fun deleteSavedSearch(id: String)
+}
+
+data class SavedSearch(
+ val id: String, // UUID
+ val label: String,
+ val query: SearchQuery,
+ val createdAt: Long,
+)
+```
+
+JSON serialization via kotlinx.serialization (already in project).
+
+**Platform data dirs:** macOS `~/Library/Application Support/Amethyst/`, Linux `~/.config/amethyst/`, Windows `%APPDATA%\Amethyst\`. Use existing `DesktopPreferences.kt` pattern or `java.util.prefs.Preferences`.
+
+**Step 5.2: History UI**
+
+When search bar is empty → show recent history + saved searches below the bar.
+- History items: click to load query into search bar
+- Saved searches: click to load, X to delete
+- "Clear history" button at bottom
+
+#### Phase 6: Integration + Polish
+
+**Step 6.1: Search hints update**
+
+Update empty state hints to show operator examples:
+```
+from:npub1... Filter by author
+kind:article Long-form content
+since:2025-01 After January 2025
+#bitcoin Hashtag search
+"exact phrase" Exact match
+bitcoin OR nostr Either term
+```
+
+**Step 6.2: Keyboard shortcuts**
+
+- `Ctrl+K` or `/` → focus search bar (desktop convention)
+- `Escape` → close advanced panel / clear search
+- `Enter` → execute search immediately (skip debounce)
+
+**Step 6.3: Search relay configuration**
+
+- Desktop settings page: list of search relays (editable)
+- Default: `relay.nostr.band`, `nostr.wine`, `relay.damus.io` (curated, don't auto-probe NIP-11)
+- Future: read from kind 10007 `SearchRelayListEvent`
+
+## System-Wide Impact
+
+### Interaction Graph
+
+1. User types in search bar → `AdvancedSearchBarState.updateFromText()` → `QueryParser.parse()` → `_query` updates
+2. `_query` change → `displayText` recomputes (serialized, guarded by `changeSource`) → text bar updates
+3. `_query` change → `debouncedQuery` emits after 300ms → `flatMapLatest` cancels old subscriptions → new relay subscriptions created
+4. Subscription creation → `relayManager.subscribe()` → relay receives REQ
+5. Relay responds → `onEvent` callback → events batched (100ms windows) → stored in cache + state
+6. Post-filter applied at batch emission → results displayed in `SearchResultsList`
+
+### Error Propagation
+
+- Relay timeout → `onEose` fires → `isSearching` set to false → "No results" shown
+- Name resolution failure → name stays in `authorNames` as unresolved → user sees "unresolved: vitor" chip
+- Parse error → malformed operators treated as literal text → no crash, graceful degradation
+- Non-NIP-50 relay → relay ignores `search` field, returns nothing useful → handled by showing results from other relays
+- Individual relay failure → `supervisorScope` isolates failure → other relays continue
+
+### State Lifecycle Risks
+
+- **Subscription churn**: Mitigated by `distinctUntilChanged()` on relay statuses (proven pattern from FeedScreen)
+- **Bidirectional update loop**: Prevented by `sourceOfChange` discriminator — TEXT changes don't trigger re-serialization
+- **Stale results**: Session-only cache cleared on restart. `flatMapLatest` clears previous subscription results on new query.
+- **Memory pressure from result batching**: Bounded by LRU cache (500 entries) and batch window (100ms)
+
+### API Surface Parity
+
+- `SearchBarState` in commons is used by both Android and Desktop today. `AdvancedSearchBarState` extends this pattern but is new.
+- `QueryParser`, `SearchQuery`, `KindRegistry`, `SearchResultFilter` placed in commons so Android can adopt later.
+- Desktop `FilterBuilders` gets new `searchAdvanced()` methods but existing methods unchanged.
+
+## Acceptance Criteria
+
+### Functional
+
+- [x] Query operators parse correctly: `from:`, `kind:`, `since:`, `until:`, `#tag`, `"phrase"`, `-exclude`, `OR`, `lang:`, `domain:`
+- [x] Kind aliases resolve: `kind:note` → kind 1, `kind:article` → kind 30023, etc.
+- [x] Pseudo-kinds handled: `kind:reply` and `kind:media` flagged for client-side post-filtering
+- [x] Advanced panel expands/collapses below search bar with `expandVertically` + `fadeIn` animation
+- [x] Bidirectional sync: text changes update form, form changes update text, no loops (sourceOfChange guard)
+- [x] Default search (no kind filter) queries 30+ kinds across 3 filter groups (Android parity)
+- [x] OR queries (`bitcoin OR lightning`) send parallel subscriptions, merge + dedup results (max 3 terms)
+- [x] Results grouped by type: People, Notes, Articles, Other with sticky section headers
+- [x] Note results show content preview, author, timestamp, kind badge
+- [x] Search history persists last 20 queries locally
+- [x] Saved searches persist across sessions
+- [x] Exclusion terms (`-spam`) filtered client-side, not sent to relay
+- [x] Empty search bar shows history + saved searches + operator hints
+- [x] `Escape` closes panel / clears search (Ctrl+K deferred — needs window-level handler)
+
+### Non-Functional
+
+- [x] Search debounce: 300ms for text, immediate for form toggles
+- [x] Max 3 OR terms, 10 authors per query
+- [x] Relay subscription churn prevented via `distinctUntilChanged()`
+- [x] Result accumulation uses set-based dedup
+- [x] `@Immutable` SearchQuery with `ImmutableList` fields
+- [x] Session cache cleared on restart
+- [x] All query parsing logic unit-tested in commons (roundtrip, edge cases, malformed input)
+
+### Quality Gates
+
+- [x] `QueryParser` + `QuerySerializer` roundtrip tests pass
+- [x] `KindRegistry` tests for all aliases + pseudo-kinds
+- [x] `SearchFilterFactory` compiles + filter generation correct
+- [x] `SearchResultFilter` handles exclusion, reply detection, media detection
+- [x] Desktop search screen renders results for all kind types
+- [x] `spotlessApply` passes
+
+## Dependencies & Prerequisites
+
+| Dependency | Status | Notes |
+|-----------|--------|-------|
+| `Filter.search` field | Exists | `quartz/.../Filter.kt` |
+| `SearchRelayListEvent` | Exists | `quartz/.../SearchRelayListEvent.kt` (kind 10007) |
+| `SearchBarState` | Exists | `commons/.../SearchBarState.kt` — will be extended |
+| `SearchParser` | Exists | `commons/.../SearchParser.kt` — bech32 parsing, kept as-is |
+| `FilterBuilders` | Exists | `desktopApp/.../FilterBuilders.kt` — extended |
+| `rememberSubscription()` | Exists | `desktopApp/.../SubscriptionUtils.kt` |
+| `DesktopLocalCache` | Exists | Needs `findNotesStartingWith()` for local note search |
+| kotlinx.serialization | In project | For search history JSON persistence |
+| kotlinx.collections.immutable | **Add** | For `ImmutableList`/`persistentListOf()` in SearchQuery |
+
+## Risk Analysis & Mitigation
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|-----------|--------|------------|
+| Bidirectional sync loops | Medium | High | `sourceOfChange` discriminator, TEXT changes preserve raw text |
+| Relay subscription explosion (OR + many relays) | Medium | Medium | Cap: 3 OR terms, batch filters per term. Total max ~27 subs |
+| NIP-50 relay variability | High | Medium | Graceful degradation — show whatever relays return |
+| Name resolution UX confusion | Medium | Medium | Show "unresolved" indicator, autocomplete dropdown |
+| O(n^2) result accumulation | Medium | Medium | Batch + set-based dedup via channelFlow |
+| Large result sets from broad queries | High | Low | Client-side pagination, "Show more" per section |
+| Android `SearchBarState` compatibility | Low | Medium | New `AdvancedSearchBarState`, old class untouched |
+
+## Simplicity Guidance (MVP Scoping)
+
+The full plan is comprehensive. If time-constrained, a minimal viable version can ship with:
+
+**MVP (Phase 1+2+4 subset, ~350 LOC, 5 files):**
+- `SearchQuery` data class (no `@Immutable` yet, plain lists)
+- `QueryParser` with 5 operators: `from:`, `kind:`, `since:`, `until:`, `#tag`
+- `SearchFilterFactory` for filter generation
+- Rewritten `SearchScreen.kt` with form panel (no bidirectional sync — form→text only)
+- Unit tests for parser
+
+**Cut for MVP:**
+- OR queries, `lang:`, `domain:`, `-exclude`
+- `QuerySerializer` (not needed without bidirectional sync)
+- Saved searches (history only)
+- Shimmer loading states
+- Keyboard shortcuts beyond Enter/Escape
+
+**Add incrementally:** OR queries → bidirectional sync → saved searches → keyboard shortcuts → NIP-50 extensions
+
+## File Matrix
+
+| File | Status | Module | Action |
+|------|--------|--------|--------|
+| `SearchQuery.kt` | New | commons/commonMain | Create data class with `@Immutable` |
+| `QueryParser.kt` | New | commons/commonMain | Create recursive descent parser |
+| `QuerySerializer.kt` | New | commons/commonMain | Create serializer |
+| `KindRegistry.kt` | New | commons/commonMain | Create kind alias registry |
+| `AdvancedSearchBarState.kt` | New | commons/commonMain | Create state holder with `sourceOfChange` |
+| `SearchResultFilter.kt` | New | commons/commonMain | Create post-filter (reusable) |
+| `QueryParserTest.kt` | New | commons/commonTest | Create tests |
+| `QuerySerializerTest.kt` | New | commons/commonTest | Create tests |
+| `KindRegistryTest.kt` | New | commons/commonTest | Create tests |
+| `SearchFilterFactory.kt` | New | desktopApp | Create filter factory |
+| `AdvancedSearchPanel.kt` | New | desktopApp | Create form panel composable |
+| `SearchResultsList.kt` | New | desktopApp | Create results list composable |
+| `SearchHistoryStore.kt` | New | desktopApp | Create persistence |
+| `SearchScreen.kt` | Rewrite | desktopApp | Integrate advanced search |
+| `FeedSubscription.kt` | Extend | desktopApp | Add `createAdvancedSearchSubscription()` |
+| `FilterBuilders.kt` | Extend | desktopApp | Add search filter methods |
+| `SearchBarState.kt` | Keep | commons/commonMain | Untouched (backward compat) |
+| `SearchParser.kt` | Keep | commons/commonMain | Untouched (bech32 parsing still used) |
+
+## Future Considerations
+
+- **AI natural language → query** (deferred) — parse "notes about bitcoin from Jack since January" to operators
+- **Local SQLite FTS5 index** — offline search for desktop
+- **NIP-90 DVM search** — pay-per-query via Lightning
+- **Search relay auto-discovery** — NIP-11 `supported_nips` check for NIP-50
+- **NIP-05 name resolution** — async resolve `from:vitor@nostr.com`
+- **Saved searches as Nostr events** — portable across devices
+- **Search analytics** — trending topics, popular queries
+
+## Sources & References
+
+### Origin
+
+- **Brainstorm document:** [docs/brainstorms/2026-03-10-advanced-search-brainstorm.md](docs/brainstorms/2026-03-10-advanced-search-brainstorm.md) — Key decisions: relay-first approach, Twitter-style operators + form UI, extensible kind presets, AI deferred, session-only caching
+
+### Internal References
+
+- Current search screen: `desktopApp/.../ui/SearchScreen.kt`
+- Search state: `commons/.../viewmodels/SearchBarState.kt`
+- Bech32 parser: `commons/.../search/SearchParser.kt`
+- Filter class: `quartz/.../nip01Core/relay/filters/Filter.kt`
+- Android kind groups: `amethyst/.../searchCommand/subassemblies/SearchPostsByText.kt`
+- FilterBuilders: `desktopApp/.../subscriptions/FilterBuilders.kt`
+- Feed subscriptions: `desktopApp/.../subscriptions/FeedSubscription.kt`
+- Relay subscription utils: `desktopApp/.../subscriptions/SubscriptionUtils.kt`
+- Relay churn fix: `desktopApp/.../ui/FeedScreen.kt:163-167` (`distinctUntilChanged()` pattern)
+
+### External References
+
+- [NIP-50 Search](https://nips.nostr.com/50)
+- [NIP-50 extensions](https://github.com/nostr-protocol/nips/blob/master/50.md): `language:`, `domain:`, `sentiment:`, `nsfw:`, `include:spam`
+- [kotlinx.collections.immutable](https://github.com/Kotlin/kotlinx.collections.immutable) — `ImmutableList`, `persistentListOf()`
+
+## Unanswered Questions
+
+None — all resolved in brainstorm. Implementation details clarified by research agents.