From ae76b86ab6d3856e2242d9264643a7210b034125 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 11:42:13 +0200 Subject: [PATCH 01/14] =?UTF-8?q?feat(search):=20add=20query=20engine=20?= =?UTF-8?q?=E2=80=94=20SearchQuery,=20QueryParser,=20QuerySerializer,=20Ki?= =?UTF-8?q?ndRegistry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of advanced search. Recursive descent parser for operators (from:, kind:, since:, until:, lang:, domain:, #tag, -exclude, OR), serializer for bidirectional sync, kind alias registry with pseudo-kind support. 68 unit tests all passing. Co-Authored-By: Claude Opus 4.6 --- .../amethyst/commons/search/KindRegistry.kt | 67 ++++ .../amethyst/commons/search/QueryParser.kt | 346 ++++++++++++++++++ .../commons/search/QuerySerializer.kt | 113 ++++++ .../amethyst/commons/search/SearchQuery.kt | 60 +++ .../commons/search/KindRegistryTest.kt | 103 ++++++ .../commons/search/QueryParserTest.kt | 284 ++++++++++++++ .../commons/search/QuerySerializerTest.kt | 177 +++++++++ 7 files changed, 1150 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchQuery.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistryTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializerTest.kt 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 000000000..2b3e921b7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt @@ -0,0 +1,67 @@ +/* + * 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 + +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 listOf(TextNoteEvent.KIND), + "Articles" to listOf(LongTextNoteEvent.KIND), + "Media" to listOf(TextNoteEvent.KIND), + "Channels" to listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), + "Communities" to listOf(CommunityDefinitionEvent.KIND), + "Wiki" to 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 000000000..13de19b7c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt @@ -0,0 +1,346 @@ +/* + * 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(), + ) + } + + internal 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 { + // Simple UTC date to unix timestamp calculation + // Days from epoch (1970-01-01) to the given date + var totalDays = 0L + + // Add days for full years + for (y in 1970 until year) { + totalDays += if (isLeapYear(y)) 366 else 365 + } + + // Add days for full months in target year + 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] + } + + // Add remaining days + totalDays += (day - 1) + + return totalDays * 86400L + } + + private fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} 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 000000000..00be7d75e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt @@ -0,0 +1,113 @@ +/* + * 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(" ") + } + + internal fun timestampToDate(timestamp: Long): String { + // Convert unix timestamp to YYYY-MM-DD + 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')}" + } + + private fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} 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 000000000..fff17c1df --- /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/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 000000000..5a03ab61c --- /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 000000000..4cae7f2c3 --- /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 000000000..0534e5013 --- /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) + } +} From c3a45822d507f5c28ccc0062bbdf2cec50d190ed Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 11:43:48 +0200 Subject: [PATCH 02/14] feat(search): add SearchFilterFactory and SearchResultFilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2: SearchFilterFactory converts SearchQuery → List with default 3-group kind splitting (Android parity), NIP-50 inline extensions, OR query support. SearchResultFilter handles client-side exclusions, reply/media pseudo-kind detection, dedup. Co-Authored-By: Claude Opus 4.6 --- .../commons/search/SearchResultFilter.kt | 74 ++++++++ .../subscriptions/SearchFilterFactory.kt | 163 ++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt 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 000000000..6cff2d8cf --- /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/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 000000000..7aa491f71 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt @@ -0,0 +1,163 @@ +/* + * 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, + ) + } + } + + fun createOrFilters( + query: SearchQuery, + limit: Int = 100, + ): List> { + if (query.orTerms.isEmpty()) return listOf(createFilters(query, limit)) + + // Each OR term gets its own set of filters + return query.orTerms.take(3).map { term -> + val termQuery = query.copy(text = term, orTerms = kotlinx.collections.immutable.persistentListOf()) + createFilters(termQuery, 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()) + } +} From 8265d3f70a52f76112bcf537b78bc6d313e63b7d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 11:44:57 +0200 Subject: [PATCH 03/14] feat(search): add AdvancedSearchBarState with bidirectional sync Phase 3: State holder with MutableStateFlow as SSOT, sourceOfChange discriminator to prevent parse/serialize loops, debounced query for subscriptions, results management, author name resolution via cache autocomplete. Co-Authored-By: Claude Opus 4.6 --- .../commons/search/AdvancedSearchBarState.kt | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt 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 000000000..ba054a801 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -0,0 +1,228 @@ +/* + * 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.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Event +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.stateIn + +enum class ChangeSource { + TEXT, + FORM, + INIT, +} + +@OptIn(FlowPreview::class) +class AdvancedSearchBarState( + private val cache: ICacheProvider, + 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() + + private val _isSearching = MutableStateFlow(false) + val isSearching: StateFlow = _isSearching.asStateFlow() + + // Author name suggestions for autocomplete + private val _authorSuggestions = MutableStateFlow>(persistentListOf()) + val authorSuggestions: StateFlow> = _authorSuggestions.asStateFlow() + + // Expanded panel state + private val _panelExpanded = MutableStateFlow(false) + val panelExpanded: StateFlow = _panelExpanded.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 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 togglePanel() { + _panelExpanded.value = !_panelExpanded.value + } + + fun clearSearch() { + _changeSource = ChangeSource.INIT + _rawText.value = "" + _query.value = SearchQuery.EMPTY + _peopleResults.value = persistentListOf() + _noteResults.value = persistentListOf() + _isSearching.value = false + } + + // Results management (called from subscription callbacks) + fun startSearching() { + _isSearching.value = true + } + + fun stopSearching() { + _isSearching.value = false + } + + fun clearResults() { + _peopleResults.value = persistentListOf() + _noteResults.value = persistentListOf() + } + + fun addPeopleResult(user: User) { + val current = _peopleResults.value + if (current.none { it.pubkeyHex == user.pubkeyHex }) { + _peopleResults.value = (current + user).toImmutableList() + } + } + + fun addNoteResults(events: List) { + val current = _noteResults.value + val existingIds = current.map { it.id }.toSet() + val newEvents = events.filter { it.id !in existingIds } + if (newEvents.isNotEmpty()) { + _noteResults.value = (current + newEvents).sortedByDescending { it.createdAt }.toImmutableList() + } + } + + // Name resolution for autocomplete + @Suppress("UNCHECKED_CAST") + fun resolveAuthorName(name: String) { + if (name.length < 2) { + _authorSuggestions.value = persistentListOf() + return + } + val results = cache.findUsersStartingWith(name, 5) as List + _authorSuggestions.value = results.toImmutableList() + } +} From 55154dddb25bd2f5dcba575ec0742a11fa77fc21 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 12:12:17 +0200 Subject: [PATCH 04/14] feat(search): add advanced search UI with panel, results list, and SearchScreen rewrite Phase 4: Desktop UI composables for advanced search. - Rewrite SearchScreen.kt to use AdvancedSearchBarState with TextFieldValue - Add AdvancedSearchPanel with kind presets, author chips, date range, hashtags, language - Add SearchResultsList with sticky headers for People/Notes/Articles/Other sections - Make QueryParser.parseDateToTimestamp and QuerySerializer.timestampToDate public Co-Authored-By: Claude Opus 4.6 --- .../amethyst/commons/search/QueryParser.kt | 2 +- .../commons/search/QuerySerializer.kt | 2 +- .../amethyst/desktop/ui/SearchScreen.kt | 465 +++++++++--------- .../desktop/ui/search/AdvancedSearchPanel.kt | 409 +++++++++++++++ .../desktop/ui/search/SearchResultsList.kt | 327 ++++++++++++ 5 files changed, 978 insertions(+), 227 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt 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 index 13de19b7c..be1a93e34 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt @@ -280,7 +280,7 @@ object QueryParser { ) } - internal fun parseDateToTimestamp(dateStr: String): Long? { + fun parseDateToTimestamp(dateStr: String): Long? { // ISO 8601 formats: YYYY, YYYY-MM, YYYY-MM-DD return try { val parts = dateStr.split("-") 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 index 00be7d75e..dd7b0612a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt @@ -84,7 +84,7 @@ object QuerySerializer { return parts.joinToString(" ") } - internal fun timestampToDate(timestamp: Long): String { + fun timestampToDate(timestamp: Long): String { // Convert unix timestamp to YYYY-MM-DD var remaining = timestamp var year = 1970 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 cc4cee93b..22ac6ffa9 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 @@ -31,8 +36,6 @@ 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.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward @@ -41,9 +44,9 @@ import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search 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.MaterialTheme @@ -59,17 +62,26 @@ 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.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.model.User +import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState 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.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.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -85,66 +97,101 @@ fun SearchScreen( modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() - val searchState = remember { SearchBarState(localCache, scope) } + val state = remember { AdvancedSearchBarState(localCache, 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 relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() + val displayText by state.displayText.collectAsState() + 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() - // 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(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty()) return@rememberSubscription null - - // Only search relays if we have a real query and limited local results - if (searchState.shouldSearchRelays) { - searchState.startRelaySearch() - createSearchPeopleSubscription( - relays = configuredRelays, - 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 - } - } - - // Subscribe to metadata for searched users (to populate cache) - rememberSubscription(relayStatuses, searchText, relayManager = relayManager) { - val configuredRelays = relayStatuses.keys - if (configuredRelays.isEmpty() || searchText.length < 2) { + // NIP-50 people search subscription + rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { + if (connectedRelays.isEmpty() || debouncedQuery.isEmpty) { return@rememberSubscription null } + // Skip if bech32 detected + if (bech32Results.isNotEmpty()) return@rememberSubscription null - // If it's a specific pubkey search, fetch that user's metadata - val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) + state.startSearching() + state.clearResults() + + createSearchPeopleSubscription( + relays = connectedRelays, + searchQuery = + debouncedQuery.text.ifBlank { + com.vitorpamplona.amethyst.commons.search.QuerySerializer + .serialize(debouncedQuery) + }, + limit = 20, + onEvent = { event, _, _, _ -> + 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 = { _, _ -> + state.stopSearching() + }, + ) + } + + // NIP-50 advanced note search subscription (kinds beyond people) + rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { + if (connectedRelays.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 = connectedRelays, + onEvent = { event, _, _, _ -> + // Skip metadata events (handled by people search) + if (event.kind == 0) return@SubscriptionConfig + val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) + if (filtered.isNotEmpty()) { + state.addNoteResults(filtered) + } + }, + onEose = { _, _ -> + state.stopSearching() + }, + ) + } + + // 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 = configuredRelays, + relays = connectedRelays, pubKeyHex = pubkeyHex, onEvent = { event, _, _, _ -> if (event is MetadataEvent) { @@ -157,14 +204,12 @@ fun SearchScreen( } } - // Auto-focus the search field + // Auto-focus LaunchedEffect(Unit) { focusRequester.requestFocus() } - Column( - modifier = modifier.fillMaxSize(), - ) { + Column(modifier = modifier.fillMaxSize()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -184,173 +229,157 @@ 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( + text = displayText, + selection = TextRange(displayText.length), + ), + onValueChange = { 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) }, + 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) { + } else if (isSearching) { Text( "Searching relays...", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) - } 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") - } - } + // Empty state with operator hints + EmptySearchHints() + } + } +} + +@Composable +private fun EmptySearchHints() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Search for users, notes, or content", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Use operators to refine your search:", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + SearchHint("from:npub1...", "Filter by author") + SearchHint("kind:article", "Long-form content") + SearchHint("since:2025-01", "After January 2025") + SearchHint("#bitcoin", "Hashtag search") + SearchHint("\"exact phrase\"", "Exact match") + SearchHint("bitcoin OR nostr", "Either term") + SearchHint("-spam", "Exclude term") + SearchHint("lang:en", "Language filter") } } } @@ -392,25 +421,11 @@ 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.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) } }, colors = 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 000000000..33de9faec --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt @@ -0,0 +1,409 @@ +/* + * 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.KindRegistry +import com.vitorpamplona.amethyst.commons.search.SearchQuery + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AdvancedSearchPanel( + query: SearchQuery, + onKindsChanged: (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, kinds) -> + FilterChip( + selected = query.kinds.toList().containsAll(kinds), + onClick = { onKindsChanged(toggleKinds(query.kinds.toList(), kinds)) }, + 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 toggleKinds( + current: List, + toggle: List, +): List = + if (current.containsAll(toggle)) { + current - toggle.toSet() + } else { + (current + toggle).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, +) { + var sinceText by remember(since) { + mutableStateOf( + since?.let { + com.vitorpamplona.amethyst.commons.search.QuerySerializer + .timestampToDate(it) + } ?: "", + ) + } + var untilText by remember(until) { + mutableStateOf( + until?.let { + com.vitorpamplona.amethyst.commons.search.QuerySerializer + .timestampToDate(it) + } ?: "", + ) + } + + 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 = + com.vitorpamplona.amethyst.commons.search.QueryParser + .parseDateToTimestamp(it) + 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 = + com.vitorpamplona.amethyst.commons.search.QueryParser + .parseDateToTimestamp(it) + 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 000000000..e26d6a462 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -0,0 +1,327 @@ +/* + * 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.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.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.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.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.ui.components.UserSearchCard +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.peopleResults.collectAsState() + val notes by state.noteResults.collectAsState() + val isSearching by state.isSearching.collectAsState() + + val hasResults = people.isNotEmpty() || notes.isNotEmpty() + + if (!hasResults && isSearching) { + Text( + "Searching relays...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = modifier.padding(vertical = 8.dp), + ) + return + } + + 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 } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + // People section + if (people.isNotEmpty()) { + stickyHeader(key = "header-people") { + SectionHeader("People", people.size, Icons.Default.Person) + } + 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), + key = { "person-more-${it.pubkeyHex}" }, + ) { 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)) } + } + stickyHeader(key = "header-notes") { + SectionHeader("Notes", textNotes.size, Icons.Default.Description) + } + 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), + key = { "note-more-${it.id}" }, + ) { 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)) } + } + stickyHeader(key = "header-articles") { + SectionHeader("Articles", articles.size, Icons.Default.Article) + } + 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), + key = { "article-more-${it.id}" }, + ) { event -> + NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) + } + } + } + } + + // Other section + if (otherNotes.isNotEmpty()) { + item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } + stickyHeader(key = "header-other") { + SectionHeader("Other", otherNotes.size, Icons.Default.Forum) + } + 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 SectionHeader( + title: String, + count: Int, + icon: ImageVector, +) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 8.dp), + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + "$title ($count)", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@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( + formatTimestamp(event.createdAt), + 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, + key: (T) -> String, + 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") + } + } +} + +private fun formatTimestamp(ts: Long): String { + val now = System.currentTimeMillis() / 1000 + val diff = now - ts + return when { + diff < 60 -> { + "just now" + } + + diff < 3600 -> { + "${diff / 60}m ago" + } + + diff < 86400 -> { + "${diff / 3600}h ago" + } + + diff < 604800 -> { + "${diff / 86400}d ago" + } + + else -> { + // Simple date format + val days = ts / 86400 + val year = 1970 + (days / 365).toInt() + "$year" + } + } +} From e1abb037be8d5cf99e17fdaf5ec08c5b5c240ddf Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 12:13:34 +0200 Subject: [PATCH 05/14] feat(search): add SearchHistoryStore with recent queries and saved searches Phase 5: Persistent search history using Java Preferences API. - Last 20 queries stored, deduped by serialized text - Saved searches with labels and timestamps - Uses QuerySerializer/QueryParser for round-trip serialization (no new deps) Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/SearchHistoryStore.kt | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt 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 000000000..8271c1e67 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt @@ -0,0 +1,143 @@ +/* + * 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.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) + } +} + +data class SavedSearch( + val id: String, + val label: String, + val query: SearchQuery, + val createdAt: Long, +) From b3952ce9db677c0ee0dc7ed47f8e345d2a41aaab Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 12:15:14 +0200 Subject: [PATCH 06/14] feat(search): add keyboard shortcuts, search history UI, and operator hints Phase 6: Integration and polish. - Escape key closes advanced panel or clears search - Search history shown in empty state (recent + saved searches) - Auto-save to history when search completes - Updated empty state with operator hint examples Co-Authored-By: Claude Opus 4.6 --- .../amethyst/desktop/ui/SearchScreen.kt | 212 +++++++++++++++--- 1 file changed, 184 insertions(+), 28 deletions(-) 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 22ac6ffa9..d7fd9a643 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 @@ -36,22 +36,29 @@ 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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items 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.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 @@ -62,15 +69,22 @@ 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.model.User import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState +import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchResult 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 @@ -204,12 +218,44 @@ fun SearchScreen( } } + // Save to history when search completes + LaunchedEffect(isSearching, debouncedQuery) { + if (!isSearching && !debouncedQuery.isEmpty) { + SearchHistoryStore.addToHistory(debouncedQuery) + } + } + + // History state + val historyItems by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + // Auto-focus LaunchedEffect(Unit) { focusRequester.requestFocus() } - Column(modifier = modifier.fillMaxSize()) { + Column( + 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 + } + } + }, + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -347,40 +393,150 @@ fun SearchScreen( style = MaterialTheme.typography.bodyMedium, ) } else { - // Empty state with operator hints - EmptySearchHints() + // 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() }, + ) } } } @Composable -private fun EmptySearchHints() { - Column( - modifier = Modifier.fillMaxWidth().padding(top = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, +private fun SearchEmptyState( + historyItems: List, + savedSearches: List, + onLoadQuery: (com.vitorpamplona.amethyst.commons.search.SearchQuery) -> Unit, + onDeleteSaved: (String) -> Unit, + onClearHistory: () -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - "Search for users, notes, or content", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - Text( - "Use operators to refine your search:", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(16.dp)) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - SearchHint("from:npub1...", "Filter by author") - SearchHint("kind:article", "Long-form content") - SearchHint("since:2025-01", "After January 2025") - SearchHint("#bitcoin", "Hashtag search") - SearchHint("\"exact phrase\"", "Exact match") - SearchHint("bitcoin OR nostr", "Either term") - SearchHint("-spam", "Exclude term") - SearchHint("lang:en", "Language filter") + // 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") } } } From e8e2650fa9ef0a45750b452bbec838c083f646df Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 11 Mar 2026 07:21:17 +0200 Subject: [PATCH 07/14] refactor(search): extract shared code to commons, remove dead code - Extract DateUtils (isLeapYear, dateToUnix, timestampToDate) to deduplicate identical implementations in QueryParser and QuerySerializer - Move SavedSearch data class from desktopApp to commons/search - Replace local formatTimestamp with existing toTimeAgo from commons/util - Remove dead code: resolveAuthorName, _authorSuggestions (never called) - Remove dead code: createOrFilters (never wired into SearchScreen) - Remove unused ICacheProvider dependency from AdvancedSearchBarState Co-Authored-By: Claude Opus 4.6 --- .../commons/search/AdvancedSearchBarState.kt | 17 ----- .../amethyst/commons/search/DateUtils.kt | 71 +++++++++++++++++++ .../amethyst/commons/search/QueryParser.kt | 25 +------ .../commons/search/QuerySerializer.kt | 27 +------ .../amethyst/commons/search/SavedSearch.kt | 28 ++++++++ .../amethyst/desktop/SearchHistoryStore.kt | 8 +-- .../subscriptions/SearchFilterFactory.kt | 13 ---- .../amethyst/desktop/ui/SearchScreen.kt | 4 +- .../desktop/ui/search/SearchResultsList.kt | 32 +-------- 9 files changed, 106 insertions(+), 119 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/DateUtils.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SavedSearch.kt 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 index ba054a801..e46bcef97 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.commons.search import com.vitorpamplona.amethyst.commons.model.User -import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.Event import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -44,7 +43,6 @@ enum class ChangeSource { @OptIn(FlowPreview::class) class AdvancedSearchBarState( - private val cache: ICacheProvider, private val scope: CoroutineScope, private val debounceMs: Long = 300L, ) { @@ -82,10 +80,6 @@ class AdvancedSearchBarState( private val _isSearching = MutableStateFlow(false) val isSearching: StateFlow = _isSearching.asStateFlow() - // Author name suggestions for autocomplete - private val _authorSuggestions = MutableStateFlow>(persistentListOf()) - val authorSuggestions: StateFlow> = _authorSuggestions.asStateFlow() - // Expanded panel state private val _panelExpanded = MutableStateFlow(false) val panelExpanded: StateFlow = _panelExpanded.asStateFlow() @@ -214,15 +208,4 @@ class AdvancedSearchBarState( _noteResults.value = (current + newEvents).sortedByDescending { it.createdAt }.toImmutableList() } } - - // Name resolution for autocomplete - @Suppress("UNCHECKED_CAST") - fun resolveAuthorName(name: String) { - if (name.length < 2) { - _authorSuggestions.value = persistentListOf() - return - } - val results = cache.findUsersStartingWith(name, 5) as List - _authorSuggestions.value = results.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 000000000..e7b1d053b --- /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/QueryParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt index be1a93e34..073e9cc41 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParser.kt @@ -319,28 +319,5 @@ object QueryParser { year: Int, month: Int, day: Int, - ): Long { - // Simple UTC date to unix timestamp calculation - // Days from epoch (1970-01-01) to the given date - var totalDays = 0L - - // Add days for full years - for (y in 1970 until year) { - totalDays += if (isLeapYear(y)) 366 else 365 - } - - // Add days for full months in target year - 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] - } - - // Add remaining days - totalDays += (day - 1) - - return totalDays * 86400L - } - - private fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + ): 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 index dd7b0612a..3cd27a017 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/QuerySerializer.kt @@ -84,30 +84,5 @@ object QuerySerializer { return parts.joinToString(" ") } - fun timestampToDate(timestamp: Long): String { - // Convert unix timestamp to YYYY-MM-DD - 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')}" - } - - private fun isLeapYear(year: Int): Boolean = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + 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 000000000..20302fba7 --- /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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt index 8271c1e67..9c3e3b01a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/SearchHistoryStore.kt @@ -22,6 +22,7 @@ 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 @@ -134,10 +135,3 @@ object SearchHistoryStore { prefs.put(KEY_SAVED, raw) } } - -data class SavedSearch( - val id: String, - val label: String, - val query: SearchQuery, - val createdAt: Long, -) 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 index 7aa491f71..6a61b000d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SearchFilterFactory.kt @@ -128,19 +128,6 @@ object SearchFilterFactory { } } - fun createOrFilters( - query: SearchQuery, - limit: Int = 100, - ): List> { - if (query.orTerms.isEmpty()) return listOf(createFilters(query, limit)) - - // Each OR term gets its own set of filters - return query.orTerms.take(3).map { term -> - val termQuery = query.copy(text = term, orTerms = kotlinx.collections.immutable.persistentListOf()) - createFilters(termQuery, limit) - } - } - private fun buildSearchString(query: SearchQuery): String? { val parts = mutableListOf() 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 d7fd9a643..885b48843 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 @@ -111,7 +111,7 @@ fun SearchScreen( modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() - val state = remember { AdvancedSearchBarState(localCache, scope) } + val state = remember { AdvancedSearchBarState(scope) } val focusRequester = remember { FocusRequester() } // Pre-fill initial query @@ -408,7 +408,7 @@ fun SearchScreen( @Composable private fun SearchEmptyState( historyItems: List, - savedSearches: List, + savedSearches: List, onLoadQuery: (com.vitorpamplona.amethyst.commons.search.SearchQuery) -> Unit, onDeleteSaved: (String) -> Unit, onClearHistory: () -> Unit, 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 index e26d6a462..93a62e39e 100644 --- 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 @@ -62,6 +62,7 @@ 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.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -255,7 +256,7 @@ private fun NotePreviewCard( Spacer(Modifier.weight(1f)) // Timestamp Text( - formatTimestamp(event.createdAt), + event.createdAt.toTimeAgo(withDot = false), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -296,32 +297,3 @@ private fun ExpandableSection( } } } - -private fun formatTimestamp(ts: Long): String { - val now = System.currentTimeMillis() / 1000 - val diff = now - ts - return when { - diff < 60 -> { - "just now" - } - - diff < 3600 -> { - "${diff / 60}m ago" - } - - diff < 86400 -> { - "${diff / 3600}h ago" - } - - diff < 604800 -> { - "${diff / 86400}d ago" - } - - else -> { - // Simple date format - val days = ts / 86400 - val year = 1970 + (days / 365).toInt() - "$year" - } - } -} From a702dcc0ee12525d4f9e5ba7da41d46063ec8611 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 11 Mar 2026 07:28:04 +0200 Subject: [PATCH 08/14] =?UTF-8?q?fix(search):=20cleanup=20review=20finding?= =?UTF-8?q?s=20=E2=80=94=20dead=20code,=20FQN=20refs,=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `key` param from ExpandableSection - Replace FQN references with imports in AdvancedSearchPanel and SearchScreen - Use MetadataEvent.KIND instead of magic `0` - Remove dead `relayStatuses` collection (only `connectedRelays` used) - Remove CachedUserResult sealed variant (never constructed) - Rename SearchHint `identifier` param to `example` Co-Authored-By: Claude Opus 4.6 --- .../amethyst/commons/search/SearchResult.kt | 9 -------- .../amethyst/desktop/ui/SearchScreen.kt | 22 ++++++++----------- .../desktop/ui/search/AdvancedSearchPanel.kt | 14 +++++------- .../desktop/ui/search/SearchResultsList.kt | 4 ---- 4 files changed, 15 insertions(+), 34 deletions(-) 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 dc77eba39..21cd3a0a2 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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 885b48843..74323e8d4 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 @@ -81,6 +81,8 @@ import androidx.compose.ui.unit.dp 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.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.search.parseSearchInput @@ -121,7 +123,6 @@ fun SearchScreen( } } - val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() val displayText by state.displayText.collectAsState() val query by state.query.collectAsState() @@ -149,8 +150,7 @@ fun SearchScreen( relays = connectedRelays, searchQuery = debouncedQuery.text.ifBlank { - com.vitorpamplona.amethyst.commons.search.QuerySerializer - .serialize(debouncedQuery) + QuerySerializer.serialize(debouncedQuery) }, limit = 20, onEvent = { event, _, _, _ -> @@ -185,7 +185,7 @@ fun SearchScreen( relays = connectedRelays, onEvent = { event, _, _, _ -> // Skip metadata events (handled by people search) - if (event.kind == 0) return@SubscriptionConfig + if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) if (filtered.isNotEmpty()) { state.addNoteResults(filtered) @@ -407,9 +407,9 @@ fun SearchScreen( @Composable private fun SearchEmptyState( - historyItems: List, - savedSearches: List, - onLoadQuery: (com.vitorpamplona.amethyst.commons.search.SearchQuery) -> Unit, + historyItems: List, + savedSearches: List, + onLoadQuery: (SearchQuery) -> Unit, onDeleteSaved: (String) -> Unit, onClearHistory: () -> Unit, ) { @@ -542,7 +542,7 @@ private fun SearchEmptyState( @Composable private fun SearchHint( - identifier: String, + example: String, description: String, ) { Row( @@ -550,7 +550,7 @@ private fun SearchHint( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - identifier, + example, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.primary, @@ -578,7 +578,6 @@ private fun SearchResultCard( .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) @@ -598,7 +597,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 @@ -612,7 +610,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}" @@ -623,7 +620,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/search/AdvancedSearchPanel.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/AdvancedSearchPanel.kt index 33de9faec..ec1eeae19 100644 --- 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 @@ -56,7 +56,9 @@ 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.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) @@ -243,16 +245,14 @@ private fun DateRangeFields( var sinceText by remember(since) { mutableStateOf( since?.let { - com.vitorpamplona.amethyst.commons.search.QuerySerializer - .timestampToDate(it) + DateUtils.timestampToDate(it) } ?: "", ) } var untilText by remember(until) { mutableStateOf( until?.let { - com.vitorpamplona.amethyst.commons.search.QuerySerializer - .timestampToDate(it) + DateUtils.timestampToDate(it) } ?: "", ) } @@ -272,8 +272,7 @@ private fun DateRangeFields( onValueChange = { sinceText = it val ts = - com.vitorpamplona.amethyst.commons.search.QueryParser - .parseDateToTimestamp(it) + QueryParser.parseDateToTimestamp(it) onChanged(ts, until) }, modifier = Modifier.fillMaxWidth(), @@ -292,8 +291,7 @@ private fun DateRangeFields( onValueChange = { untilText = it val ts = - com.vitorpamplona.amethyst.commons.search.QueryParser - .parseDateToTimestamp(it) + QueryParser.parseDateToTimestamp(it) onChanged(since, ts) }, modifier = Modifier.fillMaxWidth(), 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 index 93a62e39e..f51d186ef 100644 --- 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 @@ -118,7 +118,6 @@ fun SearchResultsList( item(key = "people-expand") { ExpandableSection( remaining = people.drop(5), - key = { "person-more-${it.pubkeyHex}" }, ) { user -> UserSearchCard( user = user, @@ -145,7 +144,6 @@ fun SearchResultsList( item(key = "notes-expand") { ExpandableSection( remaining = textNotes.drop(5), - key = { "note-more-${it.id}" }, ) { event -> NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) } @@ -168,7 +166,6 @@ fun SearchResultsList( item(key = "articles-expand") { ExpandableSection( remaining = articles.drop(5), - key = { "article-more-${it.id}" }, ) { event -> NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) } @@ -277,7 +274,6 @@ private fun NotePreviewCard( @Composable private fun ExpandableSection( remaining: List, - key: (T) -> String, content: @Composable (T) -> Unit, ) { var expanded by remember { mutableStateOf(false) } From 979d07679cc0b7c0f87249e5b4c80d70499ba067 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 11 Mar 2026 09:29:27 +0200 Subject: [PATCH 09/14] fix(search): loading indicator, result delivery, and history race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace boolean isSearching with counter-based activeSubscriptionCount so loading state stays true until both subs (people + notes) complete - Add indeterminate LinearProgressIndicator overlaid on search bar bottom border with matching 12dp rounding, replacing "Searching relays..." text - Send search REQs to all configured relays (relayStatuses) instead of only connected ones — fixes NIP-50 relays never receiving search queries - Move clearResults/startSearching out of remember() into LaunchedEffect to avoid side effects during composition - Fix history saving partial queries by replacing LaunchedEffect with snapshotFlow to properly observe isSearching transitions Co-Authored-By: Claude Opus 4.6 --- .../commons/search/AdvancedSearchBarState.kt | 15 +- .../amethyst/desktop/ui/SearchScreen.kt | 131 ++++++++++-------- .../desktop/ui/search/SearchResultsList.kt | 11 -- 3 files changed, 86 insertions(+), 71 deletions(-) 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 index e46bcef97..6a6e23fa1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -33,7 +33,9 @@ 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, @@ -77,8 +79,11 @@ class AdvancedSearchBarState( private val _noteResults = MutableStateFlow>(persistentListOf()) val noteResults: StateFlow> = _noteResults.asStateFlow() - private val _isSearching = MutableStateFlow(false) - val isSearching: StateFlow = _isSearching.asStateFlow() + private val activeSubscriptionCount = MutableStateFlow(0) + val isSearching: StateFlow = + activeSubscriptionCount + .map { it > 0 } + .stateIn(scope, SharingStarted.Eagerly, false) // Expanded panel state private val _panelExpanded = MutableStateFlow(false) @@ -176,16 +181,16 @@ class AdvancedSearchBarState( _query.value = SearchQuery.EMPTY _peopleResults.value = persistentListOf() _noteResults.value = persistentListOf() - _isSearching.value = false + activeSubscriptionCount.value = 0 } // Results management (called from subscription callbacks) fun startSearching() { - _isSearching.value = true + activeSubscriptionCount.update { it + 1 } } fun stopSearching() { - _isSearching.value = false + activeSubscriptionCount.update { maxOf(0, it - 1) } } fun clearResults() { 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 74323e8d4..2d87dad25 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 @@ -27,6 +27,7 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -55,6 +56,7 @@ 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 @@ -65,8 +67,10 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key @@ -124,6 +128,8 @@ fun SearchScreen( } val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val allRelayUrls = remember(relayStatuses) { relayStatuses.keys } val displayText by state.displayText.collectAsState() val query by state.query.collectAsState() val debouncedQuery by state.debouncedQuery.collectAsState() @@ -135,19 +141,24 @@ fun SearchScreen( // Bech32 parsing (immediate, no debounce) val bech32Results = remember(displayText) { parseSearchInput(displayText) } - // NIP-50 people search subscription + // Clear results and start loading when query changes + LaunchedEffect(debouncedQuery) { + if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) { + state.clearResults() + state.startSearching() + state.startSearching() + } + } + + // NIP-50 people search subscription (use allRelayUrls — openReqSubscription will connect) rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { - if (connectedRelays.isEmpty() || debouncedQuery.isEmpty) { + if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { return@rememberSubscription null } - // Skip if bech32 detected if (bech32Results.isNotEmpty()) return@rememberSubscription null - state.startSearching() - state.clearResults() - createSearchPeopleSubscription( - relays = connectedRelays, + relays = allRelayUrls, searchQuery = debouncedQuery.text.ifBlank { QuerySerializer.serialize(debouncedQuery) @@ -169,9 +180,9 @@ fun SearchScreen( ) } - // NIP-50 advanced note search subscription (kinds beyond people) + // NIP-50 advanced note search subscription (use allRelayUrls) rememberSubscription(connectedRelays, debouncedQuery, relayManager = relayManager) { - if (connectedRelays.isEmpty() || debouncedQuery.isEmpty) { + if (allRelayUrls.isEmpty() || debouncedQuery.isEmpty) { return@rememberSubscription null } if (bech32Results.isNotEmpty()) return@rememberSubscription null @@ -182,9 +193,8 @@ fun SearchScreen( SubscriptionConfig( subId = generateSubId("adv-search"), filters = filters, - relays = connectedRelays, + relays = allRelayUrls, onEvent = { event, _, _, _ -> - // Skip metadata events (handled by people search) if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) if (filtered.isNotEmpty()) { @@ -218,11 +228,14 @@ fun SearchScreen( } } - // Save to history when search completes - LaunchedEffect(isSearching, debouncedQuery) { - if (!isSearching && !debouncedQuery.isEmpty) { - SearchHistoryStore.addToHistory(debouncedQuery) - } + // 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 @@ -281,39 +294,53 @@ fun SearchScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedTextField( - value = - TextFieldValue( - text = displayText, - selection = TextRange(displayText.length), - ), - onValueChange = { 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, - ) + Box(modifier = Modifier.weight(1f)) { + OutlinedTextField( + value = + TextFieldValue( + text = displayText, + selection = TextRange(displayText.length), + ), + onValueChange = { state.updateFromText(it.text) }, + modifier = Modifier.fillMaxWidth().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), + ) + androidx.compose.animation.AnimatedVisibility( + visible = isSearching, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 1.dp) + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } IconButton(onClick = { state.togglePanel() }) { Icon( Icons.Default.Tune, @@ -386,13 +413,7 @@ fun SearchScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) - } else if (isSearching) { - Text( - "Searching relays...", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - } else { + } else if (!isSearching) { // Empty state: show history + saved searches + operator hints SearchEmptyState( historyItems = historyItems, 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 index f51d186ef..b5bce1111 100644 --- 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 @@ -76,20 +76,9 @@ fun SearchResultsList( ) { val people by state.peopleResults.collectAsState() val notes by state.noteResults.collectAsState() - val isSearching by state.isSearching.collectAsState() val hasResults = people.isNotEmpty() || notes.isNotEmpty() - if (!hasResults && isSearching) { - Text( - "Searching relays...", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - modifier = modifier.padding(vertical = 8.dp), - ) - return - } - if (!hasResults) return // Group notes by kind From 3c6c368582a45b8e722eac8667fe7113ef9c2cef Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 11 Mar 2026 11:36:39 +0200 Subject: [PATCH 10/14] refactor(search): extract thread-safe EventDeduplicator, unify dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw MutableSet with synchronized EventDeduplicator class. Remove dual dedup in addNoteResults — trackRelayEvent now gates all event processing. SearchScreen callbacks skip dupes early via return value. Co-Authored-By: Claude Opus 4.6 --- .../commons/search/AdvancedSearchBarState.kt | 91 +++++++-- .../commons/search/EventDeduplicator.kt | 34 ++++ .../amethyst/desktop/network/RelayStatus.kt | 1 + .../desktop/subscriptions/FeedSubscription.kt | 2 + .../subscriptions/SubscriptionUtils.kt | 9 + .../amethyst/desktop/ui/SearchScreen.kt | 152 ++++++++------- .../desktop/ui/search/SearchSyncBanner.kt | 179 ++++++++++++++++++ 7 files changed, 390 insertions(+), 78 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/EventDeduplicator.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchSyncBanner.kt 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 index 6a6e23fa1..5a375ebc9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -20,8 +20,12 @@ */ 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 @@ -79,16 +83,22 @@ class AdvancedSearchBarState( private val _noteResults = MutableStateFlow>(persistentListOf()) val noteResults: StateFlow> = _noteResults.asStateFlow() - private val activeSubscriptionCount = MutableStateFlow(0) + private val activeSubIds = MutableStateFlow>(emptySet()) val isSearching: StateFlow = - activeSubscriptionCount - .map { it > 0 } + 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 @@ -171,6 +181,49 @@ class AdvancedSearchBarState( _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 } @@ -181,21 +234,35 @@ class AdvancedSearchBarState( _query.value = SearchQuery.EMPTY _peopleResults.value = persistentListOf() _noteResults.value = persistentListOf() - activeSubscriptionCount.value = 0 + _relayStates.value = persistentListOf() + activeSubIds.value = emptySet() + eventDeduplicator.clear() } // Results management (called from subscription callbacks) - fun startSearching() { - activeSubscriptionCount.update { it + 1 } + fun startSearching(subId: String) { + activeSubIds.update { it + subId } } - fun stopSearching() { - activeSubscriptionCount.update { maxOf(0, it - 1) } + 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) { @@ -206,11 +273,9 @@ class AdvancedSearchBarState( } fun addNoteResults(events: List) { - val current = _noteResults.value - val existingIds = current.map { it.id }.toSet() - val newEvents = events.filter { it.id !in existingIds } - if (newEvents.isNotEmpty()) { - _noteResults.value = (current + newEvents).sortedByDescending { it.createdAt }.toImmutableList() + if (events.isNotEmpty()) { + val current = _noteResults.value + _noteResults.value = (current + events).sortedByDescending { it.createdAt }.toImmutableList() } } } 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 000000000..8a843955a --- /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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt index 24c092b90..32f69dac9 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,5 +45,6 @@ object DefaultRelays { "wss://nos.lol", "wss://relay.snort.social", "wss://nostr.wine", + "wss://relay.noswhere.com", ) } 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 d4d3bac20..a7d4a633a 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/SubscriptionUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt index 3ecac1b05..ab0238304 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 2d87dad25..ba29094af 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 @@ -27,7 +27,6 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -70,7 +69,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.key.Key @@ -82,6 +80,7 @@ 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 @@ -102,6 +101,7 @@ 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 @@ -137,6 +137,7 @@ fun SearchScreen( val isSearching by state.isSearching.collectAsState() val peopleResults by state.peopleResults.collectAsState() val noteResults by state.noteResults.collectAsState() + val relayStates by state.relayStates.collectAsState() // Bech32 parsing (immediate, no debounce) val bech32Results = remember(displayText) { parseSearchInput(displayText) } @@ -145,8 +146,12 @@ fun SearchScreen( LaunchedEffect(debouncedQuery) { if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) { state.clearResults() - state.startSearching() - state.startSearching() + state.initRelayStates(allRelayUrls) + 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() } } @@ -164,18 +169,25 @@ fun SearchScreen( QuerySerializer.serialize(debouncedQuery) }, limit = 20, - onEvent = { event, _, _, _ -> - if (event is MetadataEvent) { - localCache.consumeMetadata(event) - @Suppress("UNCHECKED_CAST") - val user = localCache.getUserIfExists(event.pubKey) as? User - if (user != null) { - state.addPeopleResult(user) + 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 = { _, _ -> - state.stopSearching() + 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") }, ) } @@ -194,15 +206,22 @@ fun SearchScreen( subId = generateSubId("adv-search"), filters = filters, relays = allRelayUrls, - onEvent = { event, _, _, _ -> + onEvent = { event, _, relay, _ -> if (event.kind == MetadataEvent.KIND) return@SubscriptionConfig - val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) - if (filtered.isNotEmpty()) { - state.addNoteResults(filtered) + if (state.trackRelayEvent(relay.url, event.id)) { + val filtered = SearchResultFilter.filter(listOf(event), debouncedQuery) + if (filtered.isNotEmpty()) { + state.addNoteResults(filtered) + } } }, - onEose = { _, _ -> - state.stopSearching() + 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") }, ) } @@ -269,6 +288,26 @@ fun SearchScreen( } }, ) { + // 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, @@ -294,53 +333,36 @@ fun SearchScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Box(modifier = Modifier.weight(1f)) { - OutlinedTextField( - value = - TextFieldValue( - text = displayText, - selection = TextRange(displayText.length), - ), - onValueChange = { state.updateFromText(it.text) }, - modifier = Modifier.fillMaxWidth().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), - ) - androidx.compose.animation.AnimatedVisibility( - visible = isSearching, - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(horizontal = 1.dp) - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), - ) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, + OutlinedTextField( + value = + TextFieldValue( + text = displayText, + selection = TextRange(displayText.length), + ), + onValueChange = { 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), + ) IconButton(onClick = { state.togglePanel() }) { Icon( Icons.Default.Tune, 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 000000000..348dcaf0f --- /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 + } From f0ffe3cbe39d106938555feef238e84f77d452fb Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 11 Mar 2026 11:36:46 +0200 Subject: [PATCH 11/14] fix(ui): remove duplicate back button from SinglePaneLayout Overlay screens (UserProfileScreen, ThreadScreen) already render their own back+title header. Remove the redundant SinglePaneHeader bar. Co-Authored-By: Claude Opus 4.6 --- .../desktop/ui/deck/SinglePaneLayout.kt | 56 ------------------- 1 file changed, 56 deletions(-) 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 dd8276699..e35e7c3fc 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,15 @@ */ 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,9 +38,7 @@ 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 @@ -57,7 +50,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 @@ -144,20 +136,6 @@ 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), ) { @@ -197,37 +175,3 @@ fun SinglePaneLayout( } } } - -@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, - ) - } -} From 5a437ed5ac22999c56e10ff2d61a9d8e5989acda Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 12 Mar 2026 13:23:27 +0200 Subject: [PATCH 12/14] feat(search): collapsible section headers + sorting + search improvements - Collapsible sticky section headers with animated chevron - Full header row clickable to toggle collapse - SearchResultSorter, SearchSortOrder, pseudo-kind filtering - TextFieldValue cursor preservation, relay timeout improvements Co-Authored-By: Claude Opus 4.6 --- .../settings/ReactionsSettingsScreen.kt | 76 +- .../commons/search/AdvancedSearchBarState.kt | 35 +- .../amethyst/commons/search/KindRegistry.kt | 30 +- .../commons/search/SearchResultSorter.kt | 117 +++ .../commons/search/SearchSortOrder.kt | 39 + .../commons/search/SearchResultSorterTest.kt | 197 ++++ .../amethyst/desktop/ui/SearchScreen.kt | 35 +- .../desktop/ui/search/AdvancedSearchPanel.kt | 78 +- .../desktop/ui/search/SearchResultsList.kt | 187 +++- .../2026-03-10-advanced-search-brainstorm.md | 230 +++++ ...03-10-feat-desktop-advanced-search-plan.md | 962 ++++++++++++++++++ 11 files changed, 1849 insertions(+), 137 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchSortOrder.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt create mode 100644 docs/brainstorms/2026-03-10-advanced-search-brainstorm.md create mode 100644 docs/plans/2026-03-10-feat-desktop-advanced-search-plan.md diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt index e995b1377..15b5e583c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt @@ -21,10 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -33,9 +31,10 @@ 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.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DragIndicator +import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -57,32 +56,17 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ReactionRowAction import com.vitorpamplona.amethyst.model.ReactionRowItem -import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow - -@Composable -@Preview(device = "spec:width=2100px,height=2340px,dpi=440") -fun ReactionsSettingsScreenPreview() { - ThemeComparisonRow { - ReactionsSettingsScreen( - mockAccountViewModel(), - EmptyNav(), - ) - } -} @Composable fun ReactionsSettingsScreen( @@ -103,7 +87,7 @@ fun ReactionsSettingsScreen( @Composable fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { val reactionRowItems by accountViewModel.reactionRowItemsFlow().collectAsStateWithLifecycle() - var items by remember(reactionRowItems) { mutableStateOf(reactionRowItems.toList()) } + var items by remember(reactionRowItems) { mutableStateOf(reactionRowItems.toMutableList()) } fun save(newItems: List) { items = newItems.toMutableList() @@ -113,14 +97,13 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { var draggedItemIndex by remember { mutableIntStateOf(-1) } var dragOffset by remember { mutableFloatStateOf(0f) } val itemHeights = remember { mutableStateMapOf() } - val isDragging = draggedItemIndex >= 0 - val scrollState = remember { ScrollState(0) } Column( modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState, enabled = !isDragging), + .padding(horizontal = Size20dp) + .verticalScroll(rememberScrollState()), ) { Spacer(modifier = Modifier.height(16.dp)) @@ -128,7 +111,7 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { text = stringRes(R.string.reactions_settings_description), style = MaterialTheme.typography.bodyMedium, color = Color.Gray, - modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp), + modifier = Modifier.padding(bottom = 16.dp), ) items.forEachIndexed { index, item -> @@ -223,7 +206,7 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { .zIndex(if (isDragging) 1f else 0f), ) if (index < items.lastIndex) { - HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) + HorizontalDivider() } } @@ -262,18 +245,7 @@ private fun ReactionRowItemCard( scaleX = 1.02f scaleY = 1.02f } - }.padding(vertical = 8.dp, horizontal = Size20dp) - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { onDragStart() }, - onDrag = { change, dragAmount -> - change.consume() - onDrag(dragAmount.y) - }, - onDragEnd = { onDragEnd() }, - onDragCancel = { onDragCancel() }, - ) - }, + }.padding(vertical = 8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -296,17 +268,25 @@ private fun ReactionRowItemCard( ) } - Box( - modifier = Modifier.size(32.dp), - contentAlignment = Alignment.CenterEnd, - ) { - Icon( - Icons.Default.DragIndicator, - contentDescription = stringRes(R.string.reactions_settings_reorder), - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Icon( + Icons.Default.DragHandle, + contentDescription = stringRes(R.string.reactions_settings_reorder), + modifier = + Modifier + .size(32.dp) + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) } Spacer(modifier = Modifier.height(8.dp)) 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 index 5a375ebc9..88702439c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/AdvancedSearchBarState.kt @@ -83,6 +83,24 @@ class AdvancedSearchBarState( 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 @@ -112,6 +130,11 @@ class AdvancedSearchBarState( _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 @@ -228,6 +251,14 @@ class AdvancedSearchBarState( _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 = "" @@ -235,6 +266,8 @@ class AdvancedSearchBarState( _peopleResults.value = persistentListOf() _noteResults.value = persistentListOf() _relayStates.value = persistentListOf() + _eventSortOrder.value = SearchSortOrder.DEFAULT_EVENT + _peopleSortOrder.value = SearchSortOrder.DEFAULT_PEOPLE activeSubIds.value = emptySet() eventDeduplicator.clear() } @@ -275,7 +308,7 @@ class AdvancedSearchBarState( fun addNoteResults(events: List) { if (events.isNotEmpty()) { val current = _noteResults.value - _noteResults.value = (current + events).sortedByDescending { it.createdAt }.toImmutableList() + _noteResults.value = (current + events).toImmutableList() } } } 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 index 2b3e921b7..e914be294 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/KindRegistry.kt @@ -32,6 +32,22 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti 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( @@ -49,14 +65,14 @@ object KindRegistry { val pseudoKinds: Set = setOf("reply", "media") - val presets: Map> = + val presets: Map = mapOf( - "Notes" to listOf(TextNoteEvent.KIND), - "Articles" to listOf(LongTextNoteEvent.KIND), - "Media" to listOf(TextNoteEvent.KIND), - "Channels" to listOf(ChannelCreateEvent.KIND, ChannelMetadataEvent.KIND), - "Communities" to listOf(CommunityDefinitionEvent.KIND), - "Wiki" to listOf(WikiNoteEvent.KIND), + "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()] 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 000000000..bc1ea8177 --- /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 000000000..1b4350787 --- /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/SearchResultSorterTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorterTest.kt new file mode 100644 index 000000000..bdf3b3cf6 --- /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/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index ba29094af..da405b6c4 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 @@ -64,8 +64,10 @@ 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 @@ -131,6 +133,14 @@ fun SearchScreen( 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() @@ -142,12 +152,19 @@ fun SearchScreen( // Bech32 parsing (immediate, no debounce) val bech32Results = remember(displayText) { parseSearchInput(displayText) } + // 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) + // Clear results and start loading when query changes LaunchedEffect(debouncedQuery) { if (!debouncedQuery.isEmpty && bech32Results.isEmpty()) { state.clearResults() state.initRelayStates(allRelayUrls) - state.startSearching("people-search") + 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) @@ -161,6 +178,10 @@ fun SearchScreen( return@rememberSubscription null } if (bech32Results.isNotEmpty()) return@rememberSubscription null + if (!shouldSearchPeople) { + state.stopSearching("people-search") + return@rememberSubscription null + } createSearchPeopleSubscription( relays = allRelayUrls, @@ -334,12 +355,11 @@ fun SearchScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { OutlinedTextField( - value = - TextFieldValue( - text = displayText, - selection = TextRange(displayText.length), - ), - onValueChange = { state.updateFromText(it.text) }, + 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 = { @@ -386,6 +406,7 @@ fun SearchScreen( 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) }, 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 index ec1eeae19..4b18918c4 100644 --- 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 @@ -56,6 +56,7 @@ 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 @@ -66,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.search.SearchQuery fun AdvancedSearchPanel( query: SearchQuery, onKindsChanged: (List) -> Unit, + onPseudoKindsChanged: (List) -> Unit, onAuthorAdded: (String) -> Unit, onAuthorRemoved: (String) -> Unit, onDateRangeChanged: (Long?, Long?) -> Unit, @@ -99,10 +101,10 @@ fun AdvancedSearchPanel( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - KindRegistry.presets.forEach { (name, kinds) -> + KindRegistry.presets.forEach { (name, preset) -> FilterChip( - selected = query.kinds.toList().containsAll(kinds), - onClick = { onKindsChanged(toggleKinds(query.kinds.toList(), kinds)) }, + selected = preset.isSelected(query.kinds.toList(), query.pseudoKinds.toList()), + onClick = { togglePreset(preset, query, onKindsChanged, onPseudoKindsChanged) }, label = { Text(name) }, ) } @@ -161,15 +163,29 @@ fun AdvancedSearchPanel( } } -private fun toggleKinds( - current: List, - toggle: List, -): List = - if (current.containsAll(toggle)) { - current - toggle.toSet() +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 { - (current + toggle).distinct() + 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 @@ -242,19 +258,21 @@ private fun DateRangeFields( until: Long?, onChanged: (Long?, Long?) -> Unit, ) { - var sinceText by remember(since) { - mutableStateOf( - since?.let { - DateUtils.timestampToDate(it) - } ?: "", - ) + // 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(until) { - mutableStateOf( - until?.let { - DateUtils.timestampToDate(it) - } ?: "", - ) + + 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( @@ -271,9 +289,11 @@ private fun DateRangeFields( value = sinceText, onValueChange = { sinceText = it - val ts = - QueryParser.parseDateToTimestamp(it) - onChanged(ts, until) + val ts = QueryParser.parseDateToTimestamp(it) + if (ts != null || it.isBlank()) { + lastSince = ts + onChanged(ts, until) + } }, modifier = Modifier.fillMaxWidth(), placeholder = { Text("YYYY-MM-DD") }, @@ -290,9 +310,11 @@ private fun DateRangeFields( value = untilText, onValueChange = { untilText = it - val ts = - QueryParser.parseDateToTimestamp(it) - onChanged(since, ts) + val ts = QueryParser.parseDateToTimestamp(it) + if (ts != null || it.isBlank()) { + lastUntil = ts + onChanged(since, ts) + } }, modifier = Modifier.fillMaxWidth(), placeholder = { Text("YYYY-MM-DD") }, 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 index b5bce1111..bb43400f9 100644 --- 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 @@ -20,6 +20,7 @@ */ 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 @@ -41,6 +42,8 @@ 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 @@ -50,17 +53,20 @@ 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 @@ -74,8 +80,10 @@ fun SearchResultsList( modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), ) { - val people by state.peopleResults.collectAsState() - val notes by state.noteResults.collectAsState() + 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() @@ -86,6 +94,9 @@ fun SearchResultsList( 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), @@ -93,25 +104,37 @@ fun SearchResultsList( ) { // People section if (people.isNotEmpty()) { + val collapsed = collapsedSections["people"] == true stickyHeader(key = "header-people") { - SectionHeader("People", people.size, Icons.Default.Person) - } - val displayPeople = people.take(5) - items(displayPeople, key = { "person-${it.pubkeyHex}" }) { user -> - UserSearchCard( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, + 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 (people.size > 5) { - item(key = "people-expand") { - ExpandableSection( - remaining = people.drop(5), - ) { user -> - UserSearchCard( - user = user, - onClick = { onNavigateToProfile(user.pubkeyHex) }, - ) + 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) }, + ) + } } } } @@ -122,19 +145,31 @@ fun SearchResultsList( if (people.isNotEmpty()) { item(key = "divider-notes") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } } + val collapsed = collapsedSections["notes"] == true stickyHeader(key = "header-notes") { - SectionHeader("Notes", textNotes.size, Icons.Default.Description) + 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 }, + ) } - 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) }) + 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) }) + } } } } @@ -145,18 +180,30 @@ fun SearchResultsList( if (people.isNotEmpty() || textNotes.isNotEmpty()) { item(key = "divider-articles") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } } + val collapsed = collapsedSections["articles"] == true stickyHeader(key = "header-articles") { - SectionHeader("Articles", articles.size, Icons.Default.Article) + 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 }, + ) } - 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) }) + 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) }) + } } } } @@ -165,11 +212,23 @@ fun SearchResultsList( // Other section if (otherNotes.isNotEmpty()) { item(key = "divider-other") { HorizontalDivider(Modifier.padding(vertical = 4.dp)) } + val collapsed = collapsedSections["other"] == true stickyHeader(key = "header-other") { - SectionHeader("Other", otherNotes.size, Icons.Default.Forum) + 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 }, + ) } - items(otherNotes.take(5), key = { "other-${it.id}" }) { event -> - NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) + if (!collapsed) { + items(otherNotes.take(5), key = { "other-${it.id}" }) { event -> + NotePreviewCard(event = event, onClick = { onNavigateToThread(event.id) }) + } } } @@ -179,31 +238,67 @@ fun SearchResultsList( } @Composable -private fun SectionHeader( +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, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(vertical = 8.dp), + 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.size(18.dp), + 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), + ) + } + } + } } } } 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 000000000..609a0fdfb --- /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 000000000..244213eba --- /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. From 327bb1efc855a38b85d5d583e97e3c01224733f6 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 12 Mar 2026 13:32:05 +0200 Subject: [PATCH 13/14] fix(nav): preserve screen state across overlay navigation RootContent now stays composed when overlays are shown, preventing search results and other state from being destroyed on navigation. Overlay wrapped in opaque Surface to cover root content visually. Co-Authored-By: Claude Opus 4.6 --- .../desktop/ui/deck/DeckColumnContainer.kt | 67 ++++++++++--------- .../desktop/ui/deck/SinglePaneLayout.kt | 66 +++++++++--------- 2 files changed, 73 insertions(+), 60 deletions(-) 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 ce90b7ef6..b59abab0f 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 66e11d8d0..a00dc029a 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 @@ -43,6 +43,7 @@ import androidx.compose.material3.Icon 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 @@ -152,37 +153,42 @@ fun SinglePaneLayout( 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() }, + ) + } } } } From e4b4606ab90033ed9aa36f89469177acd860d512 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 16 Mar 2026 07:35:09 +0200 Subject: [PATCH 14/14] fix: revert ReactionsSettingsScreen to upstream version Spotless reformatted this file during merge. Restoring upstream's version to avoid unintended changes to the mobile app. Co-Authored-By: Claude Opus 4.6 --- .../settings/ReactionsSettingsScreen.kt | 76 ++++++++++++------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt index 15b5e583c..e995b1377 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -31,10 +33,9 @@ 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.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.DragIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -56,17 +57,32 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ReactionRowAction import com.vitorpamplona.amethyst.model.ReactionRowItem +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow + +@Composable +@Preview(device = "spec:width=2100px,height=2340px,dpi=440") +fun ReactionsSettingsScreenPreview() { + ThemeComparisonRow { + ReactionsSettingsScreen( + mockAccountViewModel(), + EmptyNav(), + ) + } +} @Composable fun ReactionsSettingsScreen( @@ -87,7 +103,7 @@ fun ReactionsSettingsScreen( @Composable fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { val reactionRowItems by accountViewModel.reactionRowItemsFlow().collectAsStateWithLifecycle() - var items by remember(reactionRowItems) { mutableStateOf(reactionRowItems.toMutableList()) } + var items by remember(reactionRowItems) { mutableStateOf(reactionRowItems.toList()) } fun save(newItems: List) { items = newItems.toMutableList() @@ -97,13 +113,14 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { var draggedItemIndex by remember { mutableIntStateOf(-1) } var dragOffset by remember { mutableFloatStateOf(0f) } val itemHeights = remember { mutableStateMapOf() } + val isDragging = draggedItemIndex >= 0 + val scrollState = remember { ScrollState(0) } Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = Size20dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(scrollState, enabled = !isDragging), ) { Spacer(modifier = Modifier.height(16.dp)) @@ -111,7 +128,7 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { text = stringRes(R.string.reactions_settings_description), style = MaterialTheme.typography.bodyMedium, color = Color.Gray, - modifier = Modifier.padding(bottom = 16.dp), + modifier = Modifier.padding(bottom = 16.dp, start = Size20dp, end = Size20dp), ) items.forEachIndexed { index, item -> @@ -206,7 +223,7 @@ fun ReactionsSettingsContent(accountViewModel: AccountViewModel) { .zIndex(if (isDragging) 1f else 0f), ) if (index < items.lastIndex) { - HorizontalDivider() + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) } } @@ -245,7 +262,18 @@ private fun ReactionRowItemCard( scaleX = 1.02f scaleY = 1.02f } - }.padding(vertical = 8.dp), + }.padding(vertical = 8.dp, horizontal = Size20dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() }, + ) + }, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -268,25 +296,17 @@ private fun ReactionRowItemCard( ) } - Icon( - Icons.Default.DragHandle, - contentDescription = stringRes(R.string.reactions_settings_reorder), - modifier = - Modifier - .size(32.dp) - .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = { onDragStart() }, - onDrag = { change, dragAmount -> - change.consume() - onDrag(dragAmount.y) - }, - onDragEnd = { onDragEnd() }, - onDragCancel = { onDragCancel() }, - ) - }, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box( + modifier = Modifier.size(32.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + Icons.Default.DragIndicator, + contentDescription = stringRes(R.string.reactions_settings_reorder), + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Spacer(modifier = Modifier.height(8.dp))