From ae76b86ab6d3856e2242d9264643a7210b034125 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 11:42:13 +0200 Subject: [PATCH 001/130] =?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 002/130] 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 003/130] 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 004/130] 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 005/130] 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 006/130] 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 007/130] 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 008/130] =?UTF-8?q?fix(search):=20cleanup=20review=20findi?= =?UTF-8?q?ngs=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 009/130] 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 010/130] 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 011/130] 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 012/130] 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 013/130] 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 da26ab14ab2b65c7842bde86f23f1d61d1be10d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 03:15:21 +0000 Subject: [PATCH 014/130] feat: complete NIP-47 Nostr Wallet Connect implementation in Quartz Implements the full NIP-47 specification with all request methods, response types, notification events, and JSON serialization. New request methods: - pay_invoice (updated with amount, metadata params) - pay_keysend (amount, pubkey, preimage, TLV records) - make_invoice, lookup_invoice, list_transactions - get_balance, get_info - make_hold_invoice, cancel_hold_invoice, settle_hold_invoice New response types: - Success responses for all methods above - NwcErrorResponse (generic error for any method) - NwcErrorCode enum with UNSUPPORTED_ENCRYPTION New event kinds: - NwcInfoEvent (kind 13194) - wallet service capabilities - NwcNotificationEvent (kind 23197) - NIP-44 encrypted notifications - Legacy kind 23196 support for NIP-04 notifications New data models: - NwcTransaction (shared invoice/payment object) - TlvRecord (for keysend TLV records) - Notification types: payment_received, payment_sent, hold_invoice_accepted - HoldInvoiceAcceptedData with settle_deadline Tags (following NIP-88 pattern): - EncryptionTag for encryption scheme negotiation - NotificationsTag for supported notification types Updated: - LnZapPaymentRequestEvent: generic createRequest() with NIP-44 support - URI parser: lud16 parameter support - Jackson deserializers: all methods routed correctly - EventFactory: new kinds registered https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../LnZapPaymentRequestEvent.kt | 38 +++- .../nip47WalletConnect/Nip47WalletConnect.kt | 11 +- .../quartz/nip47WalletConnect/Notification.kt | 59 +++++++ .../quartz/nip47WalletConnect/NwcErrorCode.kt | 39 +++++ .../quartz/nip47WalletConnect/NwcInfoEvent.kt | 69 ++++++++ .../quartz/nip47WalletConnect/NwcMethod.kt | 34 ++++ .../NwcNotificationEvent.kt | 58 +++++++ .../nip47WalletConnect/NwcTransaction.kt | 42 +++++ .../quartz/nip47WalletConnect/Request.kt | 163 +++++++++++++++++- .../quartz/nip47WalletConnect/Response.kt | 112 ++++++++---- .../nip47WalletConnect/tags/EncryptionTag.kt | 41 +++++ .../tags/NotificationsTag.kt | 41 +++++ .../quartz/utils/EventFactory.kt | 5 + .../quartz/nip01Core/jackson/JacksonMapper.kt | 3 + .../jackson/NotificationDeserializer.kt | 49 ++++++ .../jackson/RequestDeserializer.kt | 25 ++- .../jackson/ResponseDeserializer.kt | 60 +++++-- 17 files changed, 784 insertions(+), 65 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt index 2a766ef0b..452c99b2f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEvent.kt @@ -52,25 +52,47 @@ class LnZapPaymentRequestEvent( return OptimizedJsonMapper.fromJsonTo(jsonText) } + fun encryptionScheme() = tags.firstOrNull { it.size > 1 && it[0] == "encryption" }?.get(1) + companion object { const val KIND = 23194 - const val ALT = "Zap payment request" + const val ALT = "NWC request" suspend fun create( lnInvoice: String, walletServicePubkey: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): LnZapPaymentRequestEvent { - val serializedRequest = OptimizedJsonMapper.toJson(PayInvoiceMethod.create(lnInvoice)) + ): LnZapPaymentRequestEvent = + createRequest( + PayInvoiceMethod.create(lnInvoice), + walletServicePubkey, + signer, + createdAt, + ) - val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT)) + suspend fun createRequest( + request: Request, + walletServicePubkey: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + useNip44: Boolean = false, + ): LnZapPaymentRequestEvent { + val serializedRequest = OptimizedJsonMapper.toJson(request) + + val tags = + if (useNip44) { + arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT), arrayOf("encryption", "nip44_v2")) + } else { + arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT)) + } val encrypted = - signer.nip04Encrypt( - serializedRequest, - walletServicePubkey, - ) + if (useNip44) { + signer.nip44Encrypt(serializedRequest, walletServicePubkey) + } else { + signer.nip04Encrypt(serializedRequest, walletServicePubkey) + } return signer.sign(createdAt, KIND, tags, encrypted) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt index e3694b02a..53eb83d7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnect.kt @@ -30,11 +30,10 @@ import com.vitorpamplona.quartz.utils.UriParser import kotlinx.coroutines.CancellationException import kotlinx.serialization.Serializable -// Rename to the corect nip number when ready. class Nip47WalletConnect { companion object { fun parse(uri: String): Nip47URINorm { - // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D + // nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=...&lud16=user@example.com val url = UriParser(uri) @@ -55,8 +54,9 @@ class Nip47WalletConnect { val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null") val relayNorm = RelayUrlNormalizer.normalizeOrNull(relay) ?: throw IllegalArgumentException("Invalid relay Url") val secret = url.getQueryParameter("secret") + val lud16 = url.getQueryParameter("lud16") - return Nip47URINorm(pubkeyHex, relayNorm, secret) + return Nip47URINorm(pubkeyHex, relayNorm, secret, lud16) } } @@ -65,6 +65,7 @@ class Nip47WalletConnect { val pubKeyHex: HexKey, val relayUri: String, val secret: HexKey?, + val lud16: String? = null, ) { fun normalize(): Nip47URINorm? = RelayUrlNormalizer.normalizeOrNull(relayUri)?.let { @@ -72,6 +73,7 @@ class Nip47WalletConnect { pubKeyHex, it, secret, + lud16, ) } @@ -86,7 +88,8 @@ class Nip47WalletConnect { val pubKeyHex: HexKey, val relayUri: NormalizedRelayUrl, val secret: HexKey?, + val lud16: String? = null, ) { - fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret) + fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret, lud16) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt new file mode 100644 index 000000000..2c1bbdfc3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Notification.kt @@ -0,0 +1,59 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable + +object NwcNotificationType { + const val PAYMENT_RECEIVED = "payment_received" + const val PAYMENT_SENT = "payment_sent" + const val HOLD_INVOICE_ACCEPTED = "hold_invoice_accepted" +} + +// NOTIFICATION OBJECTS +abstract class Notification( + val notification_type: String, +) : OptimizedSerializable + +// payment_received notification +class PaymentReceivedNotification( + val notification: NwcTransaction? = null, +) : Notification(NwcNotificationType.PAYMENT_RECEIVED) + +// payment_sent notification +class PaymentSentNotification( + val notification: NwcTransaction? = null, +) : Notification(NwcNotificationType.PAYMENT_SENT) + +// hold_invoice_accepted notification +class HoldInvoiceAcceptedNotification( + val notification: HoldInvoiceAcceptedData? = null, +) : Notification(NwcNotificationType.HOLD_INVOICE_ACCEPTED) + +class HoldInvoiceAcceptedData( + var type: String? = null, + var invoice: String? = null, + var payment_hash: String? = null, + var amount: Long? = null, + var created_at: Long? = null, + var expires_at: Long? = null, + var settle_deadline: Long? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt new file mode 100644 index 000000000..f88fd1892 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.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.quartz.nip47WalletConnect + +enum class NwcErrorCode { + RATE_LIMITED, + NOT_IMPLEMENTED, + INSUFFICIENT_BALANCE, + PAYMENT_FAILED, + QUOTA_EXCEEDED, + RESTRICTED, + UNAUTHORIZED, + INTERNAL, + UNSUPPORTED_ENCRYPTION, + OTHER, +} + +class NwcError( + var code: NwcErrorCode? = null, + var message: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt new file mode 100644 index 000000000..42ca7529a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEvent.kt @@ -0,0 +1,69 @@ +/* + * 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.quartz.nip47WalletConnect + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag +import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class NwcInfoEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun capabilities(): List = content.split(" ").filter { it.isNotBlank() } + + fun supportsMethod(method: String): Boolean = capabilities().contains(method) + + fun supportsNotifications(): Boolean = capabilities().contains("notifications") + + fun encryptionSchemes() = tags.mapNotNull(EncryptionTag::parse).flatten() + + fun notificationTypes() = tags.mapNotNull(NotificationsTag::parse).flatten() + + companion object { + const val KIND = 13194 + const val ALT_DESCRIPTION = "Wallet service info" + + fun build( + capabilities: List, + encryptionSchemes: List? = null, + notificationTypes: List? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, capabilities.joinToString(" "), createdAt) { + alt(ALT_DESCRIPTION) + encryptionSchemes?.let { addUnique(EncryptionTag.assemble(it)) } + notificationTypes?.let { addUnique(NotificationsTag.assemble(it)) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt new file mode 100644 index 000000000..e6195639a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.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.quartz.nip47WalletConnect + +object NwcMethod { + const val PAY_INVOICE = "pay_invoice" + const val PAY_KEYSEND = "pay_keysend" + const val MAKE_INVOICE = "make_invoice" + const val LOOKUP_INVOICE = "lookup_invoice" + const val LIST_TRANSACTIONS = "list_transactions" + const val GET_BALANCE = "get_balance" + const val GET_INFO = "get_info" + const val MAKE_HOLD_INVOICE = "make_hold_invoice" + const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice" + const val SETTLE_HOLD_INVOICE = "settle_hold_invoice" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt new file mode 100644 index 000000000..77b16a3ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt @@ -0,0 +1,58 @@ +/* + * 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.quartz.nip47WalletConnect + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions + +@Immutable +class NwcNotificationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + fun clientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1) + + fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) clientPubKey() ?: pubKey else pubKey + + fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || clientPubKey() == signer.pubKey + + suspend fun decryptNotification(signer: NostrSigner): Notification { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + val jsonText = signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + return OptimizedJsonMapper.fromJsonTo(jsonText) + } + + companion object { + const val KIND = 23197 + const val LEGACY_KIND = 23196 + const val ALT = "Wallet notification" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt new file mode 100644 index 000000000..d2a043f59 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -0,0 +1,42 @@ +/* + * 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.quartz.nip47WalletConnect + +class NwcTransaction( + var type: String? = null, + var state: String? = null, + var invoice: String? = null, + var description: String? = null, + var description_hash: String? = null, + var preimage: String? = null, + var payment_hash: String? = null, + var amount: Long? = null, + var fees_paid: Long? = null, + var created_at: Long? = null, + var expires_at: Long? = null, + var settled_at: Long? = null, + var metadata: Any? = null, +) + +class TlvRecord( + var type: Long? = null, + var value: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index b53f3482e..8d0ea548b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -27,15 +27,174 @@ abstract class Request( var method: String? = null, ) : OptimizedSerializable -// PayInvoice Call +// pay_invoice class PayInvoiceParams( var invoice: String? = null, + var amount: Long? = null, + var metadata: Any? = null, ) class PayInvoiceMethod( var params: PayInvoiceParams? = null, -) : Request("pay_invoice") { +) : Request(NwcMethod.PAY_INVOICE) { companion object { fun create(bolt11: String): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11)) + + fun create( + bolt11: String, + amount: Long, + ): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11, amount)) + } +} + +// pay_keysend +class PayKeysendParams( + var amount: Long? = null, + var pubkey: String? = null, + var preimage: String? = null, + var tlv_records: List? = null, +) + +class PayKeysendMethod( + var params: PayKeysendParams? = null, +) : Request(NwcMethod.PAY_KEYSEND) { + companion object { + fun create( + amount: Long, + pubkey: String, + preimage: String? = null, + tlvRecords: List? = null, + ): PayKeysendMethod = PayKeysendMethod(PayKeysendParams(amount, pubkey, preimage, tlvRecords)) + } +} + +// make_invoice +class MakeInvoiceParams( + var amount: Long? = null, + var description: String? = null, + var description_hash: String? = null, + var expiry: Long? = null, + var metadata: Any? = null, +) + +class MakeInvoiceMethod( + var params: MakeInvoiceParams? = null, +) : Request(NwcMethod.MAKE_INVOICE) { + companion object { + fun create( + amount: Long, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + ): MakeInvoiceMethod = MakeInvoiceMethod(MakeInvoiceParams(amount, description, descriptionHash, expiry)) + } +} + +// lookup_invoice +class LookupInvoiceParams( + var payment_hash: String? = null, + var invoice: String? = null, +) + +class LookupInvoiceMethod( + var params: LookupInvoiceParams? = null, +) : Request(NwcMethod.LOOKUP_INVOICE) { + companion object { + fun createByHash(paymentHash: String): LookupInvoiceMethod = LookupInvoiceMethod(LookupInvoiceParams(payment_hash = paymentHash)) + + fun createByInvoice(invoice: String): LookupInvoiceMethod = LookupInvoiceMethod(LookupInvoiceParams(invoice = invoice)) + } +} + +// list_transactions +class ListTransactionsParams( + var from: Long? = null, + var until: Long? = null, + var limit: Int? = null, + var offset: Int? = null, + var unpaid: Boolean? = null, + var type: String? = null, +) + +class ListTransactionsMethod( + var params: ListTransactionsParams? = null, +) : Request(NwcMethod.LIST_TRANSACTIONS) { + companion object { + fun create( + from: Long? = null, + until: Long? = null, + limit: Int? = null, + offset: Int? = null, + unpaid: Boolean? = null, + type: String? = null, + ): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, type)) + } +} + +// get_balance +class GetBalanceMethod : Request(NwcMethod.GET_BALANCE) { + companion object { + fun create(): GetBalanceMethod = GetBalanceMethod() + } +} + +// get_info +class GetInfoMethod : Request(NwcMethod.GET_INFO) { + companion object { + fun create(): GetInfoMethod = GetInfoMethod() + } +} + +// make_hold_invoice +class MakeHoldInvoiceParams( + var amount: Long? = null, + var description: String? = null, + var description_hash: String? = null, + var expiry: Long? = null, + var payment_hash: String? = null, + var min_cltv_expiry_delta: Int? = null, +) + +class MakeHoldInvoiceMethod( + var params: MakeHoldInvoiceParams? = null, +) : Request(NwcMethod.MAKE_HOLD_INVOICE) { + companion object { + fun create( + amount: Long, + paymentHash: String, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + minCltvExpiryDelta: Int? = null, + ): MakeHoldInvoiceMethod = + MakeHoldInvoiceMethod( + MakeHoldInvoiceParams(amount, description, descriptionHash, expiry, paymentHash, minCltvExpiryDelta), + ) + } +} + +// cancel_hold_invoice +class CancelHoldInvoiceParams( + var payment_hash: String? = null, +) + +class CancelHoldInvoiceMethod( + var params: CancelHoldInvoiceParams? = null, +) : Request(NwcMethod.CANCEL_HOLD_INVOICE) { + companion object { + fun create(paymentHash: String): CancelHoldInvoiceMethod = CancelHoldInvoiceMethod(CancelHoldInvoiceParams(paymentHash)) + } +} + +// settle_hold_invoice +class SettleHoldInvoiceParams( + var preimage: String? = null, +) + +class SettleHoldInvoiceMethod( + var params: SettleHoldInvoiceParams? = null, +) : Request(NwcMethod.SETTLE_HOLD_INVOICE) { + companion object { + fun create(preimage: String): SettleHoldInvoiceMethod = SettleHoldInvoiceMethod(SettleHoldInvoiceParams(preimage)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt index cb449136a..5c4d3d6a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt @@ -27,49 +27,97 @@ abstract class Response( val resultType: String, ) : OptimizedSerializable -// PayInvoice Call +// Generic error response for any method +class NwcErrorResponse( + resultType: String, + val error: NwcError? = null, +) : Response(resultType) +// pay_invoice success response class PayInvoiceSuccessResponse( val result: PayInvoiceResultParams? = null, -) : Response("pay_invoice") { +) : Response(NwcMethod.PAY_INVOICE) { class PayInvoiceResultParams( val preimage: String? = null, + val fees_paid: Long? = null, ) } +// pay_invoice error response (kept for backward compatibility) class PayInvoiceErrorResponse( val error: PayInvoiceErrorParams? = null, -) : Response("pay_invoice") { +) : Response(NwcMethod.PAY_INVOICE) { class PayInvoiceErrorParams( - val code: ErrorType? = null, + val code: NwcErrorCode? = null, val message: String? = null, ) - - enum class ErrorType { - RATE_LIMITED, - - // The client is sending commands too fast. It should retry in a few seconds. - NOT_IMPLEMENTED, - - // The command is not known or is intentionally not implemented. - INSUFFICIENT_BALANCE, - - // The command is not known or is intentionally not implemented. - PAYMENT_FAILED, - - // The wallet does not have enough funds to cover a fee reserve or the payment amount. - QUOTA_EXCEEDED, - - // The wallet has exceeded its spending quota. - RESTRICTED, - - // This public key is not allowed to do this operation. - UNAUTHORIZED, - - // This public key has no wallet connected. - INTERNAL, - - // An internal error. - OTHER, // Other error. - } } + +// pay_keysend success response +class PayKeysendSuccessResponse( + val result: PayKeysendResult? = null, +) : Response(NwcMethod.PAY_KEYSEND) { + class PayKeysendResult( + val preimage: String? = null, + val fees_paid: Long? = null, + ) +} + +// make_invoice success response +class MakeInvoiceSuccessResponse( + val result: NwcTransaction? = null, +) : Response(NwcMethod.MAKE_INVOICE) + +// lookup_invoice success response +class LookupInvoiceSuccessResponse( + val result: NwcTransaction? = null, +) : Response(NwcMethod.LOOKUP_INVOICE) + +// list_transactions success response +class ListTransactionsSuccessResponse( + val result: ListTransactionsResult? = null, +) : Response(NwcMethod.LIST_TRANSACTIONS) { + class ListTransactionsResult( + val transactions: List? = null, + ) +} + +// get_balance success response +class GetBalanceSuccessResponse( + val result: GetBalanceResult? = null, +) : Response(NwcMethod.GET_BALANCE) { + class GetBalanceResult( + val balance: Long? = null, + ) +} + +// get_info success response +class GetInfoSuccessResponse( + val result: GetInfoResult? = null, +) : Response(NwcMethod.GET_INFO) { + class GetInfoResult( + val alias: String? = null, + val color: String? = null, + val pubkey: String? = null, + val network: String? = null, + val block_height: Long? = null, + val block_hash: String? = null, + val methods: List? = null, + val notifications: List? = null, + ) +} + +// make_hold_invoice success response +class MakeHoldInvoiceSuccessResponse( + val result: NwcTransaction? = null, +) : Response(NwcMethod.MAKE_HOLD_INVOICE) + +// cancel_hold_invoice success response +class CancelHoldInvoiceSuccessResponse( + val result: Any? = null, +) : Response(NwcMethod.CANCEL_HOLD_INVOICE) + +// settle_hold_invoice success response +class SettleHoldInvoiceSuccessResponse( + val result: Any? = null, +) : Response(NwcMethod.SETTLE_HOLD_INVOICE) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt new file mode 100644 index 000000000..3d732528f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/EncryptionTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.nip47WalletConnect.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class EncryptionTag { + companion object { + const val TAG_NAME = "encryption" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag.drop(1) + } + + fun assemble(schemes: List) = arrayOf(TAG_NAME, *schemes.toTypedArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt new file mode 100644 index 000000000..cb42cce54 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/tags/NotificationsTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.nip47WalletConnect.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class NotificationsTag { + companion object { + const val TAG_NAME = "notifications" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag.drop(1) + } + + fun assemble(types: List) = arrayOf(TAG_NAME, *types.toTypedArray()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index eee5e8689..4679e4665 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -78,6 +78,8 @@ import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.NwcInfoEvent +import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent @@ -256,6 +258,9 @@ class EventFactory { LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) + NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig) + NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) + NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig) LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index bec5f11b4..7729b4dd8 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -52,8 +52,10 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestDeseriali import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestSerializer import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseDeserializer import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.Notification import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor @@ -93,6 +95,7 @@ class JacksonMapper { // nip 47 .addDeserializer(Response::class.java, ResponseDeserializer()) .addDeserializer(Request::class.java, RequestDeserializer()) + .addDeserializer(Notification::class.java, NotificationDeserializer()) // nip 46 .addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer()) .addSerializer(BunkerRequest::class.java, BunkerRequestSerializer()) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt new file mode 100644 index 000000000..6f3d2ccab --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt @@ -0,0 +1,49 @@ +/* + * 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.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification +import com.vitorpamplona.quartz.utils.asTextOrNull + +class NotificationDeserializer : StdDeserializer(Notification::class.java) { + override fun deserialize( + jp: JsonParser, + ctxt: DeserializationContext, + ): Notification? { + val jsonObject: JsonNode = jp.codec.readTree(jp) + val notificationType = jsonObject.get("notification_type")?.asTextOrNull() + + return when (notificationType) { + NwcNotificationType.PAYMENT_RECEIVED -> jp.codec.treeToValue(jsonObject, PaymentReceivedNotification::class.java) + NwcNotificationType.PAYMENT_SENT -> jp.codec.treeToValue(jsonObject, PaymentSentNotification::class.java) + NwcNotificationType.HOLD_INVOICE_ACCEPTED -> jp.codec.treeToValue(jsonObject, HoldInvoiceAcceptedNotification::class.java) + else -> null + } + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt index 8e0c5c177..4098a5dc9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt @@ -24,8 +24,18 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod import com.vitorpamplona.quartz.utils.asTextOrNull class RequestDeserializer : StdDeserializer(Request::class.java) { @@ -36,9 +46,18 @@ class RequestDeserializer : StdDeserializer(Request::class.java) { val jsonObject: JsonNode = jp.codec.readTree(jp) val method = jsonObject.get("method")?.asTextOrNull() - if (method == "pay_invoice") { - return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) + return when (method) { + NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java) + NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendMethod::class.java) + NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceMethod::class.java) + NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceMethod::class.java) + NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsMethod::class.java) + NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceMethod::class.java) + NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoMethod::class.java) + NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceMethod::class.java) + NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceMethod::class.java) + NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceMethod::class.java) + else -> null } - return null } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt index f903f0171..72e7685fb 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt @@ -24,9 +24,21 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.utils.asTextOrNull class ResponseDeserializer : StdDeserializer(Response::class.java) { @@ -36,25 +48,41 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { ): Response? { val jsonObject: JsonNode = jp.codec.readTree(jp) val resultType = jsonObject.get("result_type")?.asTextOrNull() + val hasError = jsonObject.has("error") && !jsonObject.get("error").isNull + val hasResult = jsonObject.has("result") && !jsonObject.get("result").isNull - if (resultType == "pay_invoice") { - val result = jsonObject.get("result") - val error = jsonObject.get("error") - if (result != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - if (error != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) - } - } else { - // tries to guess - if (jsonObject.get("result")?.get("preimage") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - } - if (jsonObject.get("error")?.get("code") != null) { - return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + if (hasError) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + else -> { + val error = jp.codec.treeToValue(jsonObject.get("error"), NwcError::class.java) + NwcErrorResponse(resultType ?: "", error) + } } } + + if (hasResult || resultType != null) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java) + NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceSuccessResponse::class.java) + NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceSuccessResponse::class.java) + NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java) + NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java) + NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java) + NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java) + NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java) + NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java) + else -> { + // tries to guess for backward compatibility + if (jsonObject.get("result")?.get("preimage") != null) { + return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + } + null + } + } + } + return null } } From 8c73dd8f14c359362adffc3dcea3a2107b046921 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 03:45:50 +0000 Subject: [PATCH 015/130] test: add comprehensive test suite for NIP-47 Wallet Connect implementation Covers all 10 request methods (create, serialize, deserialize), all success and error response types, notification deserialization, EncryptionTag and NotificationsTag parse/assemble/isTag, NwcInfoEvent build and capabilities, NwcNotificationEvent structure, LnZapPaymentRequestEvent create/decrypt with NIP-04 and NIP-44, and Nip47WalletConnect URI parsing and serialization. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../LnZapPaymentRequestEventTest.kt | 207 ++++++++++++ .../Nip47WalletConnectTest.kt | 155 +++++++++ .../nip47WalletConnect/NotificationTest.kt | 97 ++++++ .../nip47WalletConnect/NwcInfoEventTest.kt | 124 ++++++++ .../nip47WalletConnect/NwcMethodTest.kt | 70 ++++ .../NwcNotificationEventTest.kt | 144 +++++++++ .../quartz/nip47WalletConnect/RequestTest.kt | 299 ++++++++++++++++++ .../quartz/nip47WalletConnect/ResponseTest.kt | 268 ++++++++++++++++ .../quartz/nip47WalletConnect/TagsTest.kt | 138 ++++++++ 9 files changed, 1502 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt new file mode 100644 index 000000000..bc5af2432 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt @@ -0,0 +1,207 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LnZapPaymentRequestEventTest { + @Test + fun testEventKind() { + assertEquals(23194, LnZapPaymentRequestEvent.KIND) + } + + @Test + fun testCreatePayInvoiceRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + ) + + assertEquals(23194, event.kind) + assertEquals(walletServicePubkey, event.walletServicePubKey()) + assertTrue(event.isContentEncoded()) + assertNotNull(event.content) + assertTrue(event.content.isNotEmpty()) + } + + @Test + fun testCreateGenericRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + ) + + assertEquals(23194, event.kind) + assertEquals(walletServicePubkey, event.walletServicePubKey()) + // Should not have encryption tag for NIP-04 + assertNull(event.encryptionScheme()) + } + + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptPayInvoiceRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + // Wallet service should be able to decrypt + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + assertEquals("lnbc50n1...", decrypted.params?.invoice) + } + + @Test + fun testDecryptGenericRequest() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val request = MakeInvoiceMethod.create(5000L, "test payment") + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + assertEquals(5000L, decrypted.params?.amount) + assertEquals("test payment", decrypted.params?.description) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } + + @Test + fun testCanDecrypt() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val otherKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val otherSigner = NostrSignerInternal(otherKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + + val event = + LnZapPaymentRequestEvent.create( + lnInvoice = "lnbc50n1...", + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + ) + + assertTrue(event.canDecrypt(clientSigner)) + assertTrue(event.canDecrypt(walletSigner)) + assertTrue(!event.canDecrypt(otherSigner)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt new file mode 100644 index 000000000..c31c376d7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt @@ -0,0 +1,155 @@ +/* + * 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.quartz.nip47WalletConnect + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class Nip47WalletConnectTest { + @Test + fun testParseWalletConnectUri() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + assertEquals("wss://relay.damus.io/", parsed.relayUri.url) + assertEquals("71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5", parsed.secret) + assertNull(parsed.lud16) + } + + @Test + fun testParseWalletConnectUriWithLud16() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5&lud16=user%40example.com" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + assertNotNull(parsed.lud16) + assertEquals("user@example.com", parsed.lud16) + } + + @Test + fun testParseNostrWalletConnectScheme() { + val uri = + "nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=abc" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + } + + @Test + fun testParseAmethystWalletConnectScheme() { + val uri = + "amethyst+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=abc" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex) + } + + @Test + fun testParseWithoutSecret() { + val uri = + "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io" + val parsed = Nip47WalletConnect.parse(uri) + + assertNull(parsed.secret) + } + + @Test + fun testParseInvalidSchemeThrows() { + val uri = "https://example.com?relay=wss%3A%2F%2Frelay.damus.io" + assertFailsWith { + Nip47WalletConnect.parse(uri) + } + } + + @Test + fun testParseWithoutRelayThrows() { + val uri = "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4" + assertFailsWith { + Nip47WalletConnect.parse(uri) + } + } + + // --- Nip47URI serialization --- + + @Test + fun testNip47UriSerializationRoundTrip() { + val original = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", + relayUri = "wss://relay.damus.io", + secret = "abc123", + lud16 = "user@example.com", + ) + + val json = Nip47WalletConnect.Nip47URI.serializer(original) + val deserialized = Nip47WalletConnect.Nip47URI.parser(json) + + assertEquals(original.pubKeyHex, deserialized.pubKeyHex) + assertEquals(original.relayUri, deserialized.relayUri) + assertEquals(original.secret, deserialized.secret) + assertEquals(original.lud16, deserialized.lud16) + } + + @Test + fun testNip47UriSerializationWithNullLud16() { + val original = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "abc123", + relayUri = "wss://relay.damus.io", + secret = "secret", + ) + + val json = Nip47WalletConnect.Nip47URI.serializer(original) + val deserialized = Nip47WalletConnect.Nip47URI.parser(json) + + assertEquals(original.pubKeyHex, deserialized.pubKeyHex) + assertNull(deserialized.lud16) + } + + // --- Normalize/Denormalize --- + + @Test + fun testNormalizeDenormalizeRoundTrip() { + val uri = + Nip47WalletConnect.Nip47URI( + pubKeyHex = "abc123", + relayUri = "wss://relay.damus.io", + secret = "secret", + lud16 = "user@example.com", + ) + + val normalized = uri.normalize() + assertNotNull(normalized) + assertEquals("user@example.com", normalized.lud16) + + val denormalized = normalized.denormalize() + assertNotNull(denormalized) + assertEquals("abc123", denormalized.pubKeyHex) + assertEquals("secret", denormalized.secret) + assertEquals("user@example.com", denormalized.lud16) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt new file mode 100644 index 000000000..b06d3710c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt @@ -0,0 +1,97 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class NotificationTest { + @Test + fun testPaymentReceivedDeserialization() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","invoice":"lnbc50n1...","description":"coffee","preimage":"abc","payment_hash":"hash123","amount":5000,"fees_paid":10,"created_at":1693876497,"expires_at":1694876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("incoming", notification.notification?.type) + assertEquals("lnbc50n1...", notification.notification?.invoice) + assertEquals("coffee", notification.notification?.description) + assertEquals("abc", notification.notification?.preimage) + assertEquals("hash123", notification.notification?.payment_hash) + assertEquals(5000L, notification.notification?.amount) + assertEquals(10L, notification.notification?.fees_paid) + assertEquals(1693876497L, notification.notification?.created_at) + assertEquals(1694876497L, notification.notification?.expires_at) + assertEquals(1694876500L, notification.notification?.settled_at) + } + + @Test + fun testPaymentSentDeserialization() { + val json = + """{"notification_type":"payment_sent","notification":{"type":"outgoing","invoice":"lnbc100n1...","preimage":"def456","payment_hash":"hash456","amount":10000,"fees_paid":50,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("outgoing", notification.notification?.type) + assertEquals("lnbc100n1...", notification.notification?.invoice) + assertEquals("def456", notification.notification?.preimage) + assertEquals(10000L, notification.notification?.amount) + assertEquals(50L, notification.notification?.fees_paid) + } + + @Test + fun testHoldInvoiceAcceptedDeserialization() { + val json = + """{"notification_type":"hold_invoice_accepted","notification":{"type":"incoming","invoice":"lnbc200n1...","payment_hash":"hash789","amount":20000,"created_at":1693876497,"expires_at":1694876497,"settle_deadline":800000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertNotNull(notification.notification) + assertEquals("incoming", notification.notification?.type) + assertEquals("lnbc200n1...", notification.notification?.invoice) + assertEquals("hash789", notification.notification?.payment_hash) + assertEquals(20000L, notification.notification?.amount) + assertEquals(800000L, notification.notification?.settle_deadline) + assertEquals(1693876497L, notification.notification?.created_at) + assertEquals(1694876497L, notification.notification?.expires_at) + } + + @Test + fun testPaymentReceivedMinimalFields() { + val json = """{"notification_type":"payment_received","notification":{"type":"incoming","amount":100}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertEquals("incoming", notification.notification?.type) + assertEquals(100L, notification.notification?.amount) + assertNull(notification.notification?.invoice) + assertNull(notification.notification?.preimage) + } + + @Test + fun testUnknownNotificationTypeReturnsNull() { + val json = """{"notification_type":"unknown_type","notification":{}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertNull(notification) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt new file mode 100644 index 000000000..3a8b15ca8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt @@ -0,0 +1,124 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NwcInfoEventTest { + private val signer = DeterministicSigner("nsec1w4uucmeyyng0kegm7486r23sv4majkmvqsj6eypprq0xttxss55s5mgg9t".nsecToKeyPair()) + + @Test + fun testBuildInfoEvent() { + val capabilities = listOf("pay_invoice", "get_balance", "make_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertEquals(NwcInfoEvent.KIND, event.kind) + assertEquals("pay_invoice get_balance make_invoice notifications", event.content) + } + + @Test + fun testCapabilities() { + val capabilities = listOf("pay_invoice", "get_balance", "make_invoice") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + val parsed = event.capabilities() + assertEquals(3, parsed.size) + assertTrue(parsed.contains("pay_invoice")) + assertTrue(parsed.contains("get_balance")) + assertTrue(parsed.contains("make_invoice")) + } + + @Test + fun testSupportsMethod() { + val capabilities = listOf("pay_invoice", "get_balance") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.supportsMethod("pay_invoice")) + assertTrue(event.supportsMethod("get_balance")) + assertFalse(event.supportsMethod("make_invoice")) + assertFalse(event.supportsMethod("pay_keysend")) + } + + @Test + fun testSupportsNotifications() { + val capabilities = listOf("pay_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.supportsNotifications()) + } + + @Test + fun testDoesNotSupportNotifications() { + val capabilities = listOf("pay_invoice", "get_balance") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertFalse(event.supportsNotifications()) + } + + @Test + fun testEncryptionSchemes() { + val capabilities = listOf("pay_invoice") + val template = NwcInfoEvent.build(capabilities, encryptionSchemes = listOf("nip44_v2", "nip04")) + val event = signer.sign(template) + + val schemes = event.encryptionSchemes() + assertEquals(2, schemes.size) + assertTrue(schemes.contains("nip44_v2")) + assertTrue(schemes.contains("nip04")) + } + + @Test + fun testNotificationTypes() { + val capabilities = listOf("pay_invoice", "notifications") + val template = NwcInfoEvent.build(capabilities, notificationTypes = listOf("payment_received", "payment_sent")) + val event = signer.sign(template) + + val types = event.notificationTypes() + assertEquals(2, types.size) + assertTrue(types.contains("payment_received")) + assertTrue(types.contains("payment_sent")) + } + + @Test + fun testInfoEventKind() { + assertEquals(13194, NwcInfoEvent.KIND) + } + + @Test + fun testBuildWithNoOptionalTags() { + val capabilities = listOf("pay_invoice") + val template = NwcInfoEvent.build(capabilities) + val event = signer.sign(template) + + assertTrue(event.encryptionSchemes().isEmpty()) + assertTrue(event.notificationTypes().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt new file mode 100644 index 000000000..f088a2dbd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -0,0 +1,70 @@ +/* + * 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.quartz.nip47WalletConnect + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NwcMethodTest { + @Test + fun testMethodConstants() { + assertEquals("pay_invoice", NwcMethod.PAY_INVOICE) + assertEquals("pay_keysend", NwcMethod.PAY_KEYSEND) + assertEquals("make_invoice", NwcMethod.MAKE_INVOICE) + assertEquals("lookup_invoice", NwcMethod.LOOKUP_INVOICE) + assertEquals("list_transactions", NwcMethod.LIST_TRANSACTIONS) + assertEquals("get_balance", NwcMethod.GET_BALANCE) + assertEquals("get_info", NwcMethod.GET_INFO) + assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE) + assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE) + assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE) + } + + @Test + fun testNotificationTypeConstants() { + assertEquals("payment_received", NwcNotificationType.PAYMENT_RECEIVED) + assertEquals("payment_sent", NwcNotificationType.PAYMENT_SENT) + assertEquals("hold_invoice_accepted", NwcNotificationType.HOLD_INVOICE_ACCEPTED) + } + + @Test + fun testErrorCodeValues() { + val codes = NwcErrorCode.entries + assertEquals(10, codes.size) + assertEquals(NwcErrorCode.RATE_LIMITED, NwcErrorCode.valueOf("RATE_LIMITED")) + assertEquals(NwcErrorCode.NOT_IMPLEMENTED, NwcErrorCode.valueOf("NOT_IMPLEMENTED")) + assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, NwcErrorCode.valueOf("INSUFFICIENT_BALANCE")) + assertEquals(NwcErrorCode.PAYMENT_FAILED, NwcErrorCode.valueOf("PAYMENT_FAILED")) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, NwcErrorCode.valueOf("QUOTA_EXCEEDED")) + assertEquals(NwcErrorCode.RESTRICTED, NwcErrorCode.valueOf("RESTRICTED")) + assertEquals(NwcErrorCode.UNAUTHORIZED, NwcErrorCode.valueOf("UNAUTHORIZED")) + assertEquals(NwcErrorCode.INTERNAL, NwcErrorCode.valueOf("INTERNAL")) + assertEquals(NwcErrorCode.UNSUPPORTED_ENCRYPTION, NwcErrorCode.valueOf("UNSUPPORTED_ENCRYPTION")) + assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER")) + } + + @Test + fun testNwcError() { + val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed") + assertEquals(NwcErrorCode.UNAUTHORIZED, error.code) + assertEquals("not allowed", error.message) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt new file mode 100644 index 000000000..3b392eb55 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEventTest.kt @@ -0,0 +1,144 @@ +/* + * 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.quartz.nip47WalletConnect + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NwcNotificationEventTest { + @Test + fun testKindConstants() { + assertEquals(23197, NwcNotificationEvent.KIND) + assertEquals(23196, NwcNotificationEvent.LEGACY_KIND) + } + + @Test + fun testIsContentEncoded() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = arrayOf(arrayOf("p", "c".repeat(64))), + content = "encrypted_content", + sig = "d".repeat(128), + ) + assertTrue(event.isContentEncoded()) + } + + @Test + fun testClientPubKey() { + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + assertEquals(clientPubKey, event.clientPubKey()) + } + + @Test + fun testClientPubKeyMissing() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = emptyArray(), + content = "encrypted", + sig = "d".repeat(128), + ) + assertNull(event.clientPubKey()) + } + + @Test + fun testTalkingWithAsWalletService() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // Wallet service asking "who am I talking with?" -> client + assertEquals(clientPubKey, event.talkingWith(walletPubKey)) + } + + @Test + fun testTalkingWithAsClient() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // Client asking "who am I talking with?" -> wallet service (pubkey) + assertEquals(walletPubKey, event.talkingWith(clientPubKey)) + } + + @Test + fun testEventKindInFactory() { + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1234L, + tags = emptyArray(), + content = "", + sig = "c".repeat(128), + ) + assertEquals(23197, event.kind) + } + + @Test + fun testCanDecryptReturnsFalseForUnrelatedSigner() { + val walletPubKey = "b".repeat(64) + val clientPubKey = "c".repeat(64) + val event = + NwcNotificationEvent( + id = "a".repeat(64), + pubKey = walletPubKey, + createdAt = 1234L, + tags = arrayOf(arrayOf("p", clientPubKey)), + content = "encrypted", + sig = "d".repeat(128), + ) + // A signer that is neither the wallet nor the client shouldn't be able to decrypt + assertFalse(event.clientPubKey() == "e".repeat(64)) + assertFalse(event.pubKey == "e".repeat(64)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt new file mode 100644 index 000000000..630f7addf --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -0,0 +1,299 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RequestTest { + // --- PayInvoice --- + + @Test + fun testPayInvoiceCreate() { + val request = PayInvoiceMethod.create("lnbc50n1...") + assertEquals(NwcMethod.PAY_INVOICE, request.method) + assertEquals("lnbc50n1...", request.params?.invoice) + assertNull(request.params?.amount) + } + + @Test + fun testPayInvoiceCreateWithAmount() { + val request = PayInvoiceMethod.create("lnbc50n1...", 1000L) + assertEquals(NwcMethod.PAY_INVOICE, request.method) + assertEquals("lnbc50n1...", request.params?.invoice) + assertEquals(1000L, request.params?.amount) + } + + @Test + fun testPayInvoiceSerialization() { + val request = PayInvoiceMethod.create("lnbc50n1...") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"pay_invoice\"")) + assertTrue(json.contains("\"invoice\":\"lnbc50n1...\"")) + } + + @Test + fun testPayInvoiceDeserialization() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc50n1..."}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc50n1...", request.params?.invoice) + } + + @Test + fun testPayInvoiceWithAmountDeserialization() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc50n1...","amount":1000}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc50n1...", request.params?.invoice) + assertEquals(1000L, request.params?.amount) + } + + // --- PayKeysend --- + + @Test + fun testPayKeysendCreate() { + val request = PayKeysendMethod.create(1000L, "abcdef1234567890") + assertEquals(NwcMethod.PAY_KEYSEND, request.method) + assertEquals(1000L, request.params?.amount) + assertEquals("abcdef1234567890", request.params?.pubkey) + assertNull(request.params?.preimage) + assertNull(request.params?.tlv_records) + } + + @Test + fun testPayKeysendWithTlvRecords() { + val tlvRecords = listOf(TlvRecord(7629169L, "hex_value")) + val request = PayKeysendMethod.create(1000L, "pubkey123", "preimage123", tlvRecords) + assertEquals(1000L, request.params?.amount) + assertEquals("pubkey123", request.params?.pubkey) + assertEquals("preimage123", request.params?.preimage) + assertNotNull(request.params?.tlv_records) + assertEquals(1, request.params?.tlv_records?.size) + assertEquals(7629169L, request.params?.tlv_records?.first()?.type) + assertEquals("hex_value", request.params?.tlv_records?.first()?.value) + } + + @Test + fun testPayKeysendSerialization() { + val request = PayKeysendMethod.create(1000L, "pubkey123") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"pay_keysend\"")) + assertTrue(json.contains("\"amount\":1000")) + assertTrue(json.contains("\"pubkey\":\"pubkey123\"")) + } + + @Test + fun testPayKeysendDeserialization() { + val json = """{"method":"pay_keysend","params":{"amount":1000,"pubkey":"pubkey123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("pubkey123", request.params?.pubkey) + } + + // --- MakeInvoice --- + + @Test + fun testMakeInvoiceCreate() { + val request = MakeInvoiceMethod.create(5000L, "test payment", null, 3600L) + assertEquals(NwcMethod.MAKE_INVOICE, request.method) + assertEquals(5000L, request.params?.amount) + assertEquals("test payment", request.params?.description) + assertNull(request.params?.description_hash) + assertEquals(3600L, request.params?.expiry) + } + + @Test + fun testMakeInvoiceSerialization() { + val request = MakeInvoiceMethod.create(5000L, "test") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"make_invoice\"")) + assertTrue(json.contains("\"amount\":5000")) + } + + @Test + fun testMakeInvoiceDeserialization() { + val json = """{"method":"make_invoice","params":{"amount":5000,"description":"test","expiry":3600}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(5000L, request.params?.amount) + assertEquals("test", request.params?.description) + assertEquals(3600L, request.params?.expiry) + } + + // --- LookupInvoice --- + + @Test + fun testLookupInvoiceByHash() { + val request = LookupInvoiceMethod.createByHash("abc123") + assertEquals(NwcMethod.LOOKUP_INVOICE, request.method) + assertEquals("abc123", request.params?.payment_hash) + assertNull(request.params?.invoice) + } + + @Test + fun testLookupInvoiceByInvoice() { + val request = LookupInvoiceMethod.createByInvoice("lnbc50n1...") + assertEquals(NwcMethod.LOOKUP_INVOICE, request.method) + assertNull(request.params?.payment_hash) + assertEquals("lnbc50n1...", request.params?.invoice) + } + + @Test + fun testLookupInvoiceDeserialization() { + val json = """{"method":"lookup_invoice","params":{"payment_hash":"abc123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.payment_hash) + } + + // --- ListTransactions --- + + @Test + fun testListTransactionsCreate() { + val request = ListTransactionsMethod.create(from = 1000L, until = 2000L, limit = 10, offset = 0, unpaid = false, type = "incoming") + assertEquals(NwcMethod.LIST_TRANSACTIONS, request.method) + assertEquals(1000L, request.params?.from) + assertEquals(2000L, request.params?.until) + assertEquals(10, request.params?.limit) + assertEquals(0, request.params?.offset) + assertEquals(false, request.params?.unpaid) + assertEquals("incoming", request.params?.type) + } + + @Test + fun testListTransactionsDeserialization() { + val json = """{"method":"list_transactions","params":{"from":1000,"until":2000,"limit":10}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.from) + assertEquals(2000L, request.params?.until) + assertEquals(10, request.params?.limit) + } + + // --- GetBalance --- + + @Test + fun testGetBalanceCreate() { + val request = GetBalanceMethod.create() + assertEquals(NwcMethod.GET_BALANCE, request.method) + } + + @Test + fun testGetBalanceSerialization() { + val request = GetBalanceMethod.create() + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"get_balance\"")) + } + + @Test + fun testGetBalanceDeserialization() { + val json = """{"method":"get_balance"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- GetInfo --- + + @Test + fun testGetInfoCreate() { + val request = GetInfoMethod.create() + assertEquals(NwcMethod.GET_INFO, request.method) + } + + @Test + fun testGetInfoDeserialization() { + val json = """{"method":"get_info"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- MakeHoldInvoice --- + + @Test + fun testMakeHoldInvoiceCreate() { + val request = MakeHoldInvoiceMethod.create(10000L, "payment_hash_abc", "hold invoice", null, 7200L, 144) + assertEquals(NwcMethod.MAKE_HOLD_INVOICE, request.method) + assertEquals(10000L, request.params?.amount) + assertEquals("payment_hash_abc", request.params?.payment_hash) + assertEquals("hold invoice", request.params?.description) + assertEquals(7200L, request.params?.expiry) + assertEquals(144, request.params?.min_cltv_expiry_delta) + } + + @Test + fun testMakeHoldInvoiceDeserialization() { + val json = """{"method":"make_hold_invoice","params":{"amount":10000,"payment_hash":"abc","description":"test"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(10000L, request.params?.amount) + assertEquals("abc", request.params?.payment_hash) + } + + // --- CancelHoldInvoice --- + + @Test + fun testCancelHoldInvoiceCreate() { + val request = CancelHoldInvoiceMethod.create("payment_hash_abc") + assertEquals(NwcMethod.CANCEL_HOLD_INVOICE, request.method) + assertEquals("payment_hash_abc", request.params?.payment_hash) + } + + @Test + fun testCancelHoldInvoiceDeserialization() { + val json = """{"method":"cancel_hold_invoice","params":{"payment_hash":"abc123"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.payment_hash) + } + + // --- SettleHoldInvoice --- + + @Test + fun testSettleHoldInvoiceCreate() { + val request = SettleHoldInvoiceMethod.create("preimage_xyz") + assertEquals(NwcMethod.SETTLE_HOLD_INVOICE, request.method) + assertEquals("preimage_xyz", request.params?.preimage) + } + + @Test + fun testSettleHoldInvoiceDeserialization() { + val json = """{"method":"settle_hold_invoice","params":{"preimage":"preimage_xyz"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("preimage_xyz", request.params?.preimage) + } + + // --- Unknown method --- + + @Test + fun testUnknownMethodReturnsNull() { + val json = """{"method":"unknown_method","params":{}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertNull(request) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt new file mode 100644 index 000000000..6bf160d6a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -0,0 +1,268 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ResponseTest { + // --- PayInvoice Success --- + + @Test + fun testPayInvoiceSuccessDeserialization() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"0123456789abcdef"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("0123456789abcdef", response.result?.preimage) + } + + @Test + fun testPayInvoiceSuccessWithFeesPaid() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"abc","fees_paid":100}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("abc", response.result?.preimage) + assertEquals(100L, response.result?.fees_paid) + } + + @Test + fun testPayInvoiceSuccessGuessWithoutResultType() { + val json = """{"result":{"preimage":"0123456789abcdef"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("0123456789abcdef", response.result?.preimage) + } + + // --- PayInvoice Error --- + + @Test + fun testPayInvoiceErrorDeserialization() { + val json = """{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE","message":"Not enough funds"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, response.error?.code) + assertEquals("Not enough funds", response.error?.message) + } + + @Test + fun testPayInvoicePaymentFailedError() { + val json = """{"result_type":"pay_invoice","error":{"code":"PAYMENT_FAILED","message":"Route not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.PAYMENT_FAILED, response.error?.code) + } + + // --- PayKeysend Success --- + + @Test + fun testPayKeysendSuccessDeserialization() { + val json = """{"result_type":"pay_keysend","result":{"preimage":"abc123","fees_paid":50}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("abc123", response.result?.preimage) + assertEquals(50L, response.result?.fees_paid) + } + + // --- MakeInvoice Success --- + + @Test + fun testMakeInvoiceSuccessDeserialization() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","invoice":"lnbc50n1...","description":"test","payment_hash":"abc","amount":5000,"fees_paid":0,"created_at":1693876497,"expires_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("incoming", response.result?.type) + assertEquals("lnbc50n1...", response.result?.invoice) + assertEquals("test", response.result?.description) + assertEquals("abc", response.result?.payment_hash) + assertEquals(5000L, response.result?.amount) + assertEquals(1693876497L, response.result?.created_at) + assertEquals(1694876497L, response.result?.expires_at) + } + + // --- LookupInvoice Success --- + + @Test + fun testLookupInvoiceSuccessDeserialization() { + val json = """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("incoming", response.result?.type) + assertEquals("settled", response.result?.state) + assertEquals("hash123", response.result?.payment_hash) + assertEquals(1000L, response.result?.amount) + assertEquals(1694876497L, response.result?.settled_at) + } + + // --- ListTransactions Success --- + + @Test + fun testListTransactionsSuccessDeserialization() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","invoice":"lnbc1...","amount":100,"created_at":1000},{"type":"outgoing","invoice":"lnbc2...","amount":200,"created_at":2000}]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.transactions) + assertEquals(2, response.result?.transactions?.size) + assertEquals("incoming", response.result?.transactions?.get(0)?.type) + assertEquals(100L, response.result?.transactions?.get(0)?.amount) + assertEquals("outgoing", response.result?.transactions?.get(1)?.type) + assertEquals(200L, response.result?.transactions?.get(1)?.amount) + } + + @Test + fun testListTransactionsEmptyResult() { + val json = """{"result_type":"list_transactions","result":{"transactions":[]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.transactions) + assertEquals(0, response.result?.transactions?.size) + } + + // --- GetBalance Success --- + + @Test + fun testGetBalanceSuccessDeserialization() { + val json = """{"result_type":"get_balance","result":{"balance":21000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(21000L, response.result?.balance) + } + + @Test + fun testGetBalanceZero() { + val json = """{"result_type":"get_balance","result":{"balance":0}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.balance) + } + + // --- GetInfo Success --- + + @Test + fun testGetInfoSuccessDeserialization() { + val json = + """{"result_type":"get_info","result":{"alias":"MyNode","color":"#ff9900","pubkey":"abc123","network":"mainnet","block_height":800000,"block_hash":"hash","methods":["pay_invoice","get_balance"],"notifications":["payment_received"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("MyNode", response.result?.alias) + assertEquals("#ff9900", response.result?.color) + assertEquals("abc123", response.result?.pubkey) + assertEquals("mainnet", response.result?.network) + assertEquals(800000L, response.result?.block_height) + assertEquals("hash", response.result?.block_hash) + assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods) + assertEquals(listOf("payment_received"), response.result?.notifications) + } + + // --- MakeHoldInvoice Success --- + + @Test + fun testMakeHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"make_hold_invoice","result":{"type":"incoming","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"expires_at":2000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("lnbc...", response.result?.invoice) + assertEquals("hash", response.result?.payment_hash) + } + + // --- CancelHoldInvoice Success --- + + @Test + fun testCancelHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"cancel_hold_invoice","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + } + + // --- SettleHoldInvoice Success --- + + @Test + fun testSettleHoldInvoiceSuccessDeserialization() { + val json = """{"result_type":"settle_hold_invoice","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + } + + // --- Generic Error Response --- + + @Test + fun testGenericErrorForGetBalance() { + val json = """{"result_type":"get_balance","error":{"code":"UNAUTHORIZED","message":"No wallet connected"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("get_balance", response.resultType) + assertEquals(NwcErrorCode.UNAUTHORIZED, response.error?.code) + assertEquals("No wallet connected", response.error?.message) + } + + @Test + fun testGenericErrorForGetInfo() { + val json = """{"result_type":"get_info","error":{"code":"NOT_IMPLEMENTED","message":"Not supported"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("get_info", response.resultType) + assertEquals(NwcErrorCode.NOT_IMPLEMENTED, response.error?.code) + } + + @Test + fun testGenericErrorForMakeInvoice() { + val json = """{"result_type":"make_invoice","error":{"code":"QUOTA_EXCEEDED","message":"Spending limit reached"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, response.error?.code) + } + + @Test + fun testGenericErrorRateLimited() { + val json = """{"result_type":"pay_keysend","error":{"code":"RATE_LIMITED","message":"Too many requests"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.RATE_LIMITED, response.error?.code) + } + + @Test + fun testGenericErrorUnsupportedEncryption() { + val json = """{"result_type":"pay_invoice","error":{"code":"UNSUPPORTED_ENCRYPTION","message":"Use nip44"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + // pay_invoice errors go to PayInvoiceErrorResponse for backward compat + assertIs(response) + } + + // --- Null/missing result --- + + @Test + fun testResponseWithNoResultOrError() { + val json = """{"result_type":"pay_invoice"}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + // Should still deserialize since result_type is present + assertIs(response) + assertNull(response.result) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt new file mode 100644 index 000000000..801058935 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/TagsTest.kt @@ -0,0 +1,138 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag +import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TagsTest { + // --- EncryptionTag --- + + @Test + fun testEncryptionTagParse() { + val tag = arrayOf("encryption", "nip44_v2", "nip04") + val result = EncryptionTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("nip44_v2", "nip04"), result) + } + + @Test + fun testEncryptionTagParseSingleScheme() { + val tag = arrayOf("encryption", "nip44_v2") + val result = EncryptionTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("nip44_v2"), result) + } + + @Test + fun testEncryptionTagParseWrongTagName() { + val tag = arrayOf("other", "nip44_v2") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagParseTooShort() { + val tag = arrayOf("encryption") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagParseEmptyValue() { + val tag = arrayOf("encryption", "") + val result = EncryptionTag.parse(tag) + assertNull(result) + } + + @Test + fun testEncryptionTagAssemble() { + val tag = EncryptionTag.assemble(listOf("nip44_v2", "nip04")) + assertEquals("encryption", tag[0]) + assertEquals("nip44_v2", tag[1]) + assertEquals("nip04", tag[2]) + assertEquals(3, tag.size) + } + + @Test + fun testEncryptionTagIsTag() { + assertTrue(EncryptionTag.isTag(arrayOf("encryption", "nip44_v2"))) + assertFalse(EncryptionTag.isTag(arrayOf("other", "nip44_v2"))) + assertFalse(EncryptionTag.isTag(arrayOf("encryption"))) + assertFalse(EncryptionTag.isTag(arrayOf("encryption", ""))) + } + + // --- NotificationsTag --- + + @Test + fun testNotificationsTagParse() { + val tag = arrayOf("notifications", "payment_received", "payment_sent") + val result = NotificationsTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("payment_received", "payment_sent"), result) + } + + @Test + fun testNotificationsTagParseSingleType() { + val tag = arrayOf("notifications", "payment_received") + val result = NotificationsTag.parse(tag) + assertNotNull(result) + assertEquals(listOf("payment_received"), result) + } + + @Test + fun testNotificationsTagParseWrongTagName() { + val tag = arrayOf("other", "payment_received") + val result = NotificationsTag.parse(tag) + assertNull(result) + } + + @Test + fun testNotificationsTagParseTooShort() { + val tag = arrayOf("notifications") + val result = NotificationsTag.parse(tag) + assertNull(result) + } + + @Test + fun testNotificationsTagAssemble() { + val tag = NotificationsTag.assemble(listOf("payment_received", "payment_sent", "hold_invoice_accepted")) + assertEquals("notifications", tag[0]) + assertEquals("payment_received", tag[1]) + assertEquals("payment_sent", tag[2]) + assertEquals("hold_invoice_accepted", tag[3]) + assertEquals(4, tag.size) + } + + @Test + fun testNotificationsTagIsTag() { + assertTrue(NotificationsTag.isTag(arrayOf("notifications", "payment_received"))) + assertFalse(NotificationsTag.isTag(arrayOf("other", "payment_received"))) + assertFalse(NotificationsTag.isTag(arrayOf("notifications"))) + assertFalse(NotificationsTag.isTag(arrayOf("notifications", ""))) + } +} From 9e4cb446efdc06d29ff6ec7a3762628c2816ba4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 02:58:40 +0000 Subject: [PATCH 016/130] feat: add NIP-47 get_budget, sign_message, create_connection methods and Alby Hub interop - Add get_budget, sign_message, create_connection request/response types - Add NwcTransactionType (incoming/outgoing) and NwcTransactionState (PENDING/SETTLED/FAILED/ACCEPTED) constants - Add missing error codes: BAD_REQUEST, NOT_FOUND, EXPIRED - Add settle_deadline field to NwcTransaction - Add total_count to ListTransactionsResult - Add metadata and lud16 to GetInfoResult - Add unpaid_outgoing and unpaid_incoming to ListTransactionsParams - Update Jackson deserializers for new method types - Add AlbyInteropTest with 25+ tests verifying compatibility with Alby Hub's JSON formats for all request/response types - Update existing tests for new constants and error codes https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../quartz/nip47WalletConnect/NwcErrorCode.kt | 3 + .../quartz/nip47WalletConnect/NwcMethod.kt | 3 + .../nip47WalletConnect/NwcTransaction.kt | 13 + .../quartz/nip47WalletConnect/Request.kt | 56 +++ .../quartz/nip47WalletConnect/Response.kt | 34 ++ .../nip47WalletConnect/AlbyInteropTest.kt | 325 ++++++++++++++++++ .../nip47WalletConnect/NwcMethodTest.kt | 22 +- .../quartz/nip47WalletConnect/RequestTest.kt | 91 +++++ .../quartz/nip47WalletConnect/ResponseTest.kt | 111 ++++++ .../jackson/RequestDeserializer.kt | 6 + .../jackson/ResponseDeserializer.kt | 6 + 11 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt index f88fd1892..cef350602 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcErrorCode.kt @@ -30,6 +30,9 @@ enum class NwcErrorCode { UNAUTHORIZED, INTERNAL, UNSUPPORTED_ENCRYPTION, + BAD_REQUEST, + NOT_FOUND, + EXPIRED, OTHER, } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt index e6195639a..afc6474ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethod.kt @@ -28,6 +28,9 @@ object NwcMethod { const val LIST_TRANSACTIONS = "list_transactions" const val GET_BALANCE = "get_balance" const val GET_INFO = "get_info" + const val GET_BUDGET = "get_budget" + const val SIGN_MESSAGE = "sign_message" + const val CREATE_CONNECTION = "create_connection" const val MAKE_HOLD_INVOICE = "make_hold_invoice" const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice" const val SETTLE_HOLD_INVOICE = "settle_hold_invoice" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt index d2a043f59..9ba84991e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -20,6 +20,18 @@ */ package com.vitorpamplona.quartz.nip47WalletConnect +object NwcTransactionType { + const val INCOMING = "incoming" + const val OUTGOING = "outgoing" +} + +object NwcTransactionState { + const val PENDING = "PENDING" + const val SETTLED = "SETTLED" + const val FAILED = "FAILED" + const val ACCEPTED = "ACCEPTED" +} + class NwcTransaction( var type: String? = null, var state: String? = null, @@ -33,6 +45,7 @@ class NwcTransaction( var created_at: Long? = null, var expires_at: Long? = null, var settled_at: Long? = null, + var settle_deadline: Long? = null, var metadata: Any? = null, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index 8d0ea548b..cdce4c7ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -113,6 +113,8 @@ class ListTransactionsParams( var limit: Int? = null, var offset: Int? = null, var unpaid: Boolean? = null, + var unpaid_outgoing: Boolean? = null, + var unpaid_incoming: Boolean? = null, var type: String? = null, ) @@ -198,3 +200,57 @@ class SettleHoldInvoiceMethod( fun create(preimage: String): SettleHoldInvoiceMethod = SettleHoldInvoiceMethod(SettleHoldInvoiceParams(preimage)) } } + +// get_budget +class GetBudgetMethod : Request(NwcMethod.GET_BUDGET) { + companion object { + fun create(): GetBudgetMethod = GetBudgetMethod() + } +} + +// sign_message +class SignMessageParams( + var message: String? = null, +) + +class SignMessageMethod( + var params: SignMessageParams? = null, +) : Request(NwcMethod.SIGN_MESSAGE) { + companion object { + fun create(message: String): SignMessageMethod = SignMessageMethod(SignMessageParams(message)) + } +} + +// create_connection +class CreateConnectionParams( + var pubkey: String? = null, + var name: String? = null, + var request_methods: List? = null, + var notification_types: List? = null, + var max_amount: Long? = null, + var budget_renewal: String? = null, + var expires_at: Long? = null, + var isolated: Boolean? = null, + var metadata: Any? = null, +) + +class CreateConnectionMethod( + var params: CreateConnectionParams? = null, +) : Request(NwcMethod.CREATE_CONNECTION) { + companion object { + fun create( + pubkey: String, + name: String, + requestMethods: List? = null, + notificationTypes: List? = null, + maxAmount: Long? = null, + budgetRenewal: String? = null, + expiresAt: Long? = null, + isolated: Boolean? = null, + metadata: Any? = null, + ): CreateConnectionMethod = + CreateConnectionMethod( + CreateConnectionParams(pubkey, name, requestMethods, notificationTypes, maxAmount, budgetRenewal, expiresAt, isolated, metadata), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt index 5c4d3d6a0..623f0cee6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt @@ -79,6 +79,7 @@ class ListTransactionsSuccessResponse( ) : Response(NwcMethod.LIST_TRANSACTIONS) { class ListTransactionsResult( val transactions: List? = null, + val total_count: Long? = null, ) } @@ -104,6 +105,8 @@ class GetInfoSuccessResponse( val block_hash: String? = null, val methods: List? = null, val notifications: List? = null, + val metadata: Any? = null, + val lud16: String? = null, ) } @@ -121,3 +124,34 @@ class CancelHoldInvoiceSuccessResponse( class SettleHoldInvoiceSuccessResponse( val result: Any? = null, ) : Response(NwcMethod.SETTLE_HOLD_INVOICE) + +// get_budget success response +class GetBudgetSuccessResponse( + val result: GetBudgetResult? = null, +) : Response(NwcMethod.GET_BUDGET) { + class GetBudgetResult( + val used_budget: Long? = null, + val total_budget: Long? = null, + val renews_at: Long? = null, + val renewal_period: String? = null, + ) +} + +// sign_message success response +class SignMessageSuccessResponse( + val result: SignMessageResult? = null, +) : Response(NwcMethod.SIGN_MESSAGE) { + class SignMessageResult( + val message: String? = null, + val signature: String? = null, + ) +} + +// create_connection success response +class CreateConnectionSuccessResponse( + val result: CreateConnectionResult? = null, +) : Response(NwcMethod.CREATE_CONNECTION) { + class CreateConnectionResult( + val wallet_pubkey: String? = null, + ) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt new file mode 100644 index 000000000..babb67d67 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -0,0 +1,325 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Interoperability tests using JSON structures matching Alby Hub's NIP-47 implementation. + * These tests verify that Amethyst can correctly parse responses from Alby Hub wallets. + */ +class AlbyInteropTest { + // --- Alby Hub pay_invoice response format --- + + @Test + fun testAlbyPayInvoiceSuccess() { + val json = """{"result_type":"pay_invoice","result":{"preimage":"6565656565656565656565656565656565656565656565656565656565656565","fees_paid":1}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("6565656565656565656565656565656565656565656565656565656565656565", response.result?.preimage) + assertEquals(1L, response.result?.fees_paid) + } + + @Test + fun testAlbyPayInvoiceInsufficientBalance() { + val json = """{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE","message":"insufficient funds available to send"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, response.error?.code) + assertEquals("insufficient funds available to send", response.error?.message) + } + + @Test + fun testAlbyPayInvoiceBadRequest() { + val json = """{"result_type":"pay_invoice","error":{"code":"BAD_REQUEST","message":"Failed to decode bolt11 invoice: bolt11 too short"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.BAD_REQUEST, response.error?.code) + } + + // --- Alby Hub get_balance response format --- + + @Test + fun testAlbyGetBalanceResponse() { + val json = """{"result_type":"get_balance","result":{"balance":21000000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(21000000L, response.result?.balance) + } + + // --- Alby Hub get_info response format (with extended fields) --- + + @Test + fun testAlbyGetInfoFullResponse() { + val json = + """{"result_type":"get_info","result":{"alias":"AlbyHub","color":"#3399ff","pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","network":"mainnet","block_height":800000,"block_hash":"00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f2e4b10","methods":["pay_invoice","pay_keysend","get_balance","get_budget","get_info","make_invoice","lookup_invoice","list_transactions","sign_message"],"notifications":["payment_received","payment_sent"],"lud16":"satoshi@getalby.com"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val result = response.result + assertNotNull(result) + assertEquals("AlbyHub", result.alias) + assertEquals("#3399ff", result.color) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", result.pubkey) + assertEquals("mainnet", result.network) + assertEquals(800000L, result.block_height) + assertEquals("00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f2e4b10", result.block_hash) + assertEquals(9, result.methods?.size) + assertEquals(2, result.notifications?.size) + assertEquals("satoshi@getalby.com", result.lud16) + } + + // --- Alby Hub get_budget response format --- + + @Test + fun testAlbyGetBudgetWithRenewal() { + val json = """{"result_type":"get_budget","result":{"used_budget":50000,"total_budget":100000,"renews_at":1700000000,"renewal_period":"monthly"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val result = response.result + assertNotNull(result) + assertEquals(50000L, result.used_budget) + assertEquals(100000L, result.total_budget) + assertEquals(1700000000L, result.renews_at) + assertEquals("monthly", result.renewal_period) + } + + @Test + fun testAlbyGetBudgetUnlimited() { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":0,"renewal_period":"never"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.used_budget) + assertEquals(0L, response.result?.total_budget) + assertNull(response.result?.renews_at) + assertEquals("never", response.result?.renewal_period) + } + + // --- Alby Hub make_invoice response format --- + + @Test + fun testAlbyMakeInvoiceResponse() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","state":"PENDING","invoice":"lnbc10n1pj3xyz...","description":"Test invoice","payment_hash":"abc123def456","amount":1000,"fees_paid":0,"created_at":1693876497,"expires_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.PENDING, txn.state) + assertEquals("lnbc10n1pj3xyz...", txn.invoice) + assertEquals("Test invoice", txn.description) + assertEquals("abc123def456", txn.payment_hash) + assertEquals(1000L, txn.amount) + assertEquals(0L, txn.fees_paid) + } + + // --- Alby Hub lookup_invoice with settled state --- + + @Test + fun testAlbyLookupInvoiceSettled() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"SETTLED","invoice":"lnbc50n1...","preimage":"preimage123","payment_hash":"hash456","amount":5000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals("preimage123", txn.preimage) + assertEquals(1694876500L, txn.settled_at) + } + + @Test + fun testAlbyLookupInvoiceNotFound() { + val json = """{"result_type":"lookup_invoice","error":{"code":"NOT_FOUND","message":"transaction not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.NOT_FOUND, response.error?.code) + } + + // --- Alby Hub list_transactions with total_count --- + + @Test + fun testAlbyListTransactionsWithTotalCount() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"SETTLED","invoice":"lnbc1...","payment_hash":"h1","amount":1000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500},{"type":"outgoing","state":"SETTLED","invoice":"lnbc2...","payment_hash":"h2","amount":2000,"fees_paid":10,"created_at":1693876400,"settled_at":1694876400}],"total_count":100}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(2, response.result?.transactions?.size) + assertEquals(100L, response.result?.total_count) + + val first = response.result?.transactions?.get(0) + assertEquals(NwcTransactionType.INCOMING, first?.type) + assertEquals(NwcTransactionState.SETTLED, first?.state) + + val second = response.result?.transactions?.get(1) + assertEquals(NwcTransactionType.OUTGOING, second?.type) + assertEquals(10L, second?.fees_paid) + } + + // --- Alby Hub sign_message response --- + + @Test + fun testAlbySignMessageResponse() { + val json = """{"result_type":"sign_message","result":{"message":"Hello Nostr","signature":"3045022100..."}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("Hello Nostr", response.result?.message) + assertEquals("3045022100...", response.result?.signature) + } + + // --- Alby Hub create_connection response --- + + @Test + fun testAlbyCreateConnectionResponse() { + val json = """{"result_type":"create_connection","result":{"wallet_pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", response.result?.wallet_pubkey) + } + + // --- Alby Hub notification formats --- + + @Test + fun testAlbyPaymentReceivedNotification() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","state":"SETTLED","invoice":"lnbc50n1...","preimage":"pre123","payment_hash":"hash123","amount":5000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val txn = notification.notification + assertNotNull(txn) + assertEquals(NwcTransactionType.INCOMING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals(5000L, txn.amount) + } + + @Test + fun testAlbyPaymentSentNotification() { + val json = + """{"notification_type":"payment_sent","notification":{"type":"outgoing","state":"SETTLED","invoice":"lnbc100n1...","preimage":"pre456","payment_hash":"hash456","amount":10000,"fees_paid":10,"created_at":1693876497,"settled_at":1694876500}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val txn = notification.notification + assertNotNull(txn) + assertEquals(NwcTransactionType.OUTGOING, txn.type) + assertEquals(NwcTransactionState.SETTLED, txn.state) + assertEquals(10000L, txn.amount) + assertEquals(10L, txn.fees_paid) + } + + @Test + fun testAlbyHoldInvoiceAcceptedNotification() { + val json = + """{"notification_type":"hold_invoice_accepted","notification":{"type":"incoming","invoice":"lnbc200n1...","payment_hash":"hash789","amount":20000,"created_at":1693876497,"expires_at":1694876497,"settle_deadline":800000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + val data = notification.notification + assertNotNull(data) + assertEquals("incoming", data.type) + assertEquals(20000L, data.amount) + assertEquals(800000L, data.settle_deadline) + } + + // --- Alby Hub error code interop --- + + @Test + fun testAlbyRestricted() { + val json = """{"result_type":"pay_invoice","error":{"code":"RESTRICTED","message":"This app does not have the pay_invoice scope"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.RESTRICTED, response.error?.code) + } + + @Test + fun testAlbyExpiredConnection() { + val json = """{"result_type":"get_balance","error":{"code":"EXPIRED","message":"This app has expired"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.EXPIRED, response.error?.code) + } + + @Test + fun testAlbyQuotaExceeded() { + val json = """{"result_type":"pay_invoice","error":{"code":"QUOTA_EXCEEDED","message":"Exceeded budget limit"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.QUOTA_EXCEEDED, response.error?.code) + } + + // --- Alby Hub request format interop --- + + @Test + fun testAlbyPayInvoiceRequestFormat() { + val json = """{"method":"pay_invoice","params":{"invoice":"lnbc10u1pj3xyz..."}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("lnbc10u1pj3xyz...", request.params?.invoice) + } + + @Test + fun testAlbyGetBudgetRequestFormat() { + val json = """{"method":"get_budget","params":{}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + @Test + fun testAlbySignMessageRequestFormat() { + val json = """{"method":"sign_message","params":{"message":"Hello from Amethyst"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Hello from Amethyst", request.params?.message) + } + + @Test + fun testAlbyCreateConnectionRequestFormat() { + val json = + """{"method":"create_connection","params":{"pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","name":"Amethyst","request_methods":["pay_invoice","get_balance","get_info","make_invoice","lookup_invoice","list_transactions"],"notification_types":["payment_received","payment_sent"],"max_amount":100000,"budget_renewal":"monthly","isolated":false}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", request.params?.pubkey) + assertEquals("Amethyst", request.params?.name) + assertEquals(6, request.params?.request_methods?.size) + assertEquals(2, request.params?.notification_types?.size) + assertEquals(100000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + assertEquals(false, request.params?.isolated) + } + + // --- Transaction with settle_deadline from Alby hold invoice --- + + @Test + fun testAlbyMakeHoldInvoiceWithSettleDeadline() { + val json = + """{"result_type":"make_hold_invoice","result":{"type":"incoming","state":"PENDING","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"expires_at":2000,"settle_deadline":144}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txn = response.result + assertNotNull(txn) + assertEquals(144L, txn.settle_deadline) + assertEquals(NwcTransactionState.PENDING, txn.state) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt index f088a2dbd..4d76bb926 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -33,6 +33,9 @@ class NwcMethodTest { assertEquals("list_transactions", NwcMethod.LIST_TRANSACTIONS) assertEquals("get_balance", NwcMethod.GET_BALANCE) assertEquals("get_info", NwcMethod.GET_INFO) + assertEquals("get_budget", NwcMethod.GET_BUDGET) + assertEquals("sign_message", NwcMethod.SIGN_MESSAGE) + assertEquals("create_connection", NwcMethod.CREATE_CONNECTION) assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE) assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE) assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE) @@ -48,7 +51,7 @@ class NwcMethodTest { @Test fun testErrorCodeValues() { val codes = NwcErrorCode.entries - assertEquals(10, codes.size) + assertEquals(13, codes.size) assertEquals(NwcErrorCode.RATE_LIMITED, NwcErrorCode.valueOf("RATE_LIMITED")) assertEquals(NwcErrorCode.NOT_IMPLEMENTED, NwcErrorCode.valueOf("NOT_IMPLEMENTED")) assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, NwcErrorCode.valueOf("INSUFFICIENT_BALANCE")) @@ -58,9 +61,26 @@ class NwcMethodTest { assertEquals(NwcErrorCode.UNAUTHORIZED, NwcErrorCode.valueOf("UNAUTHORIZED")) assertEquals(NwcErrorCode.INTERNAL, NwcErrorCode.valueOf("INTERNAL")) assertEquals(NwcErrorCode.UNSUPPORTED_ENCRYPTION, NwcErrorCode.valueOf("UNSUPPORTED_ENCRYPTION")) + assertEquals(NwcErrorCode.BAD_REQUEST, NwcErrorCode.valueOf("BAD_REQUEST")) + assertEquals(NwcErrorCode.NOT_FOUND, NwcErrorCode.valueOf("NOT_FOUND")) + assertEquals(NwcErrorCode.EXPIRED, NwcErrorCode.valueOf("EXPIRED")) assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER")) } + @Test + fun testTransactionTypeConstants() { + assertEquals("incoming", NwcTransactionType.INCOMING) + assertEquals("outgoing", NwcTransactionType.OUTGOING) + } + + @Test + fun testTransactionStateConstants() { + assertEquals("PENDING", NwcTransactionState.PENDING) + assertEquals("SETTLED", NwcTransactionState.SETTLED) + assertEquals("FAILED", NwcTransactionState.FAILED) + assertEquals("ACCEPTED", NwcTransactionState.ACCEPTED) + } + @Test fun testNwcError() { val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt index 630f7addf..3299b0927 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -288,6 +288,97 @@ class RequestTest { assertEquals("preimage_xyz", request.params?.preimage) } + // --- GetBudget --- + + @Test + fun testGetBudgetCreate() { + val request = GetBudgetMethod.create() + assertEquals(NwcMethod.GET_BUDGET, request.method) + } + + @Test + fun testGetBudgetSerialization() { + val request = GetBudgetMethod.create() + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"get_budget\"")) + } + + @Test + fun testGetBudgetDeserialization() { + val json = """{"method":"get_budget"}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + } + + // --- SignMessage --- + + @Test + fun testSignMessageCreate() { + val request = SignMessageMethod.create("Hello Nostr") + assertEquals(NwcMethod.SIGN_MESSAGE, request.method) + assertEquals("Hello Nostr", request.params?.message) + } + + @Test + fun testSignMessageSerialization() { + val request = SignMessageMethod.create("test message") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"sign_message\"")) + assertTrue(json.contains("\"message\":\"test message\"")) + } + + @Test + fun testSignMessageDeserialization() { + val json = """{"method":"sign_message","params":{"message":"Hello Nostr"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Hello Nostr", request.params?.message) + } + + // --- CreateConnection --- + + @Test + fun testCreateConnectionCreate() { + val request = + CreateConnectionMethod.create( + pubkey = "abc123", + name = "My App", + requestMethods = listOf("pay_invoice", "get_balance"), + notificationTypes = listOf("payment_received"), + maxAmount = 100000L, + budgetRenewal = "monthly", + ) + assertEquals(NwcMethod.CREATE_CONNECTION, request.method) + assertEquals("abc123", request.params?.pubkey) + assertEquals("My App", request.params?.name) + assertEquals(listOf("pay_invoice", "get_balance"), request.params?.request_methods) + assertEquals(listOf("payment_received"), request.params?.notification_types) + assertEquals(100000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + } + + @Test + fun testCreateConnectionSerialization() { + val request = CreateConnectionMethod.create(pubkey = "abc123", name = "My App") + val json = OptimizedJsonMapper.toJson(request) + assertTrue(json.contains("\"method\":\"create_connection\"")) + assertTrue(json.contains("\"pubkey\":\"abc123\"")) + assertTrue(json.contains("\"name\":\"My App\"")) + } + + @Test + fun testCreateConnectionDeserialization() { + val json = + """{"method":"create_connection","params":{"pubkey":"abc123","name":"Test App","request_methods":["pay_invoice"],"max_amount":50000,"budget_renewal":"monthly"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("abc123", request.params?.pubkey) + assertEquals("Test App", request.params?.name) + assertEquals(listOf("pay_invoice"), request.params?.request_methods) + assertEquals(50000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + } + // --- Unknown method --- @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt index 6bf160d6a..148483360 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -255,6 +255,117 @@ class ResponseTest { assertIs(response) } + // --- GetBudget Success --- + + @Test + fun testGetBudgetSuccessDeserialization() { + val json = """{"result_type":"get_budget","result":{"used_budget":50000,"total_budget":100000,"renews_at":1700000000,"renewal_period":"monthly"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals(50000L, response.result?.used_budget) + assertEquals(100000L, response.result?.total_budget) + assertEquals(1700000000L, response.result?.renews_at) + assertEquals("monthly", response.result?.renewal_period) + } + + @Test + fun testGetBudgetNoBudgetLimit() { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":0,"renewal_period":"never"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(0L, response.result?.used_budget) + assertEquals(0L, response.result?.total_budget) + assertNull(response.result?.renews_at) + assertEquals("never", response.result?.renewal_period) + } + + // --- SignMessage Success --- + + @Test + fun testSignMessageSuccessDeserialization() { + val json = """{"result_type":"sign_message","result":{"message":"Hello Nostr","signature":"sig123abc"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("Hello Nostr", response.result?.message) + assertEquals("sig123abc", response.result?.signature) + } + + // --- CreateConnection Success --- + + @Test + fun testCreateConnectionSuccessDeserialization() { + val json = """{"result_type":"create_connection","result":{"wallet_pubkey":"walletpub123"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result) + assertEquals("walletpub123", response.result?.wallet_pubkey) + } + + // --- GetInfo with extended fields --- + + @Test + fun testGetInfoWithMetadataAndLud16() { + val json = + """{"result_type":"get_info","result":{"alias":"AlbyHub","methods":["pay_invoice","get_balance"],"notifications":["payment_received"],"lud16":"user@getalby.com"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("AlbyHub", response.result?.alias) + assertEquals("user@getalby.com", response.result?.lud16) + assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods) + } + + // --- ListTransactions with total_count --- + + @Test + fun testListTransactionsWithTotalCount() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","amount":100,"created_at":1000}],"total_count":42}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(1, response.result?.transactions?.size) + assertEquals(42L, response.result?.total_count) + } + + // --- Transaction with settle_deadline --- + + @Test + fun testTransactionWithSettleDeadline() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"ACCEPTED","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settle_deadline":800000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(800000L, response.result?.settle_deadline) + assertEquals("ACCEPTED", response.result?.state) + } + + // --- Error responses for new error codes --- + + @Test + fun testBadRequestError() { + val json = """{"result_type":"pay_invoice","error":{"code":"BAD_REQUEST","message":"Invalid invoice"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.BAD_REQUEST, response.error?.code) + } + + @Test + fun testNotFoundError() { + val json = """{"result_type":"lookup_invoice","error":{"code":"NOT_FOUND","message":"Invoice not found"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.NOT_FOUND, response.error?.code) + } + + @Test + fun testExpiredError() { + val json = """{"result_type":"pay_invoice","error":{"code":"EXPIRED","message":"Connection expired"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(NwcErrorCode.EXPIRED, response.error?.code) + } + // --- Null/missing result --- @Test diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt index 4098a5dc9..07e6eac96 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt @@ -25,7 +25,9 @@ import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod @@ -36,6 +38,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod import com.vitorpamplona.quartz.utils.asTextOrNull class RequestDeserializer : StdDeserializer(Request::class.java) { @@ -54,6 +57,9 @@ class RequestDeserializer : StdDeserializer(Request::class.java) { NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsMethod::class.java) NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceMethod::class.java) NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoMethod::class.java) + NwcMethod.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetMethod::class.java) + NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageMethod::class.java) + NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionMethod::class.java) NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceMethod::class.java) NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceMethod::class.java) NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceMethod::class.java) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt index 72e7685fb..c9b210ad9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt @@ -25,7 +25,9 @@ import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse @@ -39,6 +41,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse import com.vitorpamplona.quartz.utils.asTextOrNull class ResponseDeserializer : StdDeserializer(Response::class.java) { @@ -70,6 +73,9 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java) NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java) NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java) + NwcMethod.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java) + NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java) + NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::class.java) NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java) NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java) NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java) From 4ee937ff0c1821caf7ffa8d6501446cb40e7021a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 03:00:56 +0000 Subject: [PATCH 017/130] test: add Alby Hub real test vectors to interop tests Add tests using exact JSON payloads from Alby Hub's test suite: - Real bolt11 invoice strings from Alby's mock data - pay_keysend with TLV records and preimage - make_invoice with nested metadata objects - make_hold_invoice with 64-char payment hash - settle_hold_invoice with preimage - list_transactions with unpaid_outgoing filter - create_connection with isolated=true https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../nip47WalletConnect/AlbyInteropTest.kt | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index babb67d67..8fea0211b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -309,6 +309,88 @@ class AlbyInteropTest { assertEquals(false, request.params?.isolated) } + // --- Alby Hub real bolt11 test vectors --- + + @Test + fun testAlbyRealBolt11PayInvoiceRequest() { + val json = + """{"method":"pay_invoice","params":{"invoice":"lntbs1230n1pnkqautdqyw3jsnp4q09a0z84kg4a2m38zjllw43h953fx5zvqe8qxfgw694ymkq26u8zcpp5yvnh6hsnlnj4xnuh2trzlnunx732dv8ta2wjr75pdfxf6p2vlyassp5hyeg97a3ft5u769kjwsn7p0e85h79pzz8kladmnqhpcypz2uawjs9qyysgqcqpcxq8zals8sq9yeg2pa9eywkgj50cyzxd5elatujuc0c0wh6j9nat5mn34pgk8u9ufpgs99tw9ldlfk42cqlkr48au3lmuh09269prg4qkggh4a8cyqpfl0y6j","metadata":{"a":123}}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertNotNull(request.params?.invoice) + assertNotNull(request.params?.metadata) + } + + @Test + fun testAlbyPayKeysendWithTlvRecords() { + val json = + """{"method":"pay_keysend","params":{"amount":123000,"pubkey":"123pubkey2","preimage":"018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b","tlv_records":[{"type":5482373484,"value":"fajsn341414fq"}]}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(123000L, request.params?.amount) + assertEquals("123pubkey2", request.params?.pubkey) + assertEquals("018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b", request.params?.preimage) + assertNotNull(request.params?.tlv_records) + assertEquals(1, request.params?.tlv_records?.size) + assertEquals(5482373484L, request.params?.tlv_records?.first()?.type) + assertEquals("fajsn341414fq", request.params?.tlv_records?.first()?.value) + } + + @Test + fun testAlbyMakeInvoiceWithNestedMetadata() { + val json = + """{"method":"make_invoice","params":{"amount":1000,"description":"Hello, world","expiry":3600,"metadata":{"a":1,"b":"2","c":{"d":3,"e":[{"f":"g"},{"h":"i"}]}}}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("Hello, world", request.params?.description) + assertEquals(3600L, request.params?.expiry) + assertNotNull(request.params?.metadata) + } + + @Test + fun testAlbyMakeHoldInvoiceWithPaymentHash() { + val json = + """{"method":"make_hold_invoice","params":{"amount":1000,"description":"Hello, world","payment_hash":"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","expiry":3600}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(1000L, request.params?.amount) + assertEquals("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", request.params?.payment_hash) + assertEquals("Hello, world", request.params?.description) + } + + @Test + fun testAlbySettleHoldInvoiceWithPreimage() { + val json = + """{"method":"settle_hold_invoice","params":{"preimage":"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", request.params?.preimage) + } + + @Test + fun testAlbyListTransactionsWithUnpaidFilters() { + val json = """{"method":"list_transactions","params":{"from":0,"until":0,"limit":10,"offset":0,"unpaid_outgoing":true}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals(10, request.params?.limit) + assertEquals(true, request.params?.unpaid_outgoing) + } + + @Test + fun testAlbyCreateConnectionIsolated() { + val json = + """{"method":"create_connection","params":{"pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","name":"Test 123","request_methods":["get_info","pay_invoice"],"notification_types":["payment_received"],"max_amount":100000000,"budget_renewal":"monthly","isolated":true}}""" + val request = OptimizedJsonMapper.fromJsonTo(json) + assertIs(request) + assertEquals("Test 123", request.params?.name) + assertEquals(true, request.params?.isolated) + assertEquals(100000000L, request.params?.max_amount) + assertEquals("monthly", request.params?.budget_renewal) + assertEquals(listOf("get_info", "pay_invoice"), request.params?.request_methods) + assertEquals(listOf("payment_received"), request.params?.notification_types) + } + // --- Transaction with settle_deadline from Alby hold invoice --- @Test From 83a6feeca40b3b2c31a363487e8eec9e8c53c7c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 03:28:17 +0000 Subject: [PATCH 018/130] feat: add Alby JS SDK client interop improvements - Add NwcBudgetRenewal constants (daily/weekly/monthly/yearly/never) - Add case-insensitive NwcTransactionState helpers (isSettled, isPending, isFailed, isAccepted) for JS SDK lowercase state interop - Add URI test using Alby JS SDK test vector (69effe7b... pubkey) - Add JS SDK interop tests: lowercase transaction states in responses, notifications, and list_transactions; empty get_budget response; all budget renewal periods; structured transaction metadata; full 13-method get_info response https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../nip47WalletConnect/NwcTransaction.kt | 16 +++ .../nip47WalletConnect/AlbyInteropTest.kt | 101 +++++++++++++++++- .../Nip47WalletConnectTest.kt | 14 +++ .../nip47WalletConnect/NwcMethodTest.kt | 26 +++++ 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt index 9ba84991e..067f12cd9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -30,6 +30,22 @@ object NwcTransactionState { const val SETTLED = "SETTLED" const val FAILED = "FAILED" const val ACCEPTED = "ACCEPTED" + + fun isSettled(state: String?) = state.equals(SETTLED, ignoreCase = true) + + fun isPending(state: String?) = state.equals(PENDING, ignoreCase = true) + + fun isFailed(state: String?) = state.equals(FAILED, ignoreCase = true) + + fun isAccepted(state: String?) = state.equals(ACCEPTED, ignoreCase = true) +} + +object NwcBudgetRenewal { + const val DAILY = "daily" + const val WEEKLY = "weekly" + const val MONTHLY = "monthly" + const val YEARLY = "yearly" + const val NEVER = "never" } class NwcTransaction( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index 8fea0211b..c201ab285 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -26,10 +26,12 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue /** - * Interoperability tests using JSON structures matching Alby Hub's NIP-47 implementation. - * These tests verify that Amethyst can correctly parse responses from Alby Hub wallets. + * Interoperability tests using JSON structures matching Alby Hub (server) + * and Alby JS SDK (client) NIP-47 implementations. + * These tests verify that Amethyst can correctly parse responses from Alby wallets. */ class AlbyInteropTest { // --- Alby Hub pay_invoice response format --- @@ -404,4 +406,99 @@ class AlbyInteropTest { assertEquals(144L, txn.settle_deadline) assertEquals(NwcTransactionState.PENDING, txn.state) } + + // =================================================================== + // Alby JS SDK (client) interop tests + // The JS SDK uses lowercase transaction states while Hub uses uppercase. + // Both formats must be handled correctly. + // =================================================================== + + @Test + fun testJsSdkLowercaseSettledState() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("settled", response.result?.state) + assertTrue(NwcTransactionState.isSettled(response.result?.state)) + } + + @Test + fun testJsSdkLowercasePendingState() { + val json = + """{"result_type":"make_invoice","result":{"type":"incoming","state":"pending","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals("pending", response.result?.state) + assertTrue(NwcTransactionState.isPending(response.result?.state)) + } + + @Test + fun testJsSdkLowercaseStatesInListTransactions() { + val json = + """{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"settled","amount":1000,"created_at":1000},{"type":"outgoing","state":"failed","amount":2000,"created_at":2000},{"type":"incoming","state":"accepted","amount":3000,"created_at":3000}],"total_count":3}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val txns = response.result?.transactions + assertNotNull(txns) + assertEquals(3, txns.size) + assertTrue(NwcTransactionState.isSettled(txns[0].state)) + assertTrue(NwcTransactionState.isFailed(txns[1].state)) + assertTrue(NwcTransactionState.isAccepted(txns[2].state)) + } + + @Test + fun testJsSdkLowercaseStatesInNotification() { + val json = + """{"notification_type":"payment_received","notification":{"type":"incoming","state":"settled","invoice":"lnbc...","amount":5000,"created_at":1000,"settled_at":2000}}""" + val notification = OptimizedJsonMapper.fromJsonTo(json) + assertIs(notification) + assertTrue(NwcTransactionState.isSettled(notification.notification?.state)) + } + + @Test + fun testJsSdkEmptyGetBudgetResponse() { + // JS SDK allows get_budget to return empty object when no budget is set + val json = """{"result_type":"get_budget","result":{}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNull(response.result?.used_budget) + assertNull(response.result?.total_budget) + assertNull(response.result?.renews_at) + assertNull(response.result?.renewal_period) + } + + @Test + fun testJsSdkGetBudgetWithAllRenewalPeriods() { + for (period in listOf("daily", "weekly", "monthly", "yearly", "never")) { + val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":100000,"renewal_period":"$period"}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(period, response.result?.renewal_period) + } + } + + @Test + fun testJsSdkTransactionWithMetadata() { + // JS SDK supports structured metadata with comment, payer_data, nostr fields + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Thanks!","payer_data":{"name":"Alice","pubkey":"abc123"},"nostr":{"pubkey":"npub1...","tags":[["p","def456"]]}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertNotNull(response.result?.metadata) + } + + @Test + fun testJsSdkGetInfoWithAllMethods() { + // JS SDK advertises all 13 single methods + notifications + val json = + """{"result_type":"get_info","result":{"alias":"TestNode","methods":["get_info","get_balance","get_budget","make_invoice","pay_invoice","pay_keysend","lookup_invoice","list_transactions","sign_message","create_connection","make_hold_invoice","settle_hold_invoice","cancel_hold_invoice"],"notifications":["payment_received","payment_sent","hold_invoice_accepted"]}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + assertEquals(13, response.result?.methods?.size) + assertEquals(3, response.result?.notifications?.size) + assertTrue(response.result?.methods?.contains(NwcMethod.GET_BUDGET) == true) + assertTrue(response.result?.methods?.contains(NwcMethod.SIGN_MESSAGE) == true) + assertTrue(response.result?.methods?.contains(NwcMethod.CREATE_CONNECTION) == true) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt index c31c376d7..825069de6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47WalletConnectTest.kt @@ -93,6 +93,20 @@ class Nip47WalletConnectTest { } } + // --- Alby JS SDK URI test vector --- + + @Test + fun testParseAlbyJsSdkUri() { + // Test vector from @getalby/js-sdk NWCClient.test.ts + val uri = + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss%3A%2F%2Frelay.getalby.com%2Fv1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b&lud16=hello%40getalby.com" + val parsed = Nip47WalletConnect.parse(uri) + + assertEquals("69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", parsed.pubKeyHex) + assertEquals("e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", parsed.secret) + assertEquals("hello@getalby.com", parsed.lud16) + } + // --- Nip47URI serialization --- @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt index 4d76bb926..f8806444b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcMethodTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip47WalletConnect import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class NwcMethodTest { @Test @@ -81,6 +83,30 @@ class NwcMethodTest { assertEquals("ACCEPTED", NwcTransactionState.ACCEPTED) } + @Test + fun testTransactionStateCaseInsensitive() { + // Alby Hub uses uppercase, Alby JS SDK uses lowercase + assertTrue(NwcTransactionState.isSettled("SETTLED")) + assertTrue(NwcTransactionState.isSettled("settled")) + assertTrue(NwcTransactionState.isPending("PENDING")) + assertTrue(NwcTransactionState.isPending("pending")) + assertTrue(NwcTransactionState.isFailed("FAILED")) + assertTrue(NwcTransactionState.isFailed("failed")) + assertTrue(NwcTransactionState.isAccepted("ACCEPTED")) + assertTrue(NwcTransactionState.isAccepted("accepted")) + assertFalse(NwcTransactionState.isSettled("pending")) + assertFalse(NwcTransactionState.isSettled(null)) + } + + @Test + fun testBudgetRenewalConstants() { + assertEquals("daily", NwcBudgetRenewal.DAILY) + assertEquals("weekly", NwcBudgetRenewal.WEEKLY) + assertEquals("monthly", NwcBudgetRenewal.MONTHLY) + assertEquals("yearly", NwcBudgetRenewal.YEARLY) + assertEquals("never", NwcBudgetRenewal.NEVER) + } + @Test fun testNwcError() { val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed") From 4b76c70c29fcce8ecd9402afc8c6141c5f23ea86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 03:52:41 +0000 Subject: [PATCH 019/130] feat: add NIP-47 wallet interface with balance, send, receive, and transactions Adds an Alby Go-inspired wallet interface accessible from the left drawer menu. Uses existing NWC connection from zap settings to communicate with the wallet via NIP-47 methods (get_balance, get_info, pay_invoice, make_invoice, list_transactions). New files: - WalletViewModel: manages wallet state and NWC requests - WalletScreen: balance display with send/receive action buttons - WalletSendScreen: paste BOLT-11 invoice and pay - WalletReceiveScreen: create invoice with QR code display - WalletTransactionsScreen: scrollable transaction history Also adds generic sendNwcRequest() to NwcSignerState and Account for sending arbitrary NIP-47 requests beyond just pay_invoice. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../vitorpamplona/amethyst/model/Account.kt | 9 + .../nip47WalletConnect/NwcSignerState.kt | 39 +++ .../amethyst/ui/navigation/AppNavigation.kt | 9 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 8 + .../loggedIn/wallet/WalletReceiveScreen.kt | 268 +++++++++++++++++ .../ui/screen/loggedIn/wallet/WalletScreen.kt | 279 ++++++++++++++++++ .../loggedIn/wallet/WalletSendScreen.kt | 219 ++++++++++++++ .../wallet/WalletTransactionsScreen.kt | 226 ++++++++++++++ .../screen/loggedIn/wallet/WalletViewModel.kt | 264 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 23 ++ 11 files changed, 1353 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 055cb6ae2..ca79bcf40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -171,6 +171,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -581,6 +582,14 @@ class Account( suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState) + suspend fun sendNwcRequest( + request: Request, + onResponse: (Response?) -> Unit, + ) { + val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse) + client.send(event, setOf(relay)) + } + suspend fun sendZapPaymentRequestFor( bolt11: String, zappedNote: Note?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index d4f012cbb..206f9bbfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -138,6 +138,45 @@ class NwcSignerState( return zapPaymentResponseDecryptionCache.value.decryptResponse(event) } + /** + * Sends a generic NIP-47 request to the connected wallet. + * Subscribes to responses and waits up to 60s for a reply. + * + * @param request the NIP-47 request to send + * @param onResponse callback to handle the response from the wallet + * @return a pair containing the request event and target relay URL + * @throws IllegalArgumentException if no NIP-47 wallet is set up + */ + suspend fun sendNwcRequest( + request: Request, + onResponse: (Response?) -> Unit, + ): Pair { + val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup") + + val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value) + + val filter = + NWCPaymentQueryState( + fromServiceHex = walletService.pubKeyHex, + toUserHex = event.pubKey, + replyingToHex = event.id, + relay = walletService.relayUri, + ) + + nwcFilterAssembler.subscribe(filter) + + scope.launch(Dispatchers.IO) { + delay(60000) + nwcFilterAssembler.unsubscribe(filter) + } + + cache.consume(event, null, true, walletService.relayUri) { + onResponse(decryptResponse(it)) + } + + return Pair(event, walletService.relayUri) + } + /** * Sends a zap payment request to a connected Lightning wallet. * Subscribes to responses and waits up to 60s for a reply. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 2bb646e98..c4d9d37e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -119,6 +119,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.uriToRoute import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId @@ -151,6 +155,11 @@ fun AppNavigation( composable { NotificationScreen(accountViewModel, nav) } composable { ChessLobbyScreen(accountViewModel, nav) } + composableFromEnd { WalletScreen(accountViewModel, nav) } + composableFromEnd { WalletSendScreen(accountViewModel, nav) } + composableFromEnd { WalletReceiveScreen(accountViewModel, nav) } + composableFromEnd { WalletTransactionsScreen(accountViewModel, nav) } + composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } composableFromEndArgs { FollowPackScreen(it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 8ca73fb97..a3682c723 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -463,6 +464,14 @@ fun ListContent( route = Route.Drafts, ) + NavigationRow( + title = R.string.wallet, + icon = Icons.Outlined.AccountBalanceWallet, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Wallet, + ) + NavigationRow( title = R.string.route_chess, icon = R.drawable.ic_chess, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b55b4dd06..b7191759c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -43,6 +43,14 @@ sealed class Route { @Serializable object Chess : Route() + @Serializable object Wallet : Route() + + @Serializable object WalletSend : Route() + + @Serializable object WalletReceive : Route() + + @Serializable object WalletTransactions : Route() + @Serializable object Search : Route() @Serializable object SecurityFilters : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt new file mode 100644 index 000000000..c0451ede7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletReceiveScreen.kt @@ -0,0 +1,268 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +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.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.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.stringRes +import java.text.NumberFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletReceiveScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + } + + DisposableEffect(Unit) { + onDispose { walletViewModel.resetReceiveState() } + } + + val receiveState by walletViewModel.receiveState.collectAsState() + var amountText by remember { mutableStateOf("") } + var descriptionText by remember { mutableStateOf("") } + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_receive)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (val state = receiveState) { + is ReceiveState.Idle -> { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = amountText, + onValueChange = { amountText = it.filter { c -> c.isDigit() } }, + label = { Text(stringRes(R.string.wallet_amount_sats)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = descriptionText, + onValueChange = { descriptionText = it }, + label = { Text(stringRes(R.string.wallet_description)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + val amount = amountText.toLongOrNull() + if (amount != null && amount > 0) { + walletViewModel.createInvoice( + amountSats = amount, + description = descriptionText.ifBlank { null }, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + enabled = amountText.isNotBlank() && (amountText.toLongOrNull() ?: 0L) > 0, + ) { + Text( + stringRes(R.string.wallet_create_invoice), + fontWeight = FontWeight.SemiBold, + ) + } + } + + is ReceiveState.Creating -> { + Spacer(modifier = Modifier.weight(1f)) + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_creating_invoice), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } + + is ReceiveState.Created -> { + Spacer(modifier = Modifier.height(8.dp)) + + val formattedAmount = + remember(state.amount) { + val fmt = NumberFormat.getIntegerInstance() + fmt.format(state.amount) + } + + Text( + text = "$formattedAmount ${stringRes(R.string.wallet_sats)}", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + QrCodeDrawer( + contents = state.invoice, + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = state.invoice, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("invoice", state.invoice)) + }, + modifier = + Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_copy_invoice)) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + is ReceiveState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { walletViewModel.resetReceiveState() }) { + Text(stringRes(R.string.back)) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt new file mode 100644 index 000000000..ef0d94800 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -0,0 +1,279 @@ +/* + * 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.ui.screen.loggedIn.wallet + +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.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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import java.text.NumberFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + if (!walletViewModel.hasWalletSetup()) { + NoWalletSetup( + modifier = Modifier.padding(padding), + nav = nav, + ) + } else { + WalletHomeContent( + walletViewModel = walletViewModel, + modifier = Modifier.padding(padding), + nav = nav, + ) + } + } +} + +@Composable +private fun NoWalletSetup( + modifier: Modifier, + nav: INav, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringRes(R.string.wallet_no_connection), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringRes(R.string.wallet_no_connection_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = { nav.nav(Route.Nip47NWCSetup()) }) { + Text(stringRes(R.string.wallet_setup)) + } + } +} + +@Composable +private fun WalletHomeContent( + walletViewModel: WalletViewModel, + modifier: Modifier, + nav: INav, +) { + val balance by walletViewModel.balanceSats.collectAsState() + val walletAlias by walletViewModel.walletAlias.collectAsState() + val isLoading by walletViewModel.isLoading.collectAsState() + val error by walletViewModel.error.collectAsState() + + LaunchedEffect(Unit) { + walletViewModel.fetchBalance() + walletViewModel.fetchInfo() + } + + Column( + modifier = + modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // Wallet name + if (walletAlias != null) { + Text( + text = walletAlias!!, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Balance display + if (isLoading && balance == null) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } else { + val formattedBalance = + remember(balance) { + val fmt = NumberFormat.getIntegerInstance() + fmt.format(balance ?: 0L) + } + Text( + text = formattedBalance, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringRes(R.string.wallet_sats), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Error + if (error != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button( + onClick = { nav.nav(Route.WalletReceive) }, + modifier = + Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.ArrowDownward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_receive), fontWeight = FontWeight.SemiBold) + } + + Button( + onClick = { nav.nav(Route.WalletSend) }, + modifier = + Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_send), fontWeight = FontWeight.SemiBold) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Transactions button + OutlinedButton( + onClick = { nav.nav(Route.WalletTransactions) }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.List, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.wallet_transactions)) + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt new file mode 100644 index 000000000..ca5bd2609 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletSendScreen.kt @@ -0,0 +1,219 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletSendScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + } + + DisposableEffect(Unit) { + onDispose { walletViewModel.resetSendState() } + } + + val sendState by walletViewModel.sendState.collectAsState() + var invoiceText by remember { mutableStateOf("") } + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_send)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (val state = sendState) { + is SendState.Idle -> { + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = invoiceText, + onValueChange = { invoiceText = it }, + label = { Text(stringRes(R.string.wallet_paste_invoice)) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + minLines = 3, + maxLines = 5, + trailingIcon = { + IconButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = clipboard.primaryClip + if (clip != null && clip.itemCount > 0) { + invoiceText = clip.getItemAt(0).text?.toString() ?: "" + } + }) { + Icon( + imageVector = Icons.Filled.ContentPaste, + contentDescription = "Paste", + ) + } + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + if (invoiceText.isNotBlank()) { + walletViewModel.sendPayment(invoiceText.trim()) + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + enabled = invoiceText.isNotBlank(), + ) { + Text( + stringRes(R.string.wallet_pay), + fontWeight = FontWeight.SemiBold, + ) + } + } + + is SendState.Sending -> { + Spacer(modifier = Modifier.weight(1f)) + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_payment_sending), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.weight(1f)) + } + + is SendState.Success -> { + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_payment_success), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { nav.popBack() }, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Text(stringRes(R.string.back)) + } + Spacer(modifier = Modifier.height(24.dp)) + } + + is SendState.Error -> { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + state.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { walletViewModel.resetSendState() }) { + Text(stringRes(R.string.back)) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt new file mode 100644 index 000000000..a7df6653b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -0,0 +1,226 @@ +/* + * 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.ui.screen.loggedIn.wallet + +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.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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransactionType +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletTransactionsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletViewModel: WalletViewModel = viewModel() + + LaunchedEffect(accountViewModel) { + walletViewModel.init(accountViewModel.account) + walletViewModel.fetchTransactions() + } + + val transactions by walletViewModel.transactions.collectAsState() + val isLoading by walletViewModel.isLoading.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.wallet_transactions)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + actions = { + IconButton(onClick = { walletViewModel.fetchTransactions() }) { + Icon( + imageVector = Icons.Filled.Refresh, + contentDescription = stringRes(R.string.wallet_refresh), + ) + } + }, + ) + }, + ) { padding -> + if (isLoading && transactions.isEmpty()) { + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + stringRes(R.string.wallet_loading), + style = MaterialTheme.typography.bodyLarge, + ) + } + } else if (transactions.isEmpty()) { + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + stringRes(R.string.wallet_no_transactions), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.padding(padding), + ) { + items(transactions) { tx -> + TransactionItem(tx) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun TransactionItem(tx: NwcTransaction) { + val isIncoming = tx.type == NwcTransactionType.INCOMING + val amountSats = (tx.amount ?: 0L) / 1000L + val formattedAmount = + remember(amountSats) { + val fmt = NumberFormat.getIntegerInstance() + (if (isIncoming) "+" else "-") + fmt.format(amountSats) + } + + val dateText = + remember(tx.created_at) { + tx.created_at?.let { + val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) + sdf.format(Date(it * 1000L)) + } ?: "" + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = + if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, + contentDescription = + if (isIncoming) { + stringRes(R.string.wallet_incoming) + } else { + stringRes(R.string.wallet_outgoing) + }, + modifier = Modifier.size(24.dp), + tint = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = dateText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = "$formattedAmount sats", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt new file mode 100644 index 000000000..817c296e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -0,0 +1,264 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed class SendState { + data object Idle : SendState() + + data object Sending : SendState() + + data class Success( + val preimage: String?, + ) : SendState() + + data class Error( + val message: String, + ) : SendState() +} + +sealed class ReceiveState { + data object Idle : ReceiveState() + + data object Creating : ReceiveState() + + data class Created( + val invoice: String, + val amount: Long, + ) : ReceiveState() + + data class Error( + val message: String, + ) : ReceiveState() +} + +class WalletViewModel : ViewModel() { + private var account: Account? = null + + private val _balanceSats = MutableStateFlow(null) + val balanceSats = _balanceSats.asStateFlow() + + private val _walletAlias = MutableStateFlow(null) + val walletAlias = _walletAlias.asStateFlow() + + private val _transactions = MutableStateFlow>(emptyList()) + val transactions = _transactions.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + private val _sendState = MutableStateFlow(SendState.Idle) + val sendState = _sendState.asStateFlow() + + private val _receiveState = MutableStateFlow(ReceiveState.Idle) + val receiveState = _receiveState.asStateFlow() + + fun init(account: Account) { + this.account = account + } + + fun hasWalletSetup(): Boolean = account?.nip47SignerState?.hasWalletConnectSetup() == true + + fun fetchBalance() { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + _error.value = null + try { + acc.sendNwcRequest(GetBalanceMethod.create()) { response -> + when (response) { + is GetBalanceSuccessResponse -> { + // NWC balance is in millisats, convert to sats + _balanceSats.value = (response.result?.balance ?: 0L) / 1000L + } + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Balance request failed" + } + else -> {} + } + _isLoading.value = false + } + } catch (e: Exception) { + _error.value = e.message + _isLoading.value = false + } + } + } + + fun fetchInfo() { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + try { + acc.sendNwcRequest(GetInfoMethod.create()) { response -> + when (response) { + is GetInfoSuccessResponse -> { + _walletAlias.value = response.result?.alias + } + else -> {} + } + } + } catch (e: Exception) { + // ignore info errors + } + } + } + + fun fetchTransactions( + limit: Int = 20, + offset: Int = 0, + ) { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + try { + acc.sendNwcRequest( + ListTransactionsMethod.create( + limit = limit, + offset = offset, + unpaid = false, + ), + ) { response -> + when (response) { + is ListTransactionsSuccessResponse -> { + _transactions.value = response.result?.transactions ?: emptyList() + } + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Failed to load transactions" + } + else -> {} + } + _isLoading.value = false + } + } catch (e: Exception) { + _error.value = e.message + _isLoading.value = false + } + } + } + + fun sendPayment(bolt11: String) { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _sendState.value = SendState.Sending + try { + acc.sendNwcRequest(PayInvoiceMethod.create(bolt11)) { response -> + when (response) { + is PayInvoiceSuccessResponse -> { + _sendState.value = SendState.Success(response.result?.preimage) + // Refresh balance after payment + fetchBalance() + } + is PayInvoiceErrorResponse -> { + _sendState.value = SendState.Error( + response.error?.message ?: "Payment failed", + ) + } + is NwcErrorResponse -> { + _sendState.value = SendState.Error( + response.error?.message ?: "Payment failed", + ) + } + else -> { + _sendState.value = SendState.Error("Unexpected response") + } + } + } + } catch (e: Exception) { + _sendState.value = SendState.Error(e.message ?: "Payment failed") + } + } + } + + fun createInvoice( + amountSats: Long, + description: String? = null, + ) { + val acc = account ?: return + viewModelScope.launch(Dispatchers.IO) { + _receiveState.value = ReceiveState.Creating + try { + // NWC expects millisats + acc.sendNwcRequest( + MakeInvoiceMethod.create( + amount = amountSats * 1000L, + description = description, + ), + ) { response -> + when (response) { + is MakeInvoiceSuccessResponse -> { + val invoice = response.result?.invoice + if (invoice != null) { + _receiveState.value = ReceiveState.Created(invoice, amountSats) + } else { + _receiveState.value = ReceiveState.Error("No invoice returned") + } + } + is NwcErrorResponse -> { + _receiveState.value = ReceiveState.Error( + response.error?.message ?: "Invoice creation failed", + ) + } + else -> { + _receiveState.value = ReceiveState.Error("Unexpected response") + } + } + } + } catch (e: Exception) { + _receiveState.value = ReceiveState.Error(e.message ?: "Invoice creation failed") + } + } + } + + fun resetSendState() { + _sendState.value = SendState.Idle + } + + fun resetReceiveState() { + _receiveState.value = ReceiveState.Idle + } + + fun clearError() { + _error.value = null + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 72f3724dc..48d86db71 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1236,6 +1236,29 @@ Global Shorts Chess + Wallet + Balance + Send + Receive + Transactions + No wallet connected + Set up a Nostr Wallet Connect (NWC) connection in your zap settings to use the wallet. + Set Up Wallet + sats + Paste a BOLT-11 invoice + Pay + Payment successful + Sending payment… + Amount (sats) + Description (optional) + Create Invoice + Creating invoice… + Copy Invoice + No transactions yet + Loading… + Received + Sent + Refresh Security Filters Import Follows From 580678eed86a93d50e50f1b555e635eb970baee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 04:25:27 +0000 Subject: [PATCH 020/130] feat: add server-side event builders and NIP-47 README for quartz Adds missing server-side capabilities to the NIP-47 quartz module: - LnZapPaymentResponseEvent.createResponse(): builds encrypted response events (kind 23195) for wallet services to reply to client requests - NwcNotificationEvent.createNotification(): builds encrypted notification events (kind 23197) for wallet services to push payment notifications Creates comprehensive README.md documenting the full NIP-47 API with code examples for both wallet client and wallet service implementations, covering URI parsing, request/response building, encryption, notifications, error handling, transaction states, and caching. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../LnZapPaymentResponseEvent.kt | 41 +- .../NwcNotificationEvent.kt | 30 ++ .../quartz/nip47WalletConnect/README.md | 480 ++++++++++++++++++ 3 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt index 9a7de947c..a75ba45e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentResponseEvent.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class LnZapPaymentResponseEvent( @@ -55,6 +57,43 @@ class LnZapPaymentResponseEvent( companion object { const val KIND = 23195 - const val ALT = "Zap payment response" + const val ALT = "NWC response" + + /** + * Creates an NWC response event (server-side). + * + * @param response the NWC response object to send + * @param requestEvent the original request event being responded to + * @param signer the wallet service signer + * @param useNip44 whether to use NIP-44 encryption (default: false for NIP-04) + * @param createdAt event timestamp + */ + suspend fun createResponse( + response: Response, + requestEvent: LnZapPaymentRequestEvent, + signer: NostrSigner, + useNip44: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): LnZapPaymentResponseEvent { + val serializedResponse = OptimizedJsonMapper.toJson(response) + + val clientPubkey = requestEvent.pubKey + + val tags = + arrayOf( + arrayOf("p", clientPubkey), + arrayOf("e", requestEvent.id), + AltTag.assemble(ALT), + ) + + val encrypted = + if (useNip44) { + signer.nip44Encrypt(serializedResponse, clientPubkey) + } else { + signer.nip04Encrypt(serializedResponse, clientPubkey) + } + + return signer.sign(createdAt, KIND, tags, encrypted) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt index 77b16a3ec..9e9af6b2c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.utils.TimeUtils @Immutable class NwcNotificationEvent( @@ -54,5 +56,33 @@ class NwcNotificationEvent( const val KIND = 23197 const val LEGACY_KIND = 23196 const val ALT = "Wallet notification" + + /** + * Creates an NWC notification event (server-side). + * Uses NIP-44 encryption (kind 23197). + * + * @param notification the notification to send + * @param clientPubkey the client's public key to encrypt to + * @param signer the wallet service signer + * @param createdAt event timestamp + */ + suspend fun createNotification( + notification: Notification, + clientPubkey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): NwcNotificationEvent { + val serialized = OptimizedJsonMapper.toJson(notification) + + val tags = + arrayOf( + arrayOf("p", clientPubkey), + AltTag.assemble(ALT), + ) + + val encrypted = signer.nip44Encrypt(serialized, clientPubkey) + + return signer.sign(createdAt, KIND, tags, encrypted) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md new file mode 100644 index 000000000..95907efa9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -0,0 +1,480 @@ +# NIP-47 Wallet Connect (Quartz) + +Quartz implementation of [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) — Nostr +Wallet Connect (NWC). This module provides everything needed to build both **wallet client apps** +(like Amethyst) and **wallet service backends** (like Alby Hub). + +## Architecture + +``` +nip47WalletConnect/ +├── Nip47WalletConnect.kt # URI parsing (nostr+walletconnect://) +├── Request.kt # All 13 NWC request methods + params +├── Response.kt # All response types (success + error) +├── Notification.kt # Wallet notification types +├── NwcMethod.kt # Method name constants +├── NwcErrorCode.kt # Error codes enum + NwcError +├── NwcTransaction.kt # Transaction, state, budget, TLV models +├── NwcInfoEvent.kt # Kind 13194 — wallet capabilities +├── LnZapPaymentRequestEvent.kt # Kind 23194 — client → wallet request +├── LnZapPaymentResponseEvent.kt # Kind 23195 — wallet → client response +├── NwcNotificationEvent.kt # Kind 23197 — wallet → client notification +├── NostrWalletConnectRequestCache.kt # Request decryption cache +├── NostrWalletConnectResponseCache.kt # Response decryption cache +└── tags/ + ├── EncryptionTag.kt # "encryption" tag parsing + └── NotificationsTag.kt # "notifications" tag parsing +``` + +## Event Kinds + +| Kind | Class | Direction | Purpose | +|-------|------------------------------|-----------------|----------------------| +| 13194 | `NwcInfoEvent` | Wallet → Relay | Service capabilities | +| 23194 | `LnZapPaymentRequestEvent` | Client → Wallet | NWC request | +| 23195 | `LnZapPaymentResponseEvent` | Wallet → Client | NWC response | +| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-04, legacy) | +| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-44) | + +## Supported Methods + +| Method | Request Class | Success Response Class | +|----------------------|---------------------------|-----------------------------------| +| `pay_invoice` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | +| `pay_keysend` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | +| `make_invoice` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | +| `lookup_invoice` | `LookupInvoiceMethod` | `LookupInvoiceSuccessResponse` | +| `list_transactions` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | +| `get_balance` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | +| `get_info` | `GetInfoMethod` | `GetInfoSuccessResponse` | +| `get_budget` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | +| `sign_message` | `SignMessageMethod` | `SignMessageSuccessResponse` | +| `create_connection` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | +| `make_hold_invoice` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | +| `cancel_hold_invoice`| `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| +| `settle_hold_invoice`| `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| + +Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`. + +## Implementing a Wallet Client + +A wallet client connects to a user's lightning wallet to send payments, check +balances, create invoices, and list transactions. + +### 1. Parse the NWC Connection URI + +Users provide an NWC connection string from their wallet provider: + +```kotlin +val uri = "nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c..." +val nwcConfig = Nip47WalletConnect.parse(uri) + +// nwcConfig.pubKeyHex — wallet service pubkey +// nwcConfig.relayUri — relay to communicate through (NormalizedRelayUrl) +// nwcConfig.secret — hex secret for the client signer +// nwcConfig.lud16 — optional lightning address +``` + +Supported URI schemes: `nostr+walletconnect://`, `nostrwalletconnect://`, +`amethyst+walletconnect://` + +### 2. Create the Client Signer + +The `secret` from the URI becomes the client's signing key: + +```kotlin +val clientSigner = NostrSignerInternal( + KeyPair(nwcConfig.secret!!.hexToByteArray()) +) +``` + +### 3. Build and Send Requests + +Use `LnZapPaymentRequestEvent.createRequest()` to create encrypted request events: + +```kotlin +// Get balance +val balanceRequest = GetBalanceMethod.create() +val event = LnZapPaymentRequestEvent.createRequest( + request = balanceRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) +// Send `event` to `nwcConfig.relayUri` + +// Pay an invoice +val payRequest = PayInvoiceMethod.create("lnbc50n1...") +val payEvent = LnZapPaymentRequestEvent.createRequest( + request = payRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) + +// Create an invoice (amount in millisats) +val invoiceRequest = MakeInvoiceMethod.create( + amount = 50000L, // 50 sats in millisats + description = "Coffee", +) +val invoiceEvent = LnZapPaymentRequestEvent.createRequest( + request = invoiceRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) + +// List transactions with pagination +val listRequest = ListTransactionsMethod.create( + limit = 20, + offset = 0, +) +val listEvent = LnZapPaymentRequestEvent.createRequest( + request = listRequest, + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, +) +``` + +To use NIP-44 encryption instead of NIP-04: + +```kotlin +val event = LnZapPaymentRequestEvent.createRequest( + request = GetInfoMethod.create(), + walletServicePubkey = nwcConfig.pubKeyHex, + signer = clientSigner, + useNip44 = true, +) +``` + +### 4. Receive and Parse Responses + +Subscribe to kind `23195` events on the NWC relay, filtered by the wallet +service pubkey and the request event ID. When a response arrives: + +```kotlin +// responseEvent is a LnZapPaymentResponseEvent (kind 23195) +val response: Response = responseEvent.decrypt(clientSigner) + +when (response) { + is GetBalanceSuccessResponse -> { + val balanceMillisats = response.result?.balance ?: 0L + val balanceSats = balanceMillisats / 1000L + } + is PayInvoiceSuccessResponse -> { + val preimage = response.result?.preimage + val feesPaid = response.result?.fees_paid + } + is MakeInvoiceSuccessResponse -> { + val bolt11 = response.result?.invoice + val paymentHash = response.result?.payment_hash + } + is ListTransactionsSuccessResponse -> { + val transactions: List = response.result?.transactions ?: emptyList() + transactions.forEach { tx -> + // tx.type — "incoming" or "outgoing" + // tx.amount — in millisats + // tx.description, tx.created_at, tx.state, etc. + } + } + is GetInfoSuccessResponse -> { + val alias = response.result?.alias + val methods = response.result?.methods // supported methods + val lud16 = response.result?.lud16 + } + is PayInvoiceErrorResponse -> { + val errorCode = response.error?.code // NwcErrorCode enum + val errorMessage = response.error?.message + } + is NwcErrorResponse -> { + val errorCode = response.error?.code + val errorMessage = response.error?.message + } +} +``` + +### 5. Listen for Notifications (Optional) + +Subscribe to kind `23196`/`23197` events from the wallet: + +```kotlin +// notificationEvent is an NwcNotificationEvent +val notification: Notification = notificationEvent.decryptNotification(clientSigner) + +when (notification) { + is PaymentReceivedNotification -> { + val tx: NwcTransaction? = notification.notification + // tx?.amount, tx?.description, tx?.payment_hash, etc. + } + is PaymentSentNotification -> { + val tx: NwcTransaction? = notification.notification + } + is HoldInvoiceAcceptedNotification -> { + val data = notification.notification + // data?.payment_hash, data?.amount, data?.settle_deadline + } +} +``` + +### 6. Transaction State Helpers + +Transaction states from different wallet implementations may use different +casing. Use the case-insensitive helpers: + +```kotlin +val tx: NwcTransaction = ... + +NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled" +NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending" +NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed" +NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted" +``` + +### 7. URI Persistence + +To save/restore the NWC connection: + +```kotlin +// Save +val nip47URI: Nip47WalletConnect.Nip47URI = nwcConfig.denormalize()!! +val json = Nip47WalletConnect.Nip47URI.serializer(nip47URI) + +// Restore +val restored = Nip47WalletConnect.Nip47URI.parser(json) +val normalized = restored.normalize()!! +``` + +## Implementing a Wallet Service + +A wallet service receives NWC requests from clients, processes them (e.g., +pays invoices via a Lightning node), and sends back responses. + +### 1. Publish Capabilities + +Advertise which methods your wallet supports by publishing a kind `13194` +event: + +```kotlin +val capabilities = listOf( + NwcMethod.PAY_INVOICE, + NwcMethod.GET_BALANCE, + NwcMethod.GET_INFO, + NwcMethod.MAKE_INVOICE, + NwcMethod.LOOKUP_INVOICE, + NwcMethod.LIST_TRANSACTIONS, +) + +val infoTemplate = NwcInfoEvent.build( + capabilities = capabilities, + encryptionSchemes = listOf("nip04", "nip44_v2"), + notificationTypes = listOf( + NwcNotificationType.PAYMENT_RECEIVED, + NwcNotificationType.PAYMENT_SENT, + ), +) +// Sign with wallet signer: walletSigner.sign(infoTemplate) +``` + +### 2. Receive and Parse Requests + +Subscribe to kind `23194` events on your relay, filtered by your wallet +service pubkey in the `p` tag. When a request arrives: + +```kotlin +// requestEvent is a LnZapPaymentRequestEvent (kind 23194) +val request: Request = requestEvent.decryptRequest(walletSigner) + +when (request) { + is PayInvoiceMethod -> { + val bolt11 = request.params?.invoice + val amount = request.params?.amount // optional override in millisats + // Process payment via your Lightning node... + } + is GetBalanceMethod -> { + // Query your Lightning node for balance... + } + is MakeInvoiceMethod -> { + val amount = request.params?.amount // in millisats + val description = request.params?.description + // Create invoice via your Lightning node... + } + is ListTransactionsMethod -> { + val limit = request.params?.limit + val offset = request.params?.offset + val type = request.params?.type // "incoming" or "outgoing" + // Query transaction history... + } + is GetInfoMethod -> { + // Return node info... + } + is GetBudgetMethod -> { + // Return budget info... + } + // ... handle other methods +} +``` + +### 3. Build and Send Responses + +Use `LnZapPaymentResponseEvent.createResponse()` to create encrypted +response events: + +```kotlin +// Success response for get_balance +val balanceResponse = GetBalanceSuccessResponse( + GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) // in millisats +) +val responseEvent = LnZapPaymentResponseEvent.createResponse( + response = balanceResponse, + requestEvent = requestEvent, + signer = walletSigner, +) +// Send responseEvent to the relay + +// Success response for pay_invoice +val payResponse = PayInvoiceSuccessResponse( + PayInvoiceSuccessResponse.PayInvoiceResultParams( + preimage = "0123456789abcdef", + fees_paid = 100L, + ) +) +val payResponseEvent = LnZapPaymentResponseEvent.createResponse( + response = payResponse, + requestEvent = requestEvent, + signer = walletSigner, +) + +// Error response +val errorResponse = NwcErrorResponse( + resultType = NwcMethod.PAY_INVOICE, + error = NwcError(NwcErrorCode.INSUFFICIENT_BALANCE, "Not enough funds"), +) +val errorEvent = LnZapPaymentResponseEvent.createResponse( + response = errorResponse, + requestEvent = requestEvent, + signer = walletSigner, +) +``` + +To use NIP-44 encryption for responses: + +```kotlin +val responseEvent = LnZapPaymentResponseEvent.createResponse( + response = balanceResponse, + requestEvent = requestEvent, + signer = walletSigner, + useNip44 = true, +) +``` + +### 4. Send Notifications + +Push notifications to clients for payment events: + +```kotlin +// Payment received notification +val notification = PaymentReceivedNotification( + notification = NwcTransaction( + type = NwcTransactionType.INCOMING, + state = NwcTransactionState.SETTLED, + invoice = "lnbc...", + amount = 50000L, // in millisats + payment_hash = "abc123", + settled_at = TimeUtils.now(), + created_at = TimeUtils.now(), + ), +) +val notifEvent = NwcNotificationEvent.createNotification( + notification = notification, + clientPubkey = clientPubkeyHex, + signer = walletSigner, +) +// Send notifEvent to the relay +``` + +### 5. Build Transaction Objects + +Transactions are used across multiple response types: + +```kotlin +val transaction = NwcTransaction( + type = NwcTransactionType.INCOMING, // or OUTGOING + state = NwcTransactionState.SETTLED, // PENDING, SETTLED, FAILED, ACCEPTED + invoice = "lnbc50n1...", + description = "Coffee payment", + payment_hash = "abc123def456", + preimage = "fedcba654321", + amount = 50000L, // in millisats + fees_paid = 100L, // in millisats + created_at = 1693876497L, // unix timestamp + settled_at = 1693876500L, + expires_at = 1694876497L, +) +``` + +## Error Codes + +| Code | When to Use | +|-------------------------|-------------------------------------------| +| `RATE_LIMITED` | Too many requests | +| `NOT_IMPLEMENTED` | Method not supported by wallet | +| `INSUFFICIENT_BALANCE` | Not enough funds for payment | +| `PAYMENT_FAILED` | Payment could not be completed | +| `QUOTA_EXCEEDED` | Budget/spending limit exceeded | +| `RESTRICTED` | Method not allowed for this connection | +| `UNAUTHORIZED` | Invalid or expired credentials | +| `INTERNAL` | Internal wallet error | +| `UNSUPPORTED_ENCRYPTION`| Requested encryption not supported | +| `BAD_REQUEST` | Malformed request parameters | +| `NOT_FOUND` | Invoice or resource not found | +| `EXPIRED` | Connection or invoice expired | +| `OTHER` | Unspecified error | + +## Encryption + +NWC supports two encryption schemes: + +- **NIP-04** (default): Set `useNip44 = false` in event builders +- **NIP-44 v2**: Set `useNip44 = true` in event builders + +When building requests with NIP-44, the event includes an `encryption` tag: +``` +["encryption", "nip44_v2"] +``` + +Clients can check a wallet's supported encryption via the info event: +```kotlin +val infoEvent: NwcInfoEvent = ... +val schemes: List = infoEvent.encryptionSchemes() +// e.g., ["nip04", "nip44_v2"] +``` + +## Caching + +For apps handling many concurrent NWC events, use the built-in LRU caches: + +```kotlin +val requestCache = NostrWalletConnectRequestCache(signer) +val responseCache = NostrWalletConnectResponseCache(signer) + +// These cache up to 50 decrypted results and handle +// async retry logic for permission dialogs and timeouts. +val request: Request? = requestCache.decryptRequest(requestEvent) +val response: Response? = responseCache.decryptResponse(responseEvent) +``` + +## Amounts + +All amounts in NWC are in **millisatoshis** (1 sat = 1000 msats). Convert for +display: + +```kotlin +val balanceMsats = response.result?.balance ?: 0L +val balanceSats = balanceMsats / 1000L +``` + +## Interoperability + +This implementation is tested against: +- **Alby Hub** (server) — uppercase transaction states, all error codes +- **Alby JS SDK** (client) — lowercase transaction states, budget renewal + periods, structured metadata + +See `AlbyInteropTest.kt` for real-world test vectors. From 3f1e826a10d1209fae6befb15fa700090fbd6393 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 14 Mar 2026 06:00:21 +0100 Subject: [PATCH 021/130] update cz, pt, de, sv --- .../src/main/res/values-cs-rCZ/strings.xml | 27 +++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 27 +++++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 27 +++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 27 +++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index bb5983ec0..b87167d44 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1503,4 +1503,31 @@ Vybrat vše %1$d%% dostupnost Nastavení Namecoin + Přidat datum vypršení + Odebrat datum vypršení + Datum vypršení + Klienti příspěvek po tomto datu skryjí (NIP-40) + Vyberte datum a čas vypršení + Vyprší za %1$s + Čas vypršení + Exportovat nastavení relé + Exportovat jako text + Exportovat jako ZIP (JSON) + Hledat jazyky + Přidat jazyk + Přidat jazykový pár + Zdrojový jazyk + Cílový jazyk + Zobrazit %1$s jako první + Zatím žádné předvolby zobrazení jazyka. Vytvoří se automaticky při překladu nebo je můžete přidat ručně. + Smazat předvolbu + Tato zpráva zmizí za %1$s + Průzkumník Bitcoin (OTS) + Nelze otevřít Blossom odkazy + Nebyly nalezeny žádné aplikace Blossom. Nainstalujte prosím lokální aplikaci Blossom pro zobrazení tohoto souboru + Přeložit do + Vyberte jazyk, do kterého chcete obsah přeložit. + Předvolby zobrazení jazyka + Pro každý přeložený jazykový pár zvolte, který jazyk zobrazit jako první. + %1$s → %2$s diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 2684746a0..5d5e61c99 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1508,4 +1508,31 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen + Ablaufdatum hinzufügen + Ablaufdatum entfernen + Ablaufdatum + Der Beitrag wird von Clients nach diesem Datum ausgeblendet (NIP-40) + Ablaufdatum und -uhrzeit auswählen + Läuft ab in %1$s + Ablaufzeit + Relay-Einstellungen exportieren + Als Text exportieren + Als ZIP exportieren (JSON) + Sprachen suchen + Sprache hinzufügen + Sprachpaar hinzufügen + Ausgangssprache + Zielsprache + %1$s zuerst anzeigen + Noch keine Sprachanzeigeeinstellungen. Diese werden automatisch bei Übersetzungen erstellt oder können manuell hinzugefügt werden. + Einstellung löschen + Diese Nachricht verschwindet in %1$s + Bitcoin Explorer (OTS) + Blossom-Links können nicht geöffnet werden + Keine Blossom-Apps gefunden. Bitte installiere eine lokale Blossom-App, um diese Datei anzuzeigen + Übersetzen in + Wähle die Sprache, in die der Inhalt übersetzt werden soll. + Sprachanzeigeeinstellungen + Wähle für jedes übersetzte Sprachpaar, welche Sprache zuerst angezeigt werden soll. + %1$s → %2$s diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7a20de62c..9ee71b932 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1503,4 +1503,31 @@ Selecionar tudo %1$d%% de disponibilidade Configurações do Namecoin + Adicionar data de expiração + Remover data de expiração + Data de expiração + A publicação será ocultada pelos clientes após esta data (NIP-40) + Selecionar data e hora de expiração + Expira em %1$s + Hora de expiração + Exportar configurações de relay + Exportar como texto + Exportar como ZIP (JSON) + Buscar idiomas + Adicionar idioma + Adicionar par de idiomas + Idioma de origem + Idioma de destino + Mostrar %1$s primeiro + Nenhuma preferência de exibição de idioma ainda. Elas são criadas automaticamente quando ocorrem traduções, ou você pode adicioná-las manualmente. + Excluir preferência + Esta mensagem desaparecerá em %1$s + Explorador Bitcoin (OTS) + Não é possível abrir links Blossom + Nenhum aplicativo Blossom foi encontrado. Instale um aplicativo Blossom local para visualizar este arquivo + Traduzir para + Escolha o idioma para o qual o conteúdo será traduzido. + Preferências de exibição de idioma + Para cada par de idiomas traduzido, escolha qual idioma exibir primeiro. + %1$s → %2$s diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 64f743251..b1dd817cc 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1502,4 +1502,31 @@ Välj alla %1$d%% drifttid Namecoin-inställningar + Lägg till utgångsdatum + Ta bort utgångsdatum + Utgångsdatum + Inlägget döljs av klienter efter detta datum (NIP-40) + Välj utgångsdatum och utgångstid + Går ut om %1$s + Utgångstid + Exportera relay-inställningar + Exportera som text + Exportera som ZIP (JSON) + Sök språk + Lägg till språk + Lägg till språkpar + Källspråk + Målspråk + Visa %1$s först + Inga språkvisningsinställningar än. Dessa skapas automatiskt när översättningar sker, eller kan läggas till manuellt. + Ta bort inställning + Detta meddelande försvinner om %1$s + Bitcoin Explorer (OTS) + Kan inte öppna Blossom-länkar + Inga Blossom-appar hittades. Installera en lokal Blossom-app för att visa den här filen + Översätt till + Välj det språk du vill översätta innehållet till. + Språkvisningsinställningar + Välj för varje översatt språkpar vilket språk som ska visas först. + %1$s → %2$s From f9f4e7716d6fc299a73839a08b3049b1e6c20d9f Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 14 Mar 2026 05:06:26 +0000 Subject: [PATCH 022/130] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 51 +++++++++--------- .../src/main/res/values-de-rDE/strings.xml | 52 +++++++++---------- .../src/main/res/values-pt-rBR/strings.xml | 51 +++++++++--------- .../src/main/res/values-sv-rSE/strings.xml | 52 +++++++++---------- 4 files changed, 100 insertions(+), 106 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index b87167d44..e12ec2833 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -923,6 +923,8 @@ Ujistěte se, že podepisující aplikace autorizovala tuto transakci Nebyly nalezeny žádné peněženky pro platbu bleskové faktury (Chyba: %1$s). Prosím, nainstalujte si bleskovou peněženku pro použití Zapů Nebyly nalezeny žádné peněženky pro platbu bleskové faktury. Prosím, nainstalujte si bleskovou peněženku pro použití Zapů + Nelze otevřít Blossom odkazy + Nebyly nalezeny žádné aplikace Blossom. Nainstalujte prosím lokální aplikaci Blossom pro zobrazení tohoto souboru Skrytá slova Skrýt nové slovo nebo větu Profilový obrázek @@ -1092,6 +1094,13 @@ Zrušit rozdělení Zap Přidat upozornění na obsah Odstranit upozornění na obsah + Přidat datum vypršení + Odebrat datum vypršení + Datum vypršení + Klienti příspěvek po tomto datu skryjí (NIP-40) + Vyberte datum a čas vypršení + Vyprší za %1$s + Čas vypršení Zobrazit npub jako QR kód Zobrazit nprofile jako QR kód Neplatná adresa @@ -1161,6 +1170,9 @@ Blokované Relé Blokované Relé Amethyst se k těmto relé nikdy nepřipojí + Exportovat nastavení relé + Exportovat jako text + Exportovat jako ZIP (JSON) Zapni vývojáře! Váš příspěvek nám pomáhá dělat rozdíl. Každý sat se počítá! Přispět nyní @@ -1266,6 +1278,18 @@ Vyhledávání hashtag: #%1$s Nepřekládat z Zde zobrazené jazyky nebudou přeloženy. Vyberte jazyk, který chcete odstranit a nechat je znovu přeložit. + Přeložit do + Vyberte jazyk, do kterého chcete obsah přeložit. + Předvolby zobrazení jazyka + Pro každý přeložený jazykový pár zvolte, který jazyk zobrazit jako první. + Hledat jazyky + Přidat jazyk + Přidat jazykový pár + Zdrojový jazyk + Cílový jazyk + Zobrazit %1$s jako první + Zatím žádné předvolby zobrazení jazyka. Vytvoří se automaticky při překladu nebo je můžete přidat ručně. + Smazat předvolbu Pozastavit Hrát Otevřít rozbalovací nabídku @@ -1275,6 +1299,7 @@ Nalezen záznam o pádu Chcete poslat poslední záznam o pádu do Amethystu v soukromé zprávě? Žádné osobní údaje nebudou sdíleny Odeslat + Tato zpráva zmizí za %1$s Tato zpráva zmizí za %1$d dní Vybrat podepisovatele Již v seznamu @@ -1503,31 +1528,5 @@ Vybrat vše %1$d%% dostupnost Nastavení Namecoin - Přidat datum vypršení - Odebrat datum vypršení - Datum vypršení - Klienti příspěvek po tomto datu skryjí (NIP-40) - Vyberte datum a čas vypršení - Vyprší za %1$s - Čas vypršení - Exportovat nastavení relé - Exportovat jako text - Exportovat jako ZIP (JSON) - Hledat jazyky - Přidat jazyk - Přidat jazykový pár - Zdrojový jazyk - Cílový jazyk - Zobrazit %1$s jako první - Zatím žádné předvolby zobrazení jazyka. Vytvoří se automaticky při překladu nebo je můžete přidat ručně. - Smazat předvolbu - Tato zpráva zmizí za %1$s Průzkumník Bitcoin (OTS) - Nelze otevřít Blossom odkazy - Nebyly nalezeny žádné aplikace Blossom. Nainstalujte prosím lokální aplikaci Blossom pro zobrazení tohoto souboru - Přeložit do - Vyberte jazyk, do kterého chcete obsah přeložit. - Předvolby zobrazení jazyka - Pro každý přeložený jazykový pár zvolte, který jazyk zobrazit jako první. - %1$s → %2$s diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 5d5e61c99..a1df34e69 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -928,6 +928,8 @@ anz der Bedingungen ist erforderlich Stellen Sie sicher, dass die Unterzeichner-Anwendung diese Transaktion autorisiert hat Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen (Fehler: %1$s). Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen. Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden + Blossom-Links können nicht geöffnet werden + Keine Blossom-Apps gefunden. Bitte installiere eine lokale Blossom-App, um diese Datei anzuzeigen Versteckte Wörter Neues Wort oder neuen Satz verstecken Profilbild @@ -1097,6 +1099,13 @@ anz der Bedingungen ist erforderlich Zap-Aufteilung abbrechen Inhaltswarnung hinzufügen Inhaltswarnung entfernen + Ablaufdatum hinzufügen + Ablaufdatum entfernen + Ablaufdatum + Der Beitrag wird von Clients nach diesem Datum ausgeblendet (NIP-40) + Ablaufdatum und -uhrzeit auswählen + Läuft ab in %1$s + Ablaufzeit Npub als QR-Code anzeigen nprofile als QR-Code anzeigen Ungültige Adresse @@ -1166,6 +1175,9 @@ anz der Bedingungen ist erforderlich Blockierte Relays Blockierte Relays Amethyst wird sich niemals mit diesen Relays verbinden + Relay-Einstellungen exportieren + Als Text exportieren + Als ZIP exportieren (JSON) Zap die Entwickler! Deine Spende hilft uns, einen Unterschied zu machen. Jeder Sat zählt! Jetzt spenden @@ -1271,6 +1283,18 @@ anz der Bedingungen ist erforderlich Suche Hashtag: #%1$s Nicht übersetzen von Die hier angezeigten Sprachen werden nicht übersetzt. Wählen Sie eine Sprache, um sie zu entfernen und lassen Sie sie erneut übersetzen. + Übersetzen in + Wähle die Sprache, in die der Inhalt übersetzt werden soll. + Sprachanzeigeeinstellungen + Wähle für jedes übersetzte Sprachpaar, welche Sprache zuerst angezeigt werden soll. + Sprachen suchen + Sprache hinzufügen + Sprachpaar hinzufügen + Ausgangssprache + Zielsprache + %1$s zuerst anzeigen + Noch keine Sprachanzeigeeinstellungen. Diese werden automatisch bei Übersetzungen erstellt oder können manuell hinzugefügt werden. + Einstellung löschen Pausen Abspielen Dropdown-Menü öffnen @@ -1280,6 +1304,7 @@ anz der Bedingungen ist erforderlich Absturzbericht gefunden Möchten Sie den letzten Absturzbericht per Direktnachricht an Amethyst senden? Es werden keine persönlichen Daten weitergegeben Senden + Diese Nachricht verschwindet in %1$s Diese Nachricht verschwindet in %1$d Tagen Signierer auswählen Bereits in der Liste @@ -1508,31 +1533,4 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen - Ablaufdatum hinzufügen - Ablaufdatum entfernen - Ablaufdatum - Der Beitrag wird von Clients nach diesem Datum ausgeblendet (NIP-40) - Ablaufdatum und -uhrzeit auswählen - Läuft ab in %1$s - Ablaufzeit - Relay-Einstellungen exportieren - Als Text exportieren - Als ZIP exportieren (JSON) - Sprachen suchen - Sprache hinzufügen - Sprachpaar hinzufügen - Ausgangssprache - Zielsprache - %1$s zuerst anzeigen - Noch keine Sprachanzeigeeinstellungen. Diese werden automatisch bei Übersetzungen erstellt oder können manuell hinzugefügt werden. - Einstellung löschen - Diese Nachricht verschwindet in %1$s - Bitcoin Explorer (OTS) - Blossom-Links können nicht geöffnet werden - Keine Blossom-Apps gefunden. Bitte installiere eine lokale Blossom-App, um diese Datei anzuzeigen - Übersetzen in - Wähle die Sprache, in die der Inhalt übersetzt werden soll. - Sprachanzeigeeinstellungen - Wähle für jedes übersetzte Sprachpaar, welche Sprache zuerst angezeigt werden soll. - %1$s → %2$s diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 9ee71b932..4ab3d5dec 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -923,6 +923,8 @@ Certifique-se de que a aplicação assinante autorizou esta transação Nenhuma carteira encontrada para pagar uma fatura Lightning (Erro: %1$s). Instale uma carteira Lightning para usar zaps Nenhuma carteira encontrada para pagar uma fatura Lightning. Instale uma carteira Lightning para usar zaps + Não é possível abrir links Blossom + Nenhum aplicativo Blossom foi encontrado. Instale um aplicativo Blossom local para visualizar este arquivo Palavras Ocultas Ocultar nova palavra ou frase Foto de Perfil @@ -1092,6 +1094,13 @@ Cancelar Divisão Zap Adicionar aviso de conteúdo Remover aviso de conteúdo + Adicionar data de expiração + Remover data de expiração + Data de expiração + A publicação será ocultada pelos clientes após esta data (NIP-40) + Selecionar data e hora de expiração + Expira em %1$s + Hora de expiração Mostrar npub como um código QR Mostrar nprofile como QR code Endereço inválido @@ -1161,6 +1170,9 @@ Relays bloqueados Relays bloqueados O Amethyst nunca se conectará a esses relays + Exportar configurações de relay + Exportar como texto + Exportar como ZIP (JSON) Zap os desenvolvedores! Sua doação nos ajuda a fazer a diferença. Cada sat conta! Doar agora @@ -1266,6 +1278,18 @@ Pesquisar hashtag: #%1$s Não Traduzir de Os idiomas mostrados aqui não serão traduzidos. Selecione um idioma para removê-lo e traduzi-lo novamente. + Traduzir para + Escolha o idioma para o qual o conteúdo será traduzido. + Preferências de exibição de idioma + Para cada par de idiomas traduzido, escolha qual idioma exibir primeiro. + Buscar idiomas + Adicionar idioma + Adicionar par de idiomas + Idioma de origem + Idioma de destino + Mostrar %1$s primeiro + Nenhuma preferência de exibição de idioma ainda. Elas são criadas automaticamente quando ocorrem traduções, ou você pode adicioná-las manualmente. + Excluir preferência Pausar Reproduzir Abrir menu suspenso @@ -1275,6 +1299,7 @@ Relatório de falha encontrado Gostaria de enviar o relatório de falha recente para o Amethyst em uma DM? Nenhuma informação pessoal será compartilhada Enviar + Esta mensagem desaparecerá em %1$s Esta mensagem desaparecerá em %1$d dias Selecionar assinador Já está na lista @@ -1503,31 +1528,5 @@ Selecionar tudo %1$d%% de disponibilidade Configurações do Namecoin - Adicionar data de expiração - Remover data de expiração - Data de expiração - A publicação será ocultada pelos clientes após esta data (NIP-40) - Selecionar data e hora de expiração - Expira em %1$s - Hora de expiração - Exportar configurações de relay - Exportar como texto - Exportar como ZIP (JSON) - Buscar idiomas - Adicionar idioma - Adicionar par de idiomas - Idioma de origem - Idioma de destino - Mostrar %1$s primeiro - Nenhuma preferência de exibição de idioma ainda. Elas são criadas automaticamente quando ocorrem traduções, ou você pode adicioná-las manualmente. - Excluir preferência - Esta mensagem desaparecerá em %1$s Explorador Bitcoin (OTS) - Não é possível abrir links Blossom - Nenhum aplicativo Blossom foi encontrado. Instale um aplicativo Blossom local para visualizar este arquivo - Traduzir para - Escolha o idioma para o qual o conteúdo será traduzido. - Preferências de exibição de idioma - Para cada par de idiomas traduzido, escolha qual idioma exibir primeiro. - %1$s → %2$s diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index b1dd817cc..60a129aa5 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -922,6 +922,8 @@ Kontrollera att signeringsprogrammet har godkänt denna transaktion Inga plånböcker hittades för att betala en blixtfaktura (Fel: %1$s). Installera en blixtpengaplånbok för att använda zaps Inga plånböcker hittades för att betala en blixtfaktura. Installera en blixtpengaplånbok för att använda zaps + Kan inte öppna Blossom-länkar + Inga Blossom-appar hittades. Installera en lokal Blossom-app för att visa den här filen Dolda ord Dölj nytt ord eller mening Profilbild @@ -1091,6 +1093,13 @@ Avbryt Zap-split Lägg till varning för innehåll Ta bort varning för innehåll + Lägg till utgångsdatum + Ta bort utgångsdatum + Utgångsdatum + Inlägget döljs av klienter efter detta datum (NIP-40) + Välj utgångsdatum och utgångstid + Går ut om %1$s + Utgångstid Visa npub som en QR-kod Visa nprofile som QR-kod Ogiltig adress @@ -1160,6 +1169,9 @@ Blockerade reläer Blockerade reläer Amethyst kommer aldrig att ansluta till dessa reläer + Exportera relay-inställningar + Exportera som text + Exportera som ZIP (JSON) Zappa utvecklarna! Din donation hjälper oss att göra skillnad. Varje sat räknas! Donera nu @@ -1265,6 +1277,18 @@ Sök hashtag: #%1$s Översätt inte från Språk som visas här kommer inte att översättas. Välj ett språk för att ta bort det och få det översatt igen. + Översätt till + Välj det språk du vill översätta innehållet till. + Språkvisningsinställningar + Välj för varje översatt språkpar vilket språk som ska visas först. + Sök språk + Lägg till språk + Lägg till språkpar + Källspråk + Målspråk + Visa %1$s först + Inga språkvisningsinställningar än. Dessa skapas automatiskt när översättningar sker, eller kan läggas till manuellt. + Ta bort inställning Pausa Spela Öppna rullgardinsmeny @@ -1274,6 +1298,7 @@ Kraschrapport hittad Vill du skicka den senaste kraschrapporten till Amethyst i ett DM? Ingen personlig information kommer att delas Skicka + Detta meddelande försvinner om %1$s Detta meddelande försvinner om %1$d dagar Välj signatör Redan i listan @@ -1502,31 +1527,4 @@ Välj alla %1$d%% drifttid Namecoin-inställningar - Lägg till utgångsdatum - Ta bort utgångsdatum - Utgångsdatum - Inlägget döljs av klienter efter detta datum (NIP-40) - Välj utgångsdatum och utgångstid - Går ut om %1$s - Utgångstid - Exportera relay-inställningar - Exportera som text - Exportera som ZIP (JSON) - Sök språk - Lägg till språk - Lägg till språkpar - Källspråk - Målspråk - Visa %1$s först - Inga språkvisningsinställningar än. Dessa skapas automatiskt när översättningar sker, eller kan läggas till manuellt. - Ta bort inställning - Detta meddelande försvinner om %1$s - Bitcoin Explorer (OTS) - Kan inte öppna Blossom-länkar - Inga Blossom-appar hittades. Installera en lokal Blossom-app för att visa den här filen - Översätt till - Välj det språk du vill översätta innehållet till. - Språkvisningsinställningar - Välj för varje översatt språkpar vilket språk som ska visas först. - %1$s → %2$s From a6d5944bf32bf288b1914540317f6cbd274622c5 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Sat, 14 Mar 2026 09:01:55 +0000 Subject: [PATCH 023/130] Fix JSON formatting in settings.json --- .claude/settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index af65d2dbc..80496cbba 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,7 +9,7 @@ } ] } - ] + ], "Stop": [ { "matcher": "", @@ -23,4 +23,4 @@ } ] } -} \ No newline at end of file +} From dcf1e4e39b9e57740a330e80d36ad166cada0e42 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Sat, 14 Mar 2026 11:08:34 +0000 Subject: [PATCH 024/130] Add Claude Code local settings to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 43404b259..d87f7f146 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ lint/tmp/ # Local task tracking TASKS.md + +# Claude Code local settings +.claude/settings.local.json From 4fc97f21c5e03ba7350fc9127c700e619de840f7 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 14 Mar 2026 11:10:01 +0000 Subject: [PATCH 025/130] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hu-rHU/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index f385c1347..cdaa9bb88 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1099,6 +1099,13 @@ Zap-megosztások visszavonása Tartalmi figyelmeztetés hozzáadása Tartalmi figyelmeztetés eltávolítása + Lejárati dátum hozzáadása + Lejárati dátum törlése + Lejárati dátum + Ezen dátum után a klienek elrejtik a bejegyzést (NIP-40) + Válassza ki a lejárati dátumot és időpontot + Lejár ekkor: %1$s + Lejárati idő Az npub-kulcs megjelenítése QR-kódként nprofile-kulcs megjelenítése QR-kódként Érvénytelen cím @@ -1168,6 +1175,9 @@ Letiltott átjátszók Letiltott átjátszók Az Amethyst soha nem fog csatlakozni ezekhez az átjátszókhoz + Átjátszóbeállítások exportálása + Exportálás szövegként + Exportálás ZIP-fájlként (JSON) Zap a fejlesztőknek! Az Ön adománya segít nekünk abban, hogy változtassunk a dolgokon. Minden satoshi számít! Adományozás most @@ -1295,6 +1305,7 @@ Összeomlási jelentés megtalálva Szeretné elküldeni a legutóbbi összeomlási jelentést az Amethystnek egy közvetlen üzenetben? A személyes adatait nem osztja meg Küldés + Ez az üzenet %1$s után eltűnik Ez az üzenet %1$d nap múlva eltűnik Aláíró kiválasztása Már rajta van a listán @@ -1523,4 +1534,5 @@ Összes kijelölése Üzemidő: %1$d%% Namecoin-beállítások + Bitcoin felfedező (OTS) From 1841e47c5ccd6a5728dc0bbb706f77d2e934b699 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 11:39:17 +0000 Subject: [PATCH 026/130] feat: add high-level Nip47Client and Nip47Server APIs for NIP-47 Simplifies the NWC developer experience by providing Nip47Client (for wallet client apps) and Nip47Server (for wallet service backends) that handle URI parsing, signer creation, event building, filter construction, and response decryption. Updates README with quick-start examples. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../quartz/nip47WalletConnect/Nip47Client.kt | 265 ++++++++++++ .../quartz/nip47WalletConnect/Nip47Server.kt | 294 +++++++++++++ .../quartz/nip47WalletConnect/README.md | 398 ++++++------------ 3 files changed, 699 insertions(+), 258 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt new file mode 100644 index 000000000..5686fc1d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -0,0 +1,265 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal + +/** + * High-level NIP-47 Wallet Connect client. + * + * Simplifies the NWC protocol by handling URI parsing, signer creation, + * event building, filter construction, and response decryption. + * + * Usage: + * ```kotlin + * val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...") + * + * // Build a request event + * val requestEvent = client.payInvoice("lnbc50n1...") + * + * // Send requestEvent to client.relayUrl via your relay connection + * // Subscribe using client.responseFilter(requestEvent.id) for the response + * + * // When response arrives: + * val response = client.parseResponse(responseEvent) + * when (response) { + * is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}") + * is NwcErrorResponse -> println("Error: ${response.error?.message}") + * } + * ``` + */ +class Nip47Client( + val walletPubKeyHex: HexKey, + val relayUrl: NormalizedRelayUrl, + val signer: NostrSigner, + val useNip44: Boolean = false, +) { + companion object { + /** + * Creates an Nip47Client from a NWC connection URI string. + * + * @param uri NWC URI (e.g., "nostr+walletconnect://pubkey?relay=...&secret=...") + * @throws IllegalArgumentException if the URI is invalid or has no secret + */ + fun fromUri(uri: String): Nip47Client { + val config = Nip47WalletConnect.parse(uri) + return fromNip47URI(config) + } + + /** + * Creates an Nip47Client from parsed NWC connection details. + * + * @param config parsed NWC URI with wallet pubkey, relay, and secret + * @throws IllegalArgumentException if config has no secret + */ + fun fromNip47URI(config: Nip47WalletConnect.Nip47URINorm): Nip47Client { + val secret = config.secret ?: throw IllegalArgumentException("NWC connection requires a secret") + val signer = NostrSignerInternal(KeyPair(secret.hexToByteArray())) + return Nip47Client( + walletPubKeyHex = config.pubKeyHex, + relayUrl = config.relayUri, + signer = signer, + ) + } + } + + // --- Request builders --- + + /** + * Builds a pay_invoice request event. + */ + suspend fun payInvoice( + bolt11: String, + amount: Long? = null, + ): LnZapPaymentRequestEvent = + buildRequest( + if (amount != null) { + PayInvoiceMethod.create(bolt11, amount) + } else { + PayInvoiceMethod.create(bolt11) + }, + ) + + /** + * Builds a pay_keysend request event. + */ + suspend fun payKeysend( + amount: Long, + pubkey: String, + preimage: String? = null, + tlvRecords: List? = null, + ): LnZapPaymentRequestEvent = buildRequest(PayKeysendMethod.create(amount, pubkey, preimage, tlvRecords)) + + /** + * Builds a get_balance request event. + */ + suspend fun getBalance(): LnZapPaymentRequestEvent = buildRequest(GetBalanceMethod.create()) + + /** + * Builds a get_info request event. + */ + suspend fun getInfo(): LnZapPaymentRequestEvent = buildRequest(GetInfoMethod.create()) + + /** + * Builds a make_invoice request event. + */ + suspend fun makeInvoice( + amount: Long, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + ): LnZapPaymentRequestEvent = buildRequest(MakeInvoiceMethod.create(amount, description, descriptionHash, expiry)) + + /** + * Builds a lookup_invoice request event by payment hash. + */ + suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = + buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) + + /** + * Builds a lookup_invoice request event by BOLT11 invoice. + */ + suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = + buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) + + /** + * Builds a list_transactions request event. + */ + suspend fun listTransactions( + from: Long? = null, + until: Long? = null, + limit: Int? = null, + offset: Int? = null, + unpaid: Boolean? = null, + type: String? = null, + ): LnZapPaymentRequestEvent = buildRequest(ListTransactionsMethod.create(from, until, limit, offset, unpaid, type)) + + /** + * Builds a get_budget request event. + */ + suspend fun getBudget(): LnZapPaymentRequestEvent = buildRequest(GetBudgetMethod.create()) + + /** + * Builds a sign_message request event. + */ + suspend fun signMessage(message: String): LnZapPaymentRequestEvent = buildRequest(SignMessageMethod.create(message)) + + /** + * Builds a make_hold_invoice request event. + */ + suspend fun makeHoldInvoice( + amount: Long, + paymentHash: String, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + minCltvExpiryDelta: Int? = null, + ): LnZapPaymentRequestEvent = + buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) + + /** + * Builds a cancel_hold_invoice request event. + */ + suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = + buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) + + /** + * Builds a settle_hold_invoice request event. + */ + suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = + buildRequest(SettleHoldInvoiceMethod.create(preimage)) + + /** + * Builds a request event from any [Request] object. + * This is the low-level method used by all convenience methods above. + */ + suspend fun buildRequest(request: Request): LnZapPaymentRequestEvent = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletPubKeyHex, + signer = signer, + useNip44 = useNip44, + ) + + // --- Response handling --- + + /** + * Decrypts and parses a response event from the wallet. + */ + suspend fun parseResponse(event: LnZapPaymentResponseEvent): Response = event.decrypt(signer) + + /** + * Decrypts and parses a notification event from the wallet. + */ + suspend fun parseNotification(event: NwcNotificationEvent): Notification = event.decryptNotification(signer) + + // --- Filter helpers --- + + /** + * Creates a filter to subscribe for responses to a specific request. + * Use this to subscribe on [relayUrl] after sending a request event. + */ + fun responseFilter(requestEventId: HexKey): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("e" to listOf(requestEventId)), + ) + + /** + * Creates a filter to subscribe for all responses from the wallet + * directed to this client. + */ + fun allResponsesFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) + + /** + * Creates a filter to subscribe for wallet notifications. + */ + fun notificationsFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) + + /** + * Creates a filter to fetch the wallet's info event (kind 13194). + */ + fun infoFilter(): Filter = + Filter( + kinds = listOf(NwcInfoEvent.KIND), + authors = listOf(walletPubKeyHex), + limit = 1, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt new file mode 100644 index 000000000..6b032f3ae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -0,0 +1,294 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** + * High-level NIP-47 Wallet Connect server (wallet service). + * + * Simplifies building a wallet service that receives NWC requests from clients, + * processes them, and sends back responses and notifications. + * + * Usage: + * ```kotlin + * val server = Nip47Server(walletSigner, supportedMethods, relayUrl) + * + * // Publish capabilities + * val infoEvent = server.buildInfoEvent() + * // Send infoEvent to relay + * + * // Subscribe using server.requestsFilter() on your relay + * + * // When a request arrives: + * val request = server.parseRequest(requestEvent) + * when (request) { + * is GetBalanceMethod -> { + * val response = server.respondGetBalance(requestEvent, balance = 2100000L) + * // Send response to relay + * } + * is PayInvoiceMethod -> { + * // Process payment, then: + * val response = server.respondPayInvoice(requestEvent, preimage = "abc123") + * // Or on error: + * val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found") + * // Send response to relay + * } + * } + * ``` + */ +class Nip47Server( + val signer: NostrSigner, + val capabilities: List = emptyList(), + val useNip44: Boolean = false, + val encryptionSchemes: List? = null, + val notificationTypes: List? = null, +) { + // --- Info event --- + + /** + * Builds a kind 13194 info event advertising wallet capabilities. + * Sign and publish this event to your relay. + */ + fun buildInfoEvent() = + NwcInfoEvent.build( + capabilities = capabilities, + encryptionSchemes = encryptionSchemes, + notificationTypes = notificationTypes, + ) + + // --- Request parsing --- + + /** + * Decrypts and parses an incoming client request. + */ + suspend fun parseRequest(event: LnZapPaymentRequestEvent): Request = event.decryptRequest(signer) + + // --- Response builders --- + + /** + * Builds a response event from any [Response] object. + */ + suspend fun buildResponse( + response: Response, + requestEvent: LnZapPaymentRequestEvent, + ): LnZapPaymentResponseEvent = + LnZapPaymentResponseEvent.createResponse( + response = response, + requestEvent = requestEvent, + signer = signer, + useNip44 = useNip44, + ) + + /** + * Builds an error response for any method. + */ + suspend fun respondError( + requestEvent: LnZapPaymentRequestEvent, + code: NwcErrorCode, + message: String, + resultType: String? = null, + ): LnZapPaymentResponseEvent { + val method = resultType ?: requestEvent.decryptRequest(signer).method ?: NwcMethod.PAY_INVOICE + return buildResponse(NwcErrorResponse(method, NwcError(code, message)), requestEvent) + } + + /** + * Builds a pay_invoice success response. + */ + suspend fun respondPayInvoice( + requestEvent: LnZapPaymentRequestEvent, + preimage: String? = null, + feesPaid: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + PayInvoiceSuccessResponse(PayInvoiceSuccessResponse.PayInvoiceResultParams(preimage, feesPaid)), + requestEvent, + ) + + /** + * Builds a pay_keysend success response. + */ + suspend fun respondPayKeysend( + requestEvent: LnZapPaymentRequestEvent, + preimage: String? = null, + feesPaid: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + PayKeysendSuccessResponse(PayKeysendSuccessResponse.PayKeysendResult(preimage, feesPaid)), + requestEvent, + ) + + /** + * Builds a get_balance success response. + */ + suspend fun respondGetBalance( + requestEvent: LnZapPaymentRequestEvent, + balance: Long, + ): LnZapPaymentResponseEvent = + buildResponse( + GetBalanceSuccessResponse(GetBalanceSuccessResponse.GetBalanceResult(balance)), + requestEvent, + ) + + /** + * Builds a get_info success response. + */ + suspend fun respondGetInfo( + requestEvent: LnZapPaymentRequestEvent, + alias: String? = null, + color: String? = null, + pubkey: String? = null, + network: String? = null, + blockHeight: Long? = null, + blockHash: String? = null, + methods: List? = null, + notifications: List? = null, + lud16: String? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + GetInfoSuccessResponse( + GetInfoSuccessResponse.GetInfoResult( + alias, color, pubkey, network, blockHeight, blockHash, methods, notifications, null, lud16, + ), + ), + requestEvent, + ) + + /** + * Builds a make_invoice success response. + */ + suspend fun respondMakeInvoice( + requestEvent: LnZapPaymentRequestEvent, + transaction: NwcTransaction, + ): LnZapPaymentResponseEvent = + buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) + + /** + * Builds a lookup_invoice success response. + */ + suspend fun respondLookupInvoice( + requestEvent: LnZapPaymentRequestEvent, + transaction: NwcTransaction, + ): LnZapPaymentResponseEvent = + buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) + + /** + * Builds a list_transactions success response. + */ + suspend fun respondListTransactions( + requestEvent: LnZapPaymentRequestEvent, + transactions: List, + totalCount: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + ListTransactionsSuccessResponse( + ListTransactionsSuccessResponse.ListTransactionsResult(transactions, totalCount), + ), + requestEvent, + ) + + /** + * Builds a get_budget success response. + */ + suspend fun respondGetBudget( + requestEvent: LnZapPaymentRequestEvent, + usedBudget: Long? = null, + totalBudget: Long? = null, + renewsAt: Long? = null, + renewalPeriod: String? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + GetBudgetSuccessResponse( + GetBudgetSuccessResponse.GetBudgetResult(usedBudget, totalBudget, renewsAt, renewalPeriod), + ), + requestEvent, + ) + + /** + * Builds a sign_message success response. + */ + suspend fun respondSignMessage( + requestEvent: LnZapPaymentRequestEvent, + message: String, + signature: String, + ): LnZapPaymentResponseEvent = + buildResponse( + SignMessageSuccessResponse(SignMessageSuccessResponse.SignMessageResult(message, signature)), + requestEvent, + ) + + // --- Notification builders --- + + /** + * Builds a payment_received notification event. + */ + suspend fun notifyPaymentReceived( + clientPubkey: HexKey, + transaction: NwcTransaction, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = PaymentReceivedNotification(transaction), + clientPubkey = clientPubkey, + signer = signer, + ) + + /** + * Builds a payment_sent notification event. + */ + suspend fun notifyPaymentSent( + clientPubkey: HexKey, + transaction: NwcTransaction, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = PaymentSentNotification(transaction), + clientPubkey = clientPubkey, + signer = signer, + ) + + /** + * Builds a notification event from any [Notification] object. + */ + suspend fun buildNotification( + notification: Notification, + clientPubkey: HexKey, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = notification, + clientPubkey = clientPubkey, + signer = signer, + ) + + // --- Filter helpers --- + + /** + * Creates a filter to subscribe for incoming client requests. + * Use this to subscribe on your relay. + */ + fun requestsFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(LnZapPaymentRequestEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md index 95907efa9..0d372e1a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -4,10 +4,86 @@ Quartz implementation of [NIP-47](https://github.com/nostr-protocol/nips/blob/ma Wallet Connect (NWC). This module provides everything needed to build both **wallet client apps** (like Amethyst) and **wallet service backends** (like Alby Hub). +## Quick Start — Wallet Client + +Use `Nip47Client` for a high-level API that handles URI parsing, signer creation, +event building, filter construction, and response decryption: + +```kotlin +// 1. Create client from NWC URI +val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...") + +// 2. Build request events — one method per NWC command +val payEvent = client.payInvoice("lnbc50n1...") +val balanceEvent = client.getBalance() +val infoEvent = client.getInfo() +val invoiceEvent = client.makeInvoice(amount = 50000L, description = "Coffee") +val txEvent = client.listTransactions(limit = 20) + +// 3. Send event to client.relayUrl via your relay connection +// 4. Subscribe using client.responseFilter(payEvent.id) for the response + +// 5. When response arrives, parse it +val response = client.parseResponse(responseEvent) +when (response) { + is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}") + is GetBalanceSuccessResponse -> println("Balance: ${response.result?.balance} msats") + is NwcErrorResponse -> println("Error: ${response.error?.message}") +} + +// Filter helpers for relay subscriptions +val filter = client.responseFilter(payEvent.id) // Filter for a specific response +val allFilter = client.allResponsesFilter() // Filter for all responses +val notifFilter = client.notificationsFilter() // Filter for notifications +val walletInfo = client.infoFilter() // Filter for wallet info event +``` + +## Quick Start — Wallet Service + +Use `Nip47Server` to build a wallet service that receives requests and sends responses: + +```kotlin +// 1. Create server +val server = Nip47Server( + signer = walletSigner, + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE, NwcMethod.GET_INFO), +) + +// 2. Publish capabilities (kind 13194) +val infoTemplate = server.buildInfoEvent() +// Sign and send: walletSigner.sign(infoTemplate) + +// 3. Subscribe using server.requestsFilter() on your relay + +// 4. When a request arrives, parse and respond +val request = server.parseRequest(requestEvent) +when (request) { + is GetBalanceMethod -> { + val response = server.respondGetBalance(requestEvent, balance = 2100000L) + // Send response to relay + } + is PayInvoiceMethod -> { + // Process payment, then: + val response = server.respondPayInvoice(requestEvent, preimage = "abc123") + // Or on error: + val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found") + } + is MakeInvoiceMethod -> { + val tx = NwcTransaction(type = NwcTransactionType.INCOMING, invoice = "lnbc...") + val response = server.respondMakeInvoice(requestEvent, tx) + } +} + +// 5. Send notifications +val notifEvent = server.notifyPaymentReceived(clientPubkey, transaction) +``` + ## Architecture ``` nip47WalletConnect/ +├── Nip47Client.kt # High-level client API (URI → requests → responses) +├── Nip47Server.kt # High-level server API (requests → responses → notifications) ├── Nip47WalletConnect.kt # URI parsing (nostr+walletconnect://) ├── Request.kt # All 13 NWC request methods + params ├── Response.kt # All response types (success + error) @@ -38,49 +114,42 @@ nip47WalletConnect/ ## Supported Methods -| Method | Request Class | Success Response Class | -|----------------------|---------------------------|-----------------------------------| -| `pay_invoice` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | -| `pay_keysend` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | -| `make_invoice` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | -| `lookup_invoice` | `LookupInvoiceMethod` | `LookupInvoiceSuccessResponse` | -| `list_transactions` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | -| `get_balance` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | -| `get_info` | `GetInfoMethod` | `GetInfoSuccessResponse` | -| `get_budget` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | -| `sign_message` | `SignMessageMethod` | `SignMessageSuccessResponse` | -| `create_connection` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | -| `make_hold_invoice` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | -| `cancel_hold_invoice`| `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| -| `settle_hold_invoice`| `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| +| Method | `Nip47Client` method | Request Class | Success Response Class | +|----------------------|-----------------------------|---------------------------|-----------------------------------| +| `pay_invoice` | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | +| `pay_keysend` | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | +| `make_invoice` | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | +| `lookup_invoice` | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` | +| `list_transactions` | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | +| `get_balance` | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | +| `get_info` | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` | +| `get_budget` | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | +| `sign_message` | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` | +| `create_connection` | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | +| `make_hold_invoice` | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | +| `cancel_hold_invoice`| `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| +| `settle_hold_invoice`| `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`. -## Implementing a Wallet Client +## Low-Level API -A wallet client connects to a user's lightning wallet to send payments, check -balances, create invoices, and list transactions. +The high-level `Nip47Client` and `Nip47Server` classes wrap the lower-level event +builders. You can use these directly if you need more control. -### 1. Parse the NWC Connection URI +### Wallet Client (Low-Level) -Users provide an NWC connection string from their wallet provider: +#### 1. Parse the NWC Connection URI ```kotlin val uri = "nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c..." val nwcConfig = Nip47WalletConnect.parse(uri) - -// nwcConfig.pubKeyHex — wallet service pubkey -// nwcConfig.relayUri — relay to communicate through (NormalizedRelayUrl) -// nwcConfig.secret — hex secret for the client signer -// nwcConfig.lud16 — optional lightning address ``` Supported URI schemes: `nostr+walletconnect://`, `nostrwalletconnect://`, `amethyst+walletconnect://` -### 2. Create the Client Signer - -The `secret` from the URI becomes the client's signing key: +#### 2. Create the Client Signer ```kotlin val clientSigner = NostrSignerInternal( @@ -88,12 +157,9 @@ val clientSigner = NostrSignerInternal( ) ``` -### 3. Build and Send Requests - -Use `LnZapPaymentRequestEvent.createRequest()` to create encrypted request events: +#### 3. Build and Send Requests ```kotlin -// Get balance val balanceRequest = GetBalanceMethod.create() val event = LnZapPaymentRequestEvent.createRequest( request = balanceRequest, @@ -101,36 +167,6 @@ val event = LnZapPaymentRequestEvent.createRequest( signer = clientSigner, ) // Send `event` to `nwcConfig.relayUri` - -// Pay an invoice -val payRequest = PayInvoiceMethod.create("lnbc50n1...") -val payEvent = LnZapPaymentRequestEvent.createRequest( - request = payRequest, - walletServicePubkey = nwcConfig.pubKeyHex, - signer = clientSigner, -) - -// Create an invoice (amount in millisats) -val invoiceRequest = MakeInvoiceMethod.create( - amount = 50000L, // 50 sats in millisats - description = "Coffee", -) -val invoiceEvent = LnZapPaymentRequestEvent.createRequest( - request = invoiceRequest, - walletServicePubkey = nwcConfig.pubKeyHex, - signer = clientSigner, -) - -// List transactions with pagination -val listRequest = ListTransactionsMethod.create( - limit = 20, - offset = 0, -) -val listEvent = LnZapPaymentRequestEvent.createRequest( - request = listRequest, - walletServicePubkey = nwcConfig.pubKeyHex, - signer = clientSigner, -) ``` To use NIP-44 encryption instead of NIP-04: @@ -144,202 +180,69 @@ val event = LnZapPaymentRequestEvent.createRequest( ) ``` -### 4. Receive and Parse Responses +#### 4. Receive and Parse Responses Subscribe to kind `23195` events on the NWC relay, filtered by the wallet -service pubkey and the request event ID. When a response arrives: +service pubkey and the request event ID: ```kotlin -// responseEvent is a LnZapPaymentResponseEvent (kind 23195) val response: Response = responseEvent.decrypt(clientSigner) when (response) { is GetBalanceSuccessResponse -> { - val balanceMillisats = response.result?.balance ?: 0L - val balanceSats = balanceMillisats / 1000L + val balanceSats = (response.result?.balance ?: 0L) / 1000L } is PayInvoiceSuccessResponse -> { val preimage = response.result?.preimage - val feesPaid = response.result?.fees_paid - } - is MakeInvoiceSuccessResponse -> { - val bolt11 = response.result?.invoice - val paymentHash = response.result?.payment_hash - } - is ListTransactionsSuccessResponse -> { - val transactions: List = response.result?.transactions ?: emptyList() - transactions.forEach { tx -> - // tx.type — "incoming" or "outgoing" - // tx.amount — in millisats - // tx.description, tx.created_at, tx.state, etc. - } - } - is GetInfoSuccessResponse -> { - val alias = response.result?.alias - val methods = response.result?.methods // supported methods - val lud16 = response.result?.lud16 - } - is PayInvoiceErrorResponse -> { - val errorCode = response.error?.code // NwcErrorCode enum - val errorMessage = response.error?.message } is NwcErrorResponse -> { - val errorCode = response.error?.code val errorMessage = response.error?.message } } ``` -### 5. Listen for Notifications (Optional) - -Subscribe to kind `23196`/`23197` events from the wallet: +#### 5. Listen for Notifications ```kotlin -// notificationEvent is an NwcNotificationEvent val notification: Notification = notificationEvent.decryptNotification(clientSigner) when (notification) { is PaymentReceivedNotification -> { val tx: NwcTransaction? = notification.notification - // tx?.amount, tx?.description, tx?.payment_hash, etc. } is PaymentSentNotification -> { val tx: NwcTransaction? = notification.notification } - is HoldInvoiceAcceptedNotification -> { - val data = notification.notification - // data?.payment_hash, data?.amount, data?.settle_deadline - } } ``` -### 6. Transaction State Helpers +### Wallet Service (Low-Level) -Transaction states from different wallet implementations may use different -casing. Use the case-insensitive helpers: +#### 1. Publish Capabilities ```kotlin -val tx: NwcTransaction = ... - -NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled" -NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending" -NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed" -NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted" -``` - -### 7. URI Persistence - -To save/restore the NWC connection: - -```kotlin -// Save -val nip47URI: Nip47WalletConnect.Nip47URI = nwcConfig.denormalize()!! -val json = Nip47WalletConnect.Nip47URI.serializer(nip47URI) - -// Restore -val restored = Nip47WalletConnect.Nip47URI.parser(json) -val normalized = restored.normalize()!! -``` - -## Implementing a Wallet Service - -A wallet service receives NWC requests from clients, processes them (e.g., -pays invoices via a Lightning node), and sends back responses. - -### 1. Publish Capabilities - -Advertise which methods your wallet supports by publishing a kind `13194` -event: - -```kotlin -val capabilities = listOf( - NwcMethod.PAY_INVOICE, - NwcMethod.GET_BALANCE, - NwcMethod.GET_INFO, - NwcMethod.MAKE_INVOICE, - NwcMethod.LOOKUP_INVOICE, - NwcMethod.LIST_TRANSACTIONS, -) - val infoTemplate = NwcInfoEvent.build( - capabilities = capabilities, + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE), encryptionSchemes = listOf("nip04", "nip44_v2"), - notificationTypes = listOf( - NwcNotificationType.PAYMENT_RECEIVED, - NwcNotificationType.PAYMENT_SENT, - ), + notificationTypes = listOf(NwcNotificationType.PAYMENT_RECEIVED), ) // Sign with wallet signer: walletSigner.sign(infoTemplate) ``` -### 2. Receive and Parse Requests - -Subscribe to kind `23194` events on your relay, filtered by your wallet -service pubkey in the `p` tag. When a request arrives: +#### 2. Parse Requests and Build Responses ```kotlin -// requestEvent is a LnZapPaymentRequestEvent (kind 23194) val request: Request = requestEvent.decryptRequest(walletSigner) -when (request) { - is PayInvoiceMethod -> { - val bolt11 = request.params?.invoice - val amount = request.params?.amount // optional override in millisats - // Process payment via your Lightning node... - } - is GetBalanceMethod -> { - // Query your Lightning node for balance... - } - is MakeInvoiceMethod -> { - val amount = request.params?.amount // in millisats - val description = request.params?.description - // Create invoice via your Lightning node... - } - is ListTransactionsMethod -> { - val limit = request.params?.limit - val offset = request.params?.offset - val type = request.params?.type // "incoming" or "outgoing" - // Query transaction history... - } - is GetInfoMethod -> { - // Return node info... - } - is GetBudgetMethod -> { - // Return budget info... - } - // ... handle other methods -} -``` - -### 3. Build and Send Responses - -Use `LnZapPaymentResponseEvent.createResponse()` to create encrypted -response events: - -```kotlin -// Success response for get_balance +// Build response val balanceResponse = GetBalanceSuccessResponse( - GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) // in millisats + GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) ) val responseEvent = LnZapPaymentResponseEvent.createResponse( response = balanceResponse, requestEvent = requestEvent, signer = walletSigner, ) -// Send responseEvent to the relay - -// Success response for pay_invoice -val payResponse = PayInvoiceSuccessResponse( - PayInvoiceSuccessResponse.PayInvoiceResultParams( - preimage = "0123456789abcdef", - fees_paid = 100L, - ) -) -val payResponseEvent = LnZapPaymentResponseEvent.createResponse( - response = payResponse, - requestEvent = requestEvent, - signer = walletSigner, -) // Error response val errorResponse = NwcErrorResponse( @@ -353,60 +256,46 @@ val errorEvent = LnZapPaymentResponseEvent.createResponse( ) ``` -To use NIP-44 encryption for responses: +#### 3. Send Notifications ```kotlin -val responseEvent = LnZapPaymentResponseEvent.createResponse( - response = balanceResponse, - requestEvent = requestEvent, - signer = walletSigner, - useNip44 = true, -) -``` - -### 4. Send Notifications - -Push notifications to clients for payment events: - -```kotlin -// Payment received notification -val notification = PaymentReceivedNotification( - notification = NwcTransaction( - type = NwcTransactionType.INCOMING, - state = NwcTransactionState.SETTLED, - invoice = "lnbc...", - amount = 50000L, // in millisats - payment_hash = "abc123", - settled_at = TimeUtils.now(), - created_at = TimeUtils.now(), - ), -) val notifEvent = NwcNotificationEvent.createNotification( - notification = notification, + notification = PaymentReceivedNotification( + notification = NwcTransaction( + type = NwcTransactionType.INCOMING, + state = NwcTransactionState.SETTLED, + invoice = "lnbc...", + amount = 50000L, + payment_hash = "abc123", + settled_at = TimeUtils.now(), + created_at = TimeUtils.now(), + ), + ), clientPubkey = clientPubkeyHex, signer = walletSigner, ) -// Send notifEvent to the relay ``` -### 5. Build Transaction Objects +## Transaction State Helpers -Transactions are used across multiple response types: +Transaction states from different wallet implementations may use different +casing. Use the case-insensitive helpers: ```kotlin -val transaction = NwcTransaction( - type = NwcTransactionType.INCOMING, // or OUTGOING - state = NwcTransactionState.SETTLED, // PENDING, SETTLED, FAILED, ACCEPTED - invoice = "lnbc50n1...", - description = "Coffee payment", - payment_hash = "abc123def456", - preimage = "fedcba654321", - amount = 50000L, // in millisats - fees_paid = 100L, // in millisats - created_at = 1693876497L, // unix timestamp - settled_at = 1693876500L, - expires_at = 1694876497L, -) +NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled" +NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending" +NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed" +NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted" +``` + +## URI Persistence + +```kotlin +// Save +val json = Nip47WalletConnect.Nip47URI.serializer(nwcConfig.denormalize()!!) + +// Restore +val restored = Nip47WalletConnect.Nip47URI.parser(json).normalize()!! ``` ## Error Codes @@ -431,13 +320,8 @@ val transaction = NwcTransaction( NWC supports two encryption schemes: -- **NIP-04** (default): Set `useNip44 = false` in event builders -- **NIP-44 v2**: Set `useNip44 = true` in event builders - -When building requests with NIP-44, the event includes an `encryption` tag: -``` -["encryption", "nip44_v2"] -``` +- **NIP-04** (default): `Nip47Client(useNip44 = false)` or `useNip44 = false` in event builders +- **NIP-44 v2**: `Nip47Client(useNip44 = true)` or `useNip44 = true` in event builders Clients can check a wallet's supported encryption via the info event: ```kotlin @@ -454,8 +338,6 @@ For apps handling many concurrent NWC events, use the built-in LRU caches: val requestCache = NostrWalletConnectRequestCache(signer) val responseCache = NostrWalletConnectResponseCache(signer) -// These cache up to 50 decrypted results and handle -// async retry logic for permission dialogs and timeouts. val request: Request? = requestCache.decryptRequest(requestEvent) val response: Response? = responseCache.decryptResponse(responseEvent) ``` From 493fe05ac41df28a6a8c72f0d8d5de6f32a6489d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 14 Mar 2026 10:18:08 -0400 Subject: [PATCH 027/130] Fixes missing parameter --- .../com/vitorpamplona/quartz/nip47WalletConnect/Request.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index cdce4c7ea..26e5711a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -129,7 +129,9 @@ class ListTransactionsMethod( offset: Int? = null, unpaid: Boolean? = null, type: String? = null, - ): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, type)) + unpaid_outgoing: Boolean? = null, + unpaid_incoming: Boolean? = null, + ): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type)) } } From 120981f8c4ecad4c17ec33da39741d5b054d8a89 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 14 Mar 2026 10:18:16 -0400 Subject: [PATCH 028/130] spotless apply --- .../ui/navigation/drawer/DrawerContent.kt | 2 +- .../screen/loggedIn/wallet/WalletViewModel.kt | 31 +++++--- .../quartz/nip47WalletConnect/Nip47Client.kt | 15 ++-- .../quartz/nip47WalletConnect/Nip47Server.kt | 17 +++-- .../nip47WalletConnect/AlbyInteropTest.kt | 16 ++++- .../quartz/nip47WalletConnect/RequestTest.kt | 16 ++++- .../quartz/nip47WalletConnect/ResponseTest.kt | 32 +++++++-- .../jackson/ResponseDeserializer.kt | 70 +++++++++++++++---- 8 files changed, 152 insertions(+), 47 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a3682c723..139ad10c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -46,10 +46,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd -import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 817c296e2..69a584025 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -112,9 +112,11 @@ class WalletViewModel : ViewModel() { // NWC balance is in millisats, convert to sats _balanceSats.value = (response.result?.balance ?: 0L) / 1000L } + is NwcErrorResponse -> { _error.value = response.error?.message ?: "Balance request failed" } + else -> {} } _isLoading.value = false @@ -135,6 +137,7 @@ class WalletViewModel : ViewModel() { is GetInfoSuccessResponse -> { _walletAlias.value = response.result?.alias } + else -> {} } } @@ -163,9 +166,11 @@ class WalletViewModel : ViewModel() { is ListTransactionsSuccessResponse -> { _transactions.value = response.result?.transactions ?: emptyList() } + is NwcErrorResponse -> { _error.value = response.error?.message ?: "Failed to load transactions" } + else -> {} } _isLoading.value = false @@ -189,16 +194,21 @@ class WalletViewModel : ViewModel() { // Refresh balance after payment fetchBalance() } + is PayInvoiceErrorResponse -> { - _sendState.value = SendState.Error( - response.error?.message ?: "Payment failed", - ) + _sendState.value = + SendState.Error( + response.error?.message ?: "Payment failed", + ) } + is NwcErrorResponse -> { - _sendState.value = SendState.Error( - response.error?.message ?: "Payment failed", - ) + _sendState.value = + SendState.Error( + response.error?.message ?: "Payment failed", + ) } + else -> { _sendState.value = SendState.Error("Unexpected response") } @@ -234,11 +244,14 @@ class WalletViewModel : ViewModel() { _receiveState.value = ReceiveState.Error("No invoice returned") } } + is NwcErrorResponse -> { - _receiveState.value = ReceiveState.Error( - response.error?.message ?: "Invoice creation failed", - ) + _receiveState.value = + ReceiveState.Error( + response.error?.message ?: "Invoice creation failed", + ) } + else -> { _receiveState.value = ReceiveState.Error("Unexpected response") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt index 5686fc1d5..05cb6e21f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -137,14 +137,12 @@ class Nip47Client( /** * Builds a lookup_invoice request event by payment hash. */ - suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = - buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) + suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) /** * Builds a lookup_invoice request event by BOLT11 invoice. */ - suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = - buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) + suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) /** * Builds a list_transactions request event. @@ -178,20 +176,17 @@ class Nip47Client( descriptionHash: String? = null, expiry: Long? = null, minCltvExpiryDelta: Int? = null, - ): LnZapPaymentRequestEvent = - buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) + ): LnZapPaymentRequestEvent = buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) /** * Builds a cancel_hold_invoice request event. */ - suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = - buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) + suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) /** * Builds a settle_hold_invoice request event. */ - suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = - buildRequest(SettleHoldInvoiceMethod.create(preimage)) + suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = buildRequest(SettleHoldInvoiceMethod.create(preimage)) /** * Builds a request event from any [Request] object. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt index 6b032f3ae..947bbecf8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -169,7 +169,16 @@ class Nip47Server( buildResponse( GetInfoSuccessResponse( GetInfoSuccessResponse.GetInfoResult( - alias, color, pubkey, network, blockHeight, blockHash, methods, notifications, null, lud16, + alias, + color, + pubkey, + network, + blockHeight, + blockHash, + methods, + notifications, + null, + lud16, ), ), requestEvent, @@ -181,8 +190,7 @@ class Nip47Server( suspend fun respondMakeInvoice( requestEvent: LnZapPaymentRequestEvent, transaction: NwcTransaction, - ): LnZapPaymentResponseEvent = - buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) + ): LnZapPaymentResponseEvent = buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) /** * Builds a lookup_invoice success response. @@ -190,8 +198,7 @@ class Nip47Server( suspend fun respondLookupInvoice( requestEvent: LnZapPaymentRequestEvent, transaction: NwcTransaction, - ): LnZapPaymentResponseEvent = - buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) + ): LnZapPaymentResponseEvent = buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) /** * Builds a list_transactions success response. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index c201ab285..6d1d0adb7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -334,8 +334,20 @@ class AlbyInteropTest { assertEquals("018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b", request.params?.preimage) assertNotNull(request.params?.tlv_records) assertEquals(1, request.params?.tlv_records?.size) - assertEquals(5482373484L, request.params?.tlv_records?.first()?.type) - assertEquals("fajsn341414fq", request.params?.tlv_records?.first()?.value) + assertEquals( + 5482373484L, + request.params + ?.tlv_records + ?.first() + ?.type, + ) + assertEquals( + "fajsn341414fq", + request.params + ?.tlv_records + ?.first() + ?.value, + ) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt index 3299b0927..21816b647 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -93,8 +93,20 @@ class RequestTest { assertEquals("preimage123", request.params?.preimage) assertNotNull(request.params?.tlv_records) assertEquals(1, request.params?.tlv_records?.size) - assertEquals(7629169L, request.params?.tlv_records?.first()?.type) - assertEquals("hex_value", request.params?.tlv_records?.first()?.value) + assertEquals( + 7629169L, + request.params + ?.tlv_records + ?.first() + ?.type, + ) + assertEquals( + "hex_value", + request.params + ?.tlv_records + ?.first() + ?.value, + ) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt index 148483360..c26e05bf0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -128,10 +128,34 @@ class ResponseTest { assertIs(response) assertNotNull(response.result?.transactions) assertEquals(2, response.result?.transactions?.size) - assertEquals("incoming", response.result?.transactions?.get(0)?.type) - assertEquals(100L, response.result?.transactions?.get(0)?.amount) - assertEquals("outgoing", response.result?.transactions?.get(1)?.type) - assertEquals(200L, response.result?.transactions?.get(1)?.amount) + assertEquals( + "incoming", + response.result + ?.transactions + ?.get(0) + ?.type, + ) + assertEquals( + 100L, + response.result + ?.transactions + ?.get(0) + ?.amount, + ) + assertEquals( + "outgoing", + response.result + ?.transactions + ?.get(1) + ?.type, + ) + assertEquals( + 200L, + response.result + ?.transactions + ?.get(1) + ?.amount, + ) } @Test diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt index c9b210ad9..ccb98c8b7 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt @@ -56,7 +56,10 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { if (hasError) { return when (resultType) { - NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + NwcMethod.PAY_INVOICE -> { + jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java) + } + else -> { val error = jp.codec.treeToValue(jsonObject.get("error"), NwcError::class.java) NwcErrorResponse(resultType ?: "", error) @@ -66,19 +69,58 @@ class ResponseDeserializer : StdDeserializer(Response::class.java) { if (hasResult || resultType != null) { return when (resultType) { - NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) - NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java) - NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceSuccessResponse::class.java) - NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceSuccessResponse::class.java) - NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java) - NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java) - NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java) - NwcMethod.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java) - NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java) - NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::class.java) - NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java) - NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java) - NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java) + NwcMethod.PAY_INVOICE -> { + jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java) + } + + NwcMethod.PAY_KEYSEND -> { + jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java) + } + + NwcMethod.MAKE_INVOICE -> { + jp.codec.treeToValue(jsonObject, MakeInvoiceSuccessResponse::class.java) + } + + NwcMethod.LOOKUP_INVOICE -> { + jp.codec.treeToValue(jsonObject, LookupInvoiceSuccessResponse::class.java) + } + + NwcMethod.LIST_TRANSACTIONS -> { + jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java) + } + + NwcMethod.GET_BALANCE -> { + jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java) + } + + NwcMethod.GET_INFO -> { + jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java) + } + + NwcMethod.GET_BUDGET -> { + jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java) + } + + NwcMethod.SIGN_MESSAGE -> { + jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java) + } + + NwcMethod.CREATE_CONNECTION -> { + jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::class.java) + } + + NwcMethod.MAKE_HOLD_INVOICE -> { + jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java) + } + + NwcMethod.CANCEL_HOLD_INVOICE -> { + jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java) + } + + NwcMethod.SETTLE_HOLD_INVOICE -> { + jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java) + } + else -> { // tries to guess for backward compatibility if (jsonObject.get("result")?.get("preimage") != null) { From 2fd623a97d3c0f3bc2751f63e62c3c31933b4da4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 14:30:38 +0000 Subject: [PATCH 029/130] fix: wallet screen showing "No wallet connected" on first open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WalletViewModel.init() was called inside LaunchedEffect (async), but hasWalletSetup() was checked synchronously during the first composition frame—before init had run. Moved init to run synchronously during composition and made hasWalletSetup a reactive StateFlow so the UI updates when wallet state changes. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt | 7 +++---- .../amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt | 8 +++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index ef0d94800..8ef787815 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -73,10 +73,9 @@ fun WalletScreen( nav: INav, ) { val walletViewModel: WalletViewModel = viewModel() + walletViewModel.init(accountViewModel.account) - LaunchedEffect(accountViewModel) { - walletViewModel.init(accountViewModel.account) - } + val hasWallet by walletViewModel.hasWalletSetup.collectAsState() Scaffold( topBar = { @@ -93,7 +92,7 @@ fun WalletScreen( ) }, ) { padding -> - if (!walletViewModel.hasWalletSetup()) { + if (!hasWallet) { NoWalletSetup( modifier = Modifier.padding(padding), nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 69a584025..ff670665d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -73,6 +73,9 @@ sealed class ReceiveState { class WalletViewModel : ViewModel() { private var account: Account? = null + private val _hasWalletSetup = MutableStateFlow(false) + val hasWalletSetup = _hasWalletSetup.asStateFlow() + private val _balanceSats = MutableStateFlow(null) val balanceSats = _balanceSats.asStateFlow() @@ -96,9 +99,12 @@ class WalletViewModel : ViewModel() { fun init(account: Account) { this.account = account + _hasWalletSetup.value = account.nip47SignerState?.hasWalletConnectSetup() == true } - fun hasWalletSetup(): Boolean = account?.nip47SignerState?.hasWalletConnectSetup() == true + fun refreshWalletSetup() { + _hasWalletSetup.value = account?.nip47SignerState?.hasWalletConnectSetup() == true + } fun fetchBalance() { val acc = account ?: return From 84035f887a6cc90e01b9dab390a6dd175f65af29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:18:53 +0000 Subject: [PATCH 030/130] feat: parse NIP-47 transaction metadata for sender/recipient display Add NwcTransactionMetadata parser that extracts comment, payer_data, recipient_data, and nostr zap data from the untyped metadata field. The transaction list UI now shows the Nostr user profile picture and name for zap senders/recipients, falls back to payer name/email or lightning address, and displays comment when it differs from description. https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../wallet/WalletTransactionsScreen.kt | 158 ++++++++++++++---- .../nip47WalletConnect/NwcTransaction.kt | 4 +- .../NwcTransactionMetadata.kt | 106 ++++++++++++ .../nip47WalletConnect/AlbyInteropTest.kt | 72 ++++++++ 4 files changed, 311 insertions(+), 29 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index a7df6653b..fb3a97ddd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -59,7 +59,10 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransactionType @@ -143,7 +146,7 @@ fun WalletTransactionsScreen( modifier = Modifier.padding(padding), ) { items(transactions) { tx -> - TransactionItem(tx) + TransactionItem(tx, accountViewModel, nav) HorizontalDivider() } } @@ -152,7 +155,11 @@ fun WalletTransactionsScreen( } @Composable -private fun TransactionItem(tx: NwcTransaction) { +private fun TransactionItem( + tx: NwcTransaction, + accountViewModel: AccountViewModel, + nav: INav, +) { val isIncoming = tx.type == NwcTransactionType.INCOMING val amountSats = (tx.amount ?: 0L) / 1000L val formattedAmount = @@ -169,6 +176,36 @@ private fun TransactionItem(tx: NwcTransaction) { } ?: "" } + val parsed = remember(tx.metadata) { tx.parsedMetadata() } + + // For incoming: show who sent it (nostr pubkey or payer name/email) + // For outgoing: show who received it (nostr recipient or recipient identifier) + val counterpartyPubkeyHex = + remember(parsed) { + if (isIncoming) parsed?.senderPubkeyHex() else parsed?.recipientPubkeyHex() + } + + val counterpartyDisplayName = + remember(parsed) { + if (isIncoming) { + parsed?.senderDisplayName() + } else { + parsed?.recipientIdentifier() + } + } + + // Show comment only if it differs from description + val commentText = + remember(parsed, tx.description) { + parsed?.comment?.let { comment -> + if (tx.description == null || !comment.equals(tx.description, ignoreCase = true)) { + comment + } else { + null + } + } + } + Row( modifier = Modifier @@ -176,34 +213,74 @@ private fun TransactionItem(tx: NwcTransaction) { .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = - if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, - contentDescription = - if (isIncoming) { - stringRes(R.string.wallet_incoming) - } else { - stringRes(R.string.wallet_outgoing) - }, - modifier = Modifier.size(24.dp), - tint = - if (isIncoming) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - - Spacer(modifier = Modifier.width(12.dp)) + if (counterpartyPubkeyHex != null) { + UserPicture( + userHex = counterpartyPubkeyHex, + size = 40.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = Modifier.width(12.dp)) + } else { + Icon( + imageVector = + if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward, + contentDescription = + if (isIncoming) { + stringRes(R.string.wallet_incoming) + } else { + stringRes(R.string.wallet_outgoing) + }, + modifier = Modifier.size(24.dp), + tint = + if (isIncoming) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + } Column(modifier = Modifier.weight(1f)) { - Text( - text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (counterpartyPubkeyHex != null) { + TransactionUserName(counterpartyPubkeyHex, counterpartyDisplayName, accountViewModel) + } else if (counterpartyDisplayName != null) { + Text( + text = counterpartyDisplayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (commentText != null) { + Text( + text = commentText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else if (counterpartyPubkeyHex != null || counterpartyDisplayName != null) { + val descOrType = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing) + Text( + text = descOrType, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( text = dateText, style = MaterialTheme.typography.bodySmall, @@ -224,3 +301,28 @@ private fun TransactionItem(tx: NwcTransaction) { ) } } + +@Composable +private fun TransactionUserName( + pubkeyHex: String, + fallbackName: String?, + accountViewModel: AccountViewModel, +) { + LoadUser(baseUserHex = pubkeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Medium, + accountViewModel = accountViewModel, + ) + } else { + Text( + text = fallbackName ?: pubkeyHex.take(8) + "...", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt index 067f12cd9..adff64b56 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -63,7 +63,9 @@ class NwcTransaction( var settled_at: Long? = null, var settle_deadline: Long? = null, var metadata: Any? = null, -) +) { + fun parsedMetadata(): NwcTransactionMetadata? = NwcTransactionMetadata.parse(metadata) +} class TlvRecord( var type: Long? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt new file mode 100644 index 000000000..a18be518a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt @@ -0,0 +1,106 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +class NwcTransactionMetadata( + val comment: String?, + val payerData: PayerData?, + val recipientData: RecipientData?, + val nostr: NostrZapData?, +) { + class PayerData( + val name: String?, + val email: String?, + val pubkey: String?, + ) + + class RecipientData( + val identifier: String?, + ) + + class NostrZapData( + val pubkeyHex: String?, + val recipientPubkeyHex: String?, + ) + + fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) } + + fun senderDisplayName(): String? = payerData?.name ?: payerData?.email + + fun recipientIdentifier(): String? = recipientData?.identifier + + fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex + + companion object { + fun parse(metadata: Any?): NwcTransactionMetadata? { + val map = metadata as? Map<*, *> ?: return null + + val comment = map["comment"] as? String + + val payerData = (map["payer_data"] as? Map<*, *>)?.let { pd -> + PayerData( + name = pd["name"] as? String, + email = pd["email"] as? String, + pubkey = pd["pubkey"] as? String, + ) + } + + val recipientData = (map["recipient_data"] as? Map<*, *>)?.let { rd -> + RecipientData( + identifier = rd["identifier"] as? String, + ) + } + + val nostr = (map["nostr"] as? Map<*, *>)?.let { n -> + val rawPubkey = n["pubkey"] as? String + val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) } + + val tags = n["tags"] as? List<*> + val recipientHex = tags?.firstNotNullOfOrNull { tag -> + val tagList = tag as? List<*> + if (tagList != null && tagList.size >= 2 && tagList[0] == "p") { + tagList[1] as? String + } else { + null + } + } + + NostrZapData( + pubkeyHex = pubkeyHex, + recipientPubkeyHex = recipientHex, + ) + } + + if (comment == null && payerData == null && recipientData == null && nostr == null) { + return null + } + + return NwcTransactionMetadata( + comment = comment, + payerData = payerData, + recipientData = recipientData, + nostr = nostr, + ) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index 6d1d0adb7..f6221cdc6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -500,6 +500,78 @@ class AlbyInteropTest { assertNotNull(response.result?.metadata) } + @Test + fun testMetadataParserComment() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Great post!"}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("Great post!", parsed.comment) + assertNull(parsed.payerData) + assertNull(parsed.nostr) + } + + @Test + fun testMetadataParserPayerData() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"payer_data":{"name":"Alice","email":"alice@example.com","pubkey":"abc123"}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("Alice", parsed.payerData?.name) + assertEquals("alice@example.com", parsed.payerData?.email) + assertEquals("abc123", parsed.payerData?.pubkey) + assertEquals("Alice", parsed.senderDisplayName()) + } + + @Test + fun testMetadataParserNostrZap() { + val senderHex = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + val recipientHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":21000,"created_at":1000,"settled_at":2000,"metadata":{"nostr":{"pubkey":"$senderHex","tags":[["p","$recipientHex"],["amount","21000"]]}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals(senderHex, parsed.senderPubkeyHex()) + assertEquals(recipientHex, parsed.recipientPubkeyHex()) + } + + @Test + fun testMetadataParserRecipientData() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"outgoing","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"recipient_data":{"identifier":"alice@getalby.com"}}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNotNull(parsed) + assertEquals("alice@getalby.com", parsed.recipientIdentifier()) + } + + @Test + fun testMetadataParserNullForSimpleMetadata() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"a":123}}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNull(parsed) + } + + @Test + fun testMetadataParserNullMetadata() { + val json = + """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000}}""" + val response = OptimizedJsonMapper.fromJsonTo(json) + assertIs(response) + val parsed = response.result?.parsedMetadata() + assertNull(parsed) + } + @Test fun testJsSdkGetInfoWithAllMethods() { // JS SDK advertises all 13 single methods + notifications From 03e1c32757035f65901f2561f5b97521fbf8b351 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 14 Mar 2026 11:20:33 -0400 Subject: [PATCH 031/130] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../quartz/nip47WalletConnect/NwcNotificationEvent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt index 9e9af6b2c..d5884bbb1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcNotificationEvent.kt @@ -48,7 +48,7 @@ class NwcNotificationEvent( suspend fun decryptNotification(signer: NostrSigner): Notification { if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - val jsonText = signer.nip44Decrypt(content, talkingWith(signer.pubKey)) + val jsonText = signer.decrypt(content, talkingWith(signer.pubKey)) return OptimizedJsonMapper.fromJsonTo(jsonText) } From 78c66b00ebf0600d8805a325709fd3b5fd16fc9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Mar 2026 15:29:19 +0000 Subject: [PATCH 032/130] fix: align arrow icons to 40dp to match user picture size https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ --- .../ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index fb3a97ddd..0f1818809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -231,7 +231,7 @@ private fun TransactionItem( } else { stringRes(R.string.wallet_outgoing) }, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(40.dp), tint = if (isIncoming) { MaterialTheme.colorScheme.primary From d4ae3411c7ffd30d745b593a734bbcbb95086ee2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 14 Mar 2026 11:36:58 -0400 Subject: [PATCH 033/130] spotless --- .../NwcTransactionMetadata.kt | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt index a18be518a..a275ac469 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransactionMetadata.kt @@ -57,39 +57,43 @@ class NwcTransactionMetadata( val comment = map["comment"] as? String - val payerData = (map["payer_data"] as? Map<*, *>)?.let { pd -> - PayerData( - name = pd["name"] as? String, - email = pd["email"] as? String, - pubkey = pd["pubkey"] as? String, - ) - } - - val recipientData = (map["recipient_data"] as? Map<*, *>)?.let { rd -> - RecipientData( - identifier = rd["identifier"] as? String, - ) - } - - val nostr = (map["nostr"] as? Map<*, *>)?.let { n -> - val rawPubkey = n["pubkey"] as? String - val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) } - - val tags = n["tags"] as? List<*> - val recipientHex = tags?.firstNotNullOfOrNull { tag -> - val tagList = tag as? List<*> - if (tagList != null && tagList.size >= 2 && tagList[0] == "p") { - tagList[1] as? String - } else { - null - } + val payerData = + (map["payer_data"] as? Map<*, *>)?.let { pd -> + PayerData( + name = pd["name"] as? String, + email = pd["email"] as? String, + pubkey = pd["pubkey"] as? String, + ) } - NostrZapData( - pubkeyHex = pubkeyHex, - recipientPubkeyHex = recipientHex, - ) - } + val recipientData = + (map["recipient_data"] as? Map<*, *>)?.let { rd -> + RecipientData( + identifier = rd["identifier"] as? String, + ) + } + + val nostr = + (map["nostr"] as? Map<*, *>)?.let { n -> + val rawPubkey = n["pubkey"] as? String + val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) } + + val tags = n["tags"] as? List<*> + val recipientHex = + tags?.firstNotNullOfOrNull { tag -> + val tagList = tag as? List<*> + if (tagList != null && tagList.size >= 2 && tagList[0] == "p") { + tagList[1] as? String + } else { + null + } + } + + NostrZapData( + pubkeyHex = pubkeyHex, + recipientPubkeyHex = recipientHex, + ) + } if (comment == null && payerData == null && recipientData == null && nostr == null) { return null From 42574f302cfcdb55aed1ac93df55799bdd5e06f4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 14 Mar 2026 11:39:02 -0400 Subject: [PATCH 034/130] updates change log --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 193605cf9..51195f4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Redesigns Media Player - Turn video controller creation into a flow to fix playback lifecycle issues - Adds support for uploading audio -Adds support for NIP events (kind 30817) +Adds support for NIP-47 Wallets Adds support for NIP-52 Calendar appointments @@ -23,6 +23,8 @@ Adds support for NIP-39 External Identities with kind 10011 Adds support for NIP-C0 Code Snippets +Adds support for NIPs on Nostr (event kind 30817) + Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5 Adds support for BUD-10 "Blossom:" URIs in images, audios, videos, and documents. From 0dce9542fb7610322dca625867432f8cf1e880b1 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 14 Mar 2026 15:41:38 +0000 Subject: [PATCH 035/130] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e733a9483..be1bd8761 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -125,6 +125,7 @@ अष्टक अपक्रम अपक्रम + सफल संयोजनों का प्रतिशत पुनःप्रसारक के साथ संयोजन अपक्रमों की संख्या इस सत्र में मुख्य सूचनावली निजी संदेश सूचनावली @@ -150,6 +151,7 @@ सर्वनाम लै॰जाल पता लै॰जाल पता (पुराना) + संचारयन्त्र में अभिलेखन करें चित्रालय में अभिलेखन करें चित्र का अभिलेखन किया गया चित्रालय क्रमक में चलचित्र अवरोहण आरम्भ हुआ … @@ -158,6 +160,7 @@ चलचित्र को संचारयन्त्र के चलचित्रालय में सुरक्षित रखा गया चलचित्र को सुरक्षित रखने में असफल चित्र आरोहण + अभिलेख आरोहण एक चित्र लें चलचित्र का अभिलेखन करें ऐक संदेश का अभिलेखन करें @@ -429,6 +432,7 @@ ज्साप गोपनीयता नियन्त्रण करता है कि आपका परिचय कैसे दिखाया जाता है ज्साप भेजने पर। संयोजन धनकोष + पुनःप्रसारक सूचनावली देखें प्रतिज्ञा मात्रा साट्स में मतदान प्रकाशित करें अनिवार्य प्रपत्रस्थान : @@ -642,6 +646,7 @@ जुडें आज विषयवस्तु चेतावनी + सावधान : %1$s इस पत्र में संवेदनशील विषयवस्तु समावेशित है जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है संवेदनशील विषयवस्तु सर्वदा छिपाएँ संवेदनशील विषयवस्तु सर्वदा दिखाएँ @@ -670,6 +675,7 @@ अष्टकों में मात्रा जो इस पुनःप्रसारक से प्राप्त हुआ था छलनियाँ तथा घटनाएँ समेत %1$s से पुनःप्रसारक जानकारी प्राप्त करने के प्रयास में अपक्रम हुआ अधिपति + द्वारा उपयुक्त सेवा कुंजी %1$s चलाया जा रहा है %1$s (%2$s) चलाया जा रहा है @@ -828,6 +834,7 @@ स्थान प्राप्त किया जा रहा है स्थान प्राप्त करने की अनुमति नहीं आपके विषयवस्तु दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है। यह आदर्श है किसी कार्यालय अनुचित विषयवस्तु के लिए अथवा जो कुछ लोगों के लिए आपत्तिजनक अथवा व्याकुल करनेवाला लग सकता है + कारण (विकल्पात्मक) नयी सुविधा इस कार्यशैली सक्षम करने के लिए अमेथिस्ट के द्वारा निप॰-१७ संदेश (उपहारकोषयुक्त, आच्छादित सीधा तथा झुण्ड संदेश) भेजना पडेगा। यह निप॰-१७ नया है तथा अनेक ग्राहक इसे कार्यान्वित किया नहीं अब तक। सुनिश्चित करें कि प्राप्तकर्ता एक अनुकूल ग्राहक का प्रयोग कर रहे हैं। सक्रिय करें @@ -866,6 +873,7 @@ चित्रों का अवरोहण कब करें चिति की अनुकृति करें टाँकाफलक में अनुकृति करें + टाँकाफलक में एन॰परिचय की अनुकृति करें टाँकाफलक में एनपुब॰ की अनुकृति करें बाँटें अथवा अभिलेखन करें टाँकाफलक में जालपता की अनुकृति करें @@ -920,6 +928,8 @@ सुनिश्चित करें हस्ताक्षर क्रमक ने इस व्यापार को अनुमति दिया कोई धनकोष प्राप्त नहीं लैटनिंग चालान चुकाने के लिए (अपक्रम : %1$s)। कृपया एक लैटनिंग धनकोष की स्थापना करें ज्सापों का प्रयोग करने के लिए कोई धनकोष प्राप्त नहीं लैटनिंग चालान चुकाने के लिए। कृपया एक लैटनिंग धनकोष की स्थापना करें ज्सापों का प्रयोग करने के लिए + ब्लोस्सम॰ योजक खोला नहीं जा सकता + ब्लोस्सम॰ क्रमक प्राप्त नहीं। कृपया एक स्थानीय ब्लोस्सम॰ क्रमक की स्थापना करें इस अभिलेख को देखने के लिए छिपाए गये शब्द नया शब्द अथवा वाक्य छिपाएँ परिचय चित्र @@ -1043,6 +1053,7 @@ छोटे चतुरंग सुरक्षार्थ छलनियाँ + अनुचरित आयात करें नया पत्र प्रकाशन नये छोटे : चित्र अथवा चलचित्र नया सामुदायिक टीका @@ -1088,7 +1099,15 @@ ज्साप विभाजन निरस्त करें विषयवस्तु चेतावनी जोडें विषयवस्तु चेतावनी हटाएँ + समापन दिनांक जोडें + समापन दिनांक हटाएँ + समापन दिनांक + पत्र इस दिनांक के पश्चात ग्राहकों द्वारा छिपाया जाएगा (निप॰४०) + समापन दिनांक तथा समय चुनें + %1$s में समाप्त + समापन समय क्यूआर॰ क्रमचित्र के रूप में एनपुब॰ को दिखाएँ + क्यूआर॰ क्रमचित्र के रूप में एन॰परिचय को दिखाएँ अमान्य पता अमेथिस्ट को एक वैश्विक वस्तु विभेदक प्राप्त हुआ खोलने के लिए परन्तु वह विभेदक अमान्य था : %1$s सीधा संदेश आगतपेटिका पुनःप्रसारक @@ -1156,6 +1175,9 @@ बाधित पुनःप्रसारक बाधित पुनःप्रसारक अमेथिस्ट इन पुनःप्रसारकों से कभी नहीं जुडेगा + पुनःप्रसारक स्थापना विकल्प निर्यात + लेख के रूप में निर्यात + ज्सिप॰ (जेसोन॰) के रूप में निर्यात क्रमलेखकों को ज्साप करें! आपका दान हमारा सहायक है परिवर्तन लाने में। प्रत्येक साट गणनीय है! दान करें अभी @@ -1261,6 +1283,19 @@ विषयसूचक खोज : #%1$s अनुवाद ना करें यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा। + अनुवाद इस में + भाषा चयन करें जिसमें विषयवस्तु का अनुवाद करना है। + भाषा प्रस्तुति आद्यताएँ + प्रत्येक अनुवाद भाषा युग्म के लिए चयन करें किस भाषा को पहले दिखाना है। + %1$s से %2$s + भाषा ढूँढें + भाषा जोडें + भाषा युग्म जोडें + स्रोत भाषा + लक्ष्य भाषा + पहले %1$s दिखाएँ + अभी कोई भाषा प्रस्तुति आद्यताएँ नहीं। ये स्वचालित रूप से बनाए जाएँगे जब अनुवाद होगा अथवा आप इनहें स्वयम हाथ से जोड सकते हैं। + आद्यता मिटाएँ विराम चलाएँ विकल्प सूची खोलें @@ -1270,6 +1305,7 @@ क्रमदोष सूचनापत्र प्राप्त क्या आप निकटकालिक क्रमदोष सूचनापत्र एक सीधे सन्देश में अमेथिस्ट को भेजना चाहते हैं। कोई व्यक्तिगत जानकारी बाँटी नहीं जाएगी भेजें + यह सन्देश %1$s में अदृश्य हो जाएगा यह सन्देश %1$d दिनों में अदृश्य हो जाएगा हस्ताक्षरकर्ता चुनें पहले से ही सूची में @@ -1471,4 +1507,8 @@ ध्वनि सन्देश ध्वनि उत्तर विकि॰ + एक बढिया सूचनावली के साथ आरम्भ करें उन लोगों का अनुचरण करके जिनको आपके द्वारा विश्वास प्राप्त कोई व्यक्ति करते हैं। + अनुचरित सूची आयात + प्रयोक्ता चुनें अनुचरण करने के लिए + प्रयोक्ता परिचय जिससे आयात करना है From c290a1c7793c9c398f751b60f953afbd41259f36 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 14 Mar 2026 12:58:18 +0100 Subject: [PATCH 036/130] - SelectFromGallery and SelectFromFiles now accept an enabled param separate from isUploading - All ViewModels track isUploadingFile alongside isUploadingImage, set via hasNonMedia() - All screen call sites pass source-specific flags so only the initiating button spins - canPost() methods block posting during file uploads too - ChatNewMessageViewModel delegates to ChatFileUploadState (fixes pre-existing dead state) - cancel() methods reset both flags in all ViewModels --- .../amethyst/ui/actions/EditPostView.kt | 4 +++- .../amethyst/ui/actions/EditPostViewModel.kt | 11 +++++++++-- .../amethyst/ui/actions/uploads/SelectFromFiles.kt | 6 ++++-- .../ui/actions/uploads/SelectFromGallery.kt | 9 ++++++--- .../ui/note/nip22Comments/CommentPostViewModel.kt | 10 +++++++++- .../note/nip22Comments/GenericCommentPostScreen.kt | 4 +++- .../privateDM/send/ChatNewMessageViewModel.kt | 4 +++- .../chats/privateDM/send/NewGroupDMScreen.kt | 4 +++- .../privateDM/send/upload/ChatFileUploader.kt | 14 ++++++++++++-- .../send/ChannelNewMessageViewModel.kt | 12 +++++++++++- .../loggedIn/chats/utils/ChatFileUploadState.kt | 2 ++ .../discover/nip99Classifieds/NewProductScreen.kt | 4 +++- .../nip99Classifieds/NewProductViewModel.kt | 10 +++++++++- .../ui/screen/loggedIn/home/ShortNotePostScreen.kt | 4 +++- .../screen/loggedIn/home/ShortNotePostViewModel.kt | 12 ++++++++++-- .../publicMessages/NewPublicMessageScreen.kt | 4 +++- .../publicMessages/NewPublicMessageViewModel.kt | 12 +++++++++++- 17 files changed, 104 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index d27935f49..14b9bb5cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -372,6 +372,7 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -379,7 +380,8 @@ private fun BottomRowActions(postViewModel: EditPostViewModel) { } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 1c9d57d60..12a7482ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -80,6 +80,7 @@ open class EditPostViewModel : ViewModel() { var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) var isUploadingImage by mutableStateOf(false) + var isUploadingFile by mutableStateOf(false) var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -180,7 +181,11 @@ open class EditPostViewModel : ViewModel() { val myAccount = account val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + if (myMultiOrchestrator.hasNonMedia()) { + isUploadingFile = true + } else { + isUploadingImage = true + } val results = myMultiOrchestrator.upload( @@ -244,6 +249,7 @@ open class EditPostViewModel : ViewModel() { } isUploadingImage = false + isUploadingFile = false } } @@ -256,6 +262,7 @@ open class EditPostViewModel : ViewModel() { multiOrchestrator = null urlPreview = null isUploadingImage = false + isUploadingFile = false wantsInvoice = false @@ -296,7 +303,7 @@ open class EditPostViewModel : ViewModel() { } } - fun canPost() = message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && multiOrchestrator == null + fun canPost() = message.text.isNotBlank() && !isUploadingImage && !isUploadingFile && !wantsInvoice && multiOrchestrator == null fun selectImage(uris: ImmutableList) { multiOrchestrator = MultiOrchestrator(uris) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt index 354853720..7ed03deb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromFiles.kt @@ -48,6 +48,7 @@ import java.util.concurrent.atomic.AtomicBoolean @Composable fun SelectFromFiles( isUploading: Boolean, + enabled: Boolean = true, tint: Color, modifier: Modifier, onFilesChosen: (ImmutableList) -> Unit, @@ -64,19 +65,20 @@ fun SelectFromFiles( ) } - FileSelectButton(isUploading, tint, modifier) { showFileSelect = true } + FileSelectButton(isUploading, enabled, tint, modifier) { showFileSelect = true } } @Composable private fun FileSelectButton( isUploading: Boolean, + enabled: Boolean, tint: Color, modifier: Modifier, onClick: () -> Unit, ) { IconButton( modifier = modifier, - enabled = !isUploading, + enabled = enabled && !isUploading, onClick = { onClick() }, ) { if (!isUploading) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index 0e38451c5..bd7eb8fe2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -69,6 +69,7 @@ class SelectedMedia( @Composable fun SelectFromGallery( isUploading: Boolean, + enabled: Boolean = true, tint: Color, modifier: Modifier, onImageChosen: (ImmutableList) -> Unit, @@ -85,12 +86,13 @@ fun SelectFromGallery( ) } - GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true } + GallerySelectButton(isUploading, enabled, tint, modifier) { showGallerySelect = true } } @Composable fun SelectSingleFromGallery( isUploading: Boolean, + enabled: Boolean = true, tint: Color, modifier: Modifier, onImageChosen: (SelectedMedia) -> Unit, @@ -107,19 +109,20 @@ fun SelectSingleFromGallery( ) } - GallerySelectButton(isUploading, tint, modifier) { showGallerySelect = true } + GallerySelectButton(isUploading, enabled, tint, modifier) { showGallerySelect = true } } @Composable private fun GallerySelectButton( isUploading: Boolean, + enabled: Boolean, tint: Color, modifier: Modifier, onClick: () -> Unit, ) { IconButton( modifier = modifier, - enabled = !isUploading, + enabled = enabled && !isUploading, onClick = { onClick() }, ) { if (!isUploading) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index c60b0a42e..91c5662e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -150,6 +150,7 @@ open class CommentPostViewModel : val urlPreviews = PreviewState() var isUploadingImage by mutableStateOf(false) + var isUploadingFile by mutableStateOf(false) var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -487,7 +488,11 @@ open class CommentPostViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + if (myMultiOrchestrator.hasNonMedia()) { + isUploadingFile = true + } else { + isUploadingImage = true + } val results = myMultiOrchestrator.upload( @@ -552,6 +557,7 @@ open class CommentPostViewModel : } isUploadingImage = false + isUploadingFile = false } } @@ -565,6 +571,7 @@ open class CommentPostViewModel : multiOrchestrator = null isUploadingImage = false + isUploadingFile = false notifying = null @@ -677,6 +684,7 @@ open class CommentPostViewModel : fun canPost(): Boolean = message.text.isNotBlank() && !isUploadingImage && + !isUploadingFile && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && multiOrchestrator == null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index ed922a496..5e8c197c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -391,6 +391,7 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -398,7 +399,8 @@ private fun BottomRowActions(postViewModel: CommentPostViewModel) { } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 747920b86..db6f377f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -141,7 +141,8 @@ class ChatNewMessageViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) + val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false + val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -692,6 +693,7 @@ class ChatNewMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && uploadState?.isUploadingImage != true && + uploadState?.isUploadingFile != true && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 3a2f1f859..ef5f32b11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -386,6 +386,7 @@ private fun BottomRowActions( if (postViewModel.room != null) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -393,7 +394,8 @@ private fun BottomRowActions( } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index ddc001805..b6f8f3f91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -43,7 +43,11 @@ class ChatFileUploader( onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return - viewState.isUploadingImage = true + if (orchestrator.hasNonMedia()) { + viewState.isUploadingFile = true + } else { + viewState.isUploadingImage = true + } val cipher = AESGCM() @@ -77,6 +81,7 @@ class ChatFileUploader( } viewState.isUploadingImage = false + viewState.isUploadingFile = false } // ------ @@ -90,7 +95,11 @@ class ChatFileUploader( onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return - viewState.isUploadingImage = true + if (orchestrator.hasNonMedia()) { + viewState.isUploadingFile = true + } else { + viewState.isUploadingImage = true + } val results = orchestrator.upload( @@ -121,5 +130,6 @@ class ChatFileUploader( } viewState.isUploadingImage = false + viewState.isUploadingFile = false } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 98b36ef0e..0c6c59dcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -135,6 +135,7 @@ open class ChannelNewMessageViewModel : var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) var isUploadingImage by mutableStateOf(false) + var isUploadingFile by mutableStateOf(false) var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -337,7 +338,11 @@ open class ChannelNewMessageViewModel : val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch - isUploadingImage = true + if (myMultiOrchestrator.hasNonMedia()) { + isUploadingFile = true + } else { + isUploadingImage = true + } val results = myMultiOrchestrator.upload( @@ -379,6 +384,7 @@ open class ChannelNewMessageViewModel : } isUploadingImage = false + isUploadingFile = false } } @@ -546,6 +552,9 @@ open class ChannelNewMessageViewModel : userSuggestions?.reset() userSuggestionsMainMessage = null + isUploadingImage = false + isUploadingFile = false + iMetaAttachments.reset() emojiSuggestions?.reset() @@ -633,6 +642,7 @@ open class ChannelNewMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && uploadState?.isUploadingImage != true && + uploadState?.isUploadingFile != true && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && uploadState?.multiOrchestrator == null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 9475afa73..384c8299e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -37,6 +37,7 @@ class ChatFileUploadState( val defaultServer: ServerName, ) { var isUploadingImage by mutableStateOf(false) + var isUploadingFile by mutableStateOf(false) var selectedServer by mutableStateOf(defaultServer) var caption by mutableStateOf("") @@ -65,6 +66,7 @@ class ChatFileUploadState( fun reset() { multiOrchestrator = null isUploadingImage = false + isUploadingFile = false caption = "" selectedServer = defaultServer } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index aa651348e..d4c3e1853 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -359,6 +359,7 @@ private fun BottomRowActions(postViewModel: NewProductViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -366,7 +367,8 @@ private fun BottomRowActions(postViewModel: NewProductViewModel) { } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index be86ac151..b2014fc66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -134,6 +134,7 @@ open class NewProductViewModel : val urlPreviews = PreviewState() var isUploadingImage by mutableStateOf(false) + var isUploadingFile by mutableStateOf(false) var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -399,7 +400,11 @@ open class NewProductViewModel : val myAccount = account ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + if (myMultiOrchestrator.hasNonMedia()) { + isUploadingFile = true + } else { + isUploadingImage = true + } val results = myMultiOrchestrator.upload( @@ -445,6 +450,7 @@ open class NewProductViewModel : } isUploadingImage = false + isUploadingFile = false } } @@ -455,6 +461,7 @@ open class NewProductViewModel : multiOrchestrator = null isUploadingImage = false + isUploadingFile = false wantsInvoice = false wantsZapraiser = false @@ -576,6 +583,7 @@ open class NewProductViewModel : fun canPost(): Boolean = message.text.isNotBlank() && !isUploadingImage && + !isUploadingFile && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && title.text.isNotBlank() && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 8f799892e..d6d473a9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -494,6 +494,7 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -501,7 +502,8 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 422394b5b..d9018502f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -179,6 +179,7 @@ open class ShortNotePostViewModel : val urlPreviews = PreviewState() var isUploadingImage by mutableStateOf(false) + var isUploadingFile by mutableStateOf(false) var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -829,7 +830,11 @@ open class ShortNotePostViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + if (myMultiOrchestrator.hasNonMedia()) { + isUploadingFile = true + } else { + isUploadingImage = true + } val results = myMultiOrchestrator.upload( @@ -886,6 +891,7 @@ open class ShortNotePostViewModel : } isUploadingImage = false + isUploadingFile = false } } @@ -898,6 +904,7 @@ open class ShortNotePostViewModel : multiOrchestrator = null isUploadingImage = false + isUploadingFile = false voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = null @@ -1032,12 +1039,13 @@ open class ShortNotePostViewModel : fun canPost(): Boolean { // Voice messages can be posted without text (with either uploaded or pending recording) if (voiceMetadata != null || voiceRecording != null) { - return !isUploadingVoice && !isUploadingImage && processingPreset == null + return !isUploadingVoice && !isUploadingImage && !isUploadingFile && processingPreset == null } // Regular text/media posts require text return message.text.isNotBlank() && !isUploadingImage && + !isUploadingFile && !isUploadingVoice && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index a442d467c..0c2ff9fc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -321,6 +321,7 @@ private fun BottomRowActions( ) { SelectFromGallery( isUploading = postViewModel.isUploadingImage, + enabled = !postViewModel.isUploadingFile, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { @@ -328,7 +329,8 @@ private fun BottomRowActions( } SelectFromFiles( - isUploading = postViewModel.isUploadingImage, + isUploading = postViewModel.isUploadingFile, + enabled = !postViewModel.isUploadingImage, tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index d3ccd5421..14495a37a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -150,6 +150,7 @@ class NewPublicMessageViewModel : val urlPreviews = PreviewState() var isUploadingImage by mutableStateOf(false) + var isUploadingFile by mutableStateOf(false) var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -439,7 +440,11 @@ class NewPublicMessageViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - isUploadingImage = true + if (myMultiOrchestrator.hasNonMedia()) { + isUploadingFile = true + } else { + isUploadingImage = true + } val results = myMultiOrchestrator.upload( @@ -495,6 +500,7 @@ class NewPublicMessageViewModel : } isUploadingImage = false + isUploadingFile = false } } @@ -523,6 +529,9 @@ class NewPublicMessageViewModel : userSuggestions?.reset() userSuggestionsMainMessage = null + isUploadingImage = false + isUploadingFile = false + iMetaAttachments.reset() emojiSuggestions?.reset() @@ -629,6 +638,7 @@ class NewPublicMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && !isUploadingImage && + !isUploadingFile && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && From 42542159d456bc0b6f9c0bb1405a87e1cca20c59 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 14 Mar 2026 13:06:12 +0100 Subject: [PATCH 037/130] bugfix: remove dead code bugfix: ChannelNewMessageViewModel canPost checks wrong state --- .../ui/actions/uploads/SelectFromGallery.kt | 3 +-- .../send/ChannelNewMessageViewModel.kt | 15 +++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt index bd7eb8fe2..fcae06fc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/SelectFromGallery.kt @@ -92,7 +92,6 @@ fun SelectFromGallery( @Composable fun SelectSingleFromGallery( isUploading: Boolean, - enabled: Boolean = true, tint: Color, modifier: Modifier, onImageChosen: (SelectedMedia) -> Unit, @@ -109,7 +108,7 @@ fun SelectSingleFromGallery( ) } - GallerySelectButton(isUploading, enabled, tint, modifier) { showGallerySelect = true } + GallerySelectButton(isUploading, true, tint, modifier) { showGallerySelect = true } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 0c6c59dcf..cd69ddf43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -134,8 +134,8 @@ open class ChannelNewMessageViewModel : var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) - var isUploadingImage by mutableStateOf(false) - var isUploadingFile by mutableStateOf(false) + val isUploadingImage: Boolean get() = uploadState?.isUploadingImage ?: false + val isUploadingFile: Boolean get() = uploadState?.isUploadingFile ?: false var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -339,9 +339,9 @@ open class ChannelNewMessageViewModel : val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch if (myMultiOrchestrator.hasNonMedia()) { - isUploadingFile = true + uploadState.isUploadingFile = true } else { - isUploadingImage = true + uploadState.isUploadingImage = true } val results = @@ -383,8 +383,8 @@ open class ChannelNewMessageViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false - isUploadingFile = false + uploadState.isUploadingImage = false + uploadState.isUploadingFile = false } } @@ -552,8 +552,7 @@ open class ChannelNewMessageViewModel : userSuggestions?.reset() userSuggestionsMainMessage = null - isUploadingImage = false - isUploadingFile = false + uploadState?.reset() iMetaAttachments.reset() From 1fc0ba8628a764a99d41b953f6a854288edfb9d9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 14 Mar 2026 16:01:23 +0100 Subject: [PATCH 038/130] refactor: extract MediaUploadTracker with common methods bugfixes: ChatFileUploadState.canPost() was missing isUploadingFile check ChatNewMessageViewModel.cancel() was never resetting upload flags Double-reset in ChatFileUploader and ChannelNewMessageViewModel --- .../amethyst/ui/actions/EditPostViewModel.kt | 20 ++++---- .../ui/actions/uploads/MediaUploadTracker.kt | 47 +++++++++++++++++++ .../nip22Comments/CommentPostViewModel.kt | 21 ++++----- .../privateDM/send/ChatNewMessageViewModel.kt | 4 +- .../privateDM/send/upload/ChatFileUploader.kt | 18 ++----- .../send/ChannelNewMessageViewModel.kt | 12 ++--- .../chats/utils/ChatFileUploadState.kt | 11 +++-- .../nip99Classifieds/NewProductViewModel.kt | 21 ++++----- .../loggedIn/home/ShortNotePostViewModel.kt | 23 ++++----- .../NewPublicMessageViewModel.kt | 21 ++++----- 10 files changed, 103 insertions(+), 95 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/MediaUploadTracker.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 12a7482ba..fec34ac2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.service.uploads.MediaCompressor import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState @@ -79,8 +80,9 @@ open class EditPostViewModel : ViewModel() { var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) - var isUploadingImage by mutableStateOf(false) - var isUploadingFile by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -181,11 +183,7 @@ open class EditPostViewModel : ViewModel() { val myAccount = account val myMultiOrchestrator = multiOrchestrator ?: return@launch - if (myMultiOrchestrator.hasNonMedia()) { - isUploadingFile = true - } else { - isUploadingImage = true - } + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -248,8 +246,7 @@ open class EditPostViewModel : ViewModel() { onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() } } @@ -261,8 +258,7 @@ open class EditPostViewModel : ViewModel() { multiOrchestrator = null urlPreview = null - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() wantsInvoice = false @@ -303,7 +299,7 @@ open class EditPostViewModel : ViewModel() { } } - fun canPost() = message.text.isNotBlank() && !isUploadingImage && !isUploadingFile && !wantsInvoice && multiOrchestrator == null + fun canPost() = message.text.isNotBlank() && !mediaUploadTracker.isUploading && !wantsInvoice && multiOrchestrator == null fun selectImage(uris: ImmutableList) { multiOrchestrator = MultiOrchestrator(uris) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/MediaUploadTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/MediaUploadTracker.kt new file mode 100644 index 000000000..6ff5e72ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/MediaUploadTracker.kt @@ -0,0 +1,47 @@ +/* + * 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.ui.actions.uploads + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class MediaUploadTracker { + var isUploadingImage by mutableStateOf(false) + private set + var isUploadingFile by mutableStateOf(false) + private set + + val isUploading: Boolean get() = isUploadingImage || isUploadingFile + + fun startUpload(hasNonMedia: Boolean) { + if (hasNonMedia) { + isUploadingFile = true + } else { + isUploadingImage = true + } + } + + fun finishUpload() { + isUploadingImage = false + isUploadingFile = false + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 91c5662e5..b8682a34d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -149,8 +150,9 @@ open class CommentPostViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) - var isUploadingFile by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -488,11 +490,7 @@ open class CommentPostViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - if (myMultiOrchestrator.hasNonMedia()) { - isUploadingFile = true - } else { - isUploadingImage = true - } + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -556,8 +554,7 @@ open class CommentPostViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() } } @@ -570,8 +567,7 @@ open class CommentPostViewModel : externalIdentity = null multiOrchestrator = null - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() notifying = null @@ -683,8 +679,7 @@ open class CommentPostViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - !isUploadingImage && - !isUploadingFile && + !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && multiOrchestrator == null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index db6f377f9..8ae6448bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -561,6 +561,7 @@ class ChatNewMessageViewModel : userSuggestionsMainMessage = null uploadsWaitingToBeSent = emptyList() + uploadState?.reset() iMetaAttachments.reset() @@ -692,8 +693,7 @@ class ChatNewMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - uploadState?.isUploadingImage != true && - uploadState?.isUploadingFile != true && + uploadState?.mediaUploadTracker?.isUploading != true && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index b6f8f3f91..33a9e456e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -43,11 +43,7 @@ class ChatFileUploader( onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return - if (orchestrator.hasNonMedia()) { - viewState.isUploadingFile = true - } else { - viewState.isUploadingImage = true - } + viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) val cipher = AESGCM() @@ -80,8 +76,7 @@ class ChatFileUploader( onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - viewState.isUploadingImage = false - viewState.isUploadingFile = false + viewState.mediaUploadTracker.finishUpload() } // ------ @@ -95,11 +90,7 @@ class ChatFileUploader( onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return - if (orchestrator.hasNonMedia()) { - viewState.isUploadingFile = true - } else { - viewState.isUploadingImage = true - } + viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) val results = orchestrator.upload( @@ -129,7 +120,6 @@ class ChatFileUploader( onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - viewState.isUploadingImage = false - viewState.isUploadingFile = false + viewState.mediaUploadTracker.finishUpload() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index cd69ddf43..b9eecab72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -338,11 +338,7 @@ open class ChannelNewMessageViewModel : val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch - if (myMultiOrchestrator.hasNonMedia()) { - uploadState.isUploadingFile = true - } else { - uploadState.isUploadingImage = true - } + uploadState.mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -383,8 +379,7 @@ open class ChannelNewMessageViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - uploadState.isUploadingImage = false - uploadState.isUploadingFile = false + uploadState.mediaUploadTracker.finishUpload() } } @@ -640,8 +635,7 @@ open class ChannelNewMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - uploadState?.isUploadingImage != true && - uploadState?.isUploadingFile != true && + uploadState?.mediaUploadTracker?.isUploading != true && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && uploadState?.multiOrchestrator == null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 384c8299e..32105a29c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import kotlinx.collections.immutable.ImmutableList @@ -36,8 +37,9 @@ import kotlinx.collections.immutable.ImmutableList class ChatFileUploadState( val defaultServer: ServerName, ) { - var isUploadingImage by mutableStateOf(false) - var isUploadingFile by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var selectedServer by mutableStateOf(defaultServer) var caption by mutableStateOf("") @@ -65,8 +67,7 @@ class ChatFileUploadState( fun reset() { multiOrchestrator = null - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() caption = "" selectedServer = defaultServer } @@ -75,7 +76,7 @@ class ChatFileUploadState( multiOrchestrator?.remove(selected) } - fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null + fun canPost(): Boolean = !mediaUploadTracker.isUploading && multiOrchestrator != null fun hasPickedMedia() = multiOrchestrator != null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index b2014fc66..c3d359d08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -133,8 +134,9 @@ open class NewProductViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) - var isUploadingFile by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -400,11 +402,7 @@ open class NewProductViewModel : val myAccount = account ?: return@launch val myMultiOrchestrator = multiOrchestrator ?: return@launch - if (myMultiOrchestrator.hasNonMedia()) { - isUploadingFile = true - } else { - isUploadingImage = true - } + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -449,8 +447,7 @@ open class NewProductViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() } } @@ -460,8 +457,7 @@ open class NewProductViewModel : message = TextFieldValue("") multiOrchestrator = null - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() wantsInvoice = false wantsZapraiser = false @@ -582,8 +578,7 @@ open class NewProductViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - !isUploadingImage && - !isUploadingFile && + !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && title.text.isNotBlank() && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index d9018502f..4b63c9df4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing @@ -178,8 +179,9 @@ open class ShortNotePostViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) - var isUploadingFile by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -830,11 +832,7 @@ open class ShortNotePostViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - if (myMultiOrchestrator.hasNonMedia()) { - isUploadingFile = true - } else { - isUploadingImage = true - } + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -890,8 +888,7 @@ open class ShortNotePostViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() } } @@ -903,8 +900,7 @@ open class ShortNotePostViewModel : forkedFromNote = null multiOrchestrator = null - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() voiceAnonymization.clear() deleteVoiceLocalFile() voiceRecording = null @@ -1039,13 +1035,12 @@ open class ShortNotePostViewModel : fun canPost(): Boolean { // Voice messages can be posted without text (with either uploaded or pending recording) if (voiceMetadata != null || voiceRecording != null) { - return !isUploadingVoice && !isUploadingImage && !isUploadingFile && processingPreset == null + return !isUploadingVoice && !mediaUploadTracker.isUploading && processingPreset == null } // Regular text/media posts require text return message.text.isNotBlank() && - !isUploadingImage && - !isUploadingFile && + !mediaUploadTracker.isUploading && !isUploadingVoice && !wantsInvoice && (!wantsZapRaiser || zapRaiserAmount.value != null) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 14495a37a..fbda70c24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState @@ -149,8 +150,9 @@ class NewPublicMessageViewModel : val urlPreviews = PreviewState() - var isUploadingImage by mutableStateOf(false) - var isUploadingFile by mutableStateOf(false) + val mediaUploadTracker = MediaUploadTracker() + val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage + val isUploadingFile: Boolean get() = mediaUploadTracker.isUploadingFile var userSuggestions: UserSuggestionState? = null var userSuggestionsMainMessage: UserSuggestionAnchor? = null @@ -440,11 +442,7 @@ class NewPublicMessageViewModel : viewModelScope.launch(Dispatchers.IO) { val myMultiOrchestrator = multiOrchestrator ?: return@launch - if (myMultiOrchestrator.hasNonMedia()) { - isUploadingFile = true - } else { - isUploadingImage = true - } + mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) val results = myMultiOrchestrator.upload( @@ -499,8 +497,7 @@ class NewPublicMessageViewModel : onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) } - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() } } @@ -529,8 +526,7 @@ class NewPublicMessageViewModel : userSuggestions?.reset() userSuggestionsMainMessage = null - isUploadingImage = false - isUploadingFile = false + mediaUploadTracker.finishUpload() iMetaAttachments.reset() @@ -637,8 +633,7 @@ class NewPublicMessageViewModel : fun canPost(): Boolean = message.text.isNotBlank() && - !isUploadingImage && - !isUploadingFile && + !mediaUploadTracker.isUploading && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount.value != null) && (toUsers.text.isNotBlank()) && From 795da8cf1b4e32b5d68fdecc8d00063f41d30aec Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 14 Mar 2026 17:54:51 +0100 Subject: [PATCH 039/130] Block Add to message button while uploading --- .../java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt | 1 + .../amethyst/ui/note/creators/uploads/ImageVideoDescription.kt | 2 ++ .../amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt | 1 + .../ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt | 1 + .../loggedIn/discover/nip99Classifieds/NewProductScreen.kt | 1 + .../amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt | 1 + .../notifications/publicMessages/NewPublicMessageScreen.kt | 1 + 7 files changed, 8 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 14b9bb5cc..f4978575b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -264,6 +264,7 @@ fun EditPostView( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context) accountViewModel.account.settings.changeDefaultFileServer(server) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index 9e40f6e1d..09f9339dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -78,6 +78,7 @@ import kotlinx.collections.immutable.toImmutableList fun ImageVideoDescription( uris: MultiOrchestrator, defaultServer: ServerName, + isUploading: Boolean, onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit, onDelete: (SelectedMediaProcessing) -> Unit, onCancel: () -> Unit, @@ -319,6 +320,7 @@ fun ImageVideoDescription( Modifier .fillMaxWidth() .padding(vertical = 10.dp), + enabled = !isUploading, onClick = { onAdd(message, selectedServer, sensitiveContent, mediaQualitySlider, useH265Codec) }, shape = QuoteBorder, colors = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 5e8c197c7..c4f826825 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -300,6 +300,7 @@ private fun GenericCommentPostBody( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) accountViewModel.account.settings.changeDefaultFileServer(server) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index ef5f32b11..c8f1bf6c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -287,6 +287,7 @@ fun GroupDMScreenContent( ImageVideoDescription( selectedFiles, accountViewModel.account.settings.defaultFileServer, + isUploading = uploading.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.uploadAndHold( accountViewModel.toastManager::toast, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index d4c3e1853..0400191e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -269,6 +269,7 @@ private fun NewProductBody( ImageVideoDescription( uris = it, defaultServer = accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) accountViewModel.account.settings.changeDefaultFileServer(server) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index d6d473a9d..7239f8e82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -356,6 +356,7 @@ private fun NewPostScreenBody( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265) accountViewModel.account.settings.changeDefaultFileServer(server) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 0c2ff9fc1..d236275d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -246,6 +246,7 @@ fun PublicMessageScreenContent( ImageVideoDescription( it, accountViewModel.account.settings.defaultFileServer, + isUploading = postViewModel.mediaUploadTracker.isUploading, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) accountViewModel.account.settings.changeDefaultFileServer(server) From 64bf1d5090331b9ba0ebb9967f158ad34bcb24f8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 14 Mar 2026 21:00:31 +0100 Subject: [PATCH 040/130] update cz, pt, de, sv --- .../src/main/res/values-cs-rCZ/strings.xml | 24 +++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 26 ++++++++++++++++++- .../src/main/res/values-pt-rBR/strings.xml | 26 ++++++++++++++++++- .../src/main/res/values-sv-rSE/strings.xml | 24 +++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index e12ec2833..3cbc5e20d 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1529,4 +1529,28 @@ %1$d%% dostupnost Nastavení Namecoin Průzkumník Bitcoin (OTS) + Peněženka + Zůstatek + Odeslat + Přijmout + Transakce + Žádná peněženka není připojena + Nastavte připojení Nostr Wallet Connect (NWC) v nastavení zapů, abyste mohli peněženku používat. + Nastavit peněženku + sats + Vložte fakturu BOLT-11 + Zaplatit + Platba úspěšná + Odesílání platby… + Částka (sats) + Popis (volitelné) + Vytvořit fakturu + Vytváření faktury… + Kopírovat fakturu + Zatím žádné transakce + Načítání… + Přijato + Odesláno + Obnovit + %1$s → %2$s diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index a1df34e69..477dd288c 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1510,7 +1510,7 @@ anz der Bedingungen ist erforderlich Folgeliste importieren Benutzer zum Folgen auswählen Profil, aus dem importiert werden soll - Namenssuche, npub1…, alice@example.com + Namenssuche, npub1…, alice@example.com Unterstützt npub, NIP-05, Hex und Namecoin (.bit / d/ / id/) Folgeliste nachschlagen Tipp @@ -1533,4 +1533,28 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen + Wallet + Guthaben + Senden + Empfangen + Transaktionen + Keine Wallet verbunden + Richte eine Nostr Wallet Connect (NWC)-Verbindung in deinen Zap-Einstellungen ein, um die Wallet zu nutzen. + Wallet einrichten + Sats + BOLT-11-Rechnung einfügen + Bezahlen + Zahlung erfolgreich + Zahlung wird gesendet… + Betrag (Sats) + Beschreibung (optional) + Rechnung erstellen + Rechnung wird erstellt… + Rechnung kopieren + Noch keine Transaktionen + Wird geladen… + Empfangen + Gesendet + Aktualisieren + %1$s → %2$s diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 4ab3d5dec..ef5a066e7 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1505,7 +1505,7 @@ Importar lista de seguidos Selecionar usuários para seguir Perfil para importar - busca por nome, npub1…, alice@example.com + busca por nome, npub1…, alice@example.com Suporta npub, NIP-05, hex e Namecoin (.bit / d/ / id/) Buscar lista de seguidos Dica @@ -1529,4 +1529,28 @@ %1$d%% de disponibilidade Configurações do Namecoin Explorador Bitcoin (OTS) + Carteira + Saldo + Enviar + Receber + Transações + Nenhuma carteira conectada + Configure uma conexão Nostr Wallet Connect (NWC) nas configurações de zap para usar a carteira. + Configurar Carteira + sats + Cole uma fatura BOLT-11 + Pagar + Pagamento bem-sucedido + Enviando pagamento… + Valor (sats) + Descrição (opcional) + Criar Fatura + Criando fatura… + Copiar Fatura + Nenhuma transação ainda + Carregando… + Recebido + Enviado + Atualizar + %1$s → %2$s diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 60a129aa5..4040381c6 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1527,4 +1527,28 @@ Välj alla %1$d%% drifttid Namecoin-inställningar + Plånbok + Saldo + Skicka + Ta emot + Transaktioner + Ingen plånbok ansluten + Konfigurera en Nostr Wallet Connect (NWC)-anslutning i dina zap-inställningar för att använda plånboken. + Konfigurera plånbok + sats + Klistra in en BOLT-11-faktura + Betala + Betalning lyckades + Skickar betalning… + Belopp (sats) + Beskrivning (valfritt) + Skapa faktura + Skapar faktura… + Kopiera faktura + Inga transaktioner ännu + Laddar… + Mottagen + Skickad + Uppdatera + %1$s → %2$s From caee58da55e59aec9a6d3c06e787c7fba3803bc3 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Sat, 14 Mar 2026 20:02:48 +0000 Subject: [PATCH 041/130] Add migrations.xml to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d87f7f146..945f2e02a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ /.idea/AndroidProjectSystem.xml /.idea/deviceManager.xml /.idea/inspectionProfiles/ +/.idea/migrations.xml /commons/.idea/gradle.xml /commons/.idea/misc.xml /commons/.idea/workspace.xml From 2df98c456adec2109618afe003c0f358d38f9590 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 14 Mar 2026 20:04:41 +0000 Subject: [PATCH 042/130] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 46 +++++++++---------- .../src/main/res/values-de-rDE/strings.xml | 26 +---------- .../src/main/res/values-pt-rBR/strings.xml | 26 +---------- .../src/main/res/values-sv-rSE/strings.xml | 46 +++++++++---------- 4 files changed, 46 insertions(+), 98 deletions(-) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 3cbc5e20d..54e559e8c 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1047,6 +1047,28 @@ Globální Krátké Šachy + Peněženka + Zůstatek + Odeslat + Přijmout + Transakce + Žádná peněženka není připojena + Nastavte připojení Nostr Wallet Connect (NWC) v nastavení zapů, abyste mohli peněženku používat. + Nastavit peněženku + Vložte fakturu BOLT-11 + Zaplatit + Platba úspěšná + Odesílání platby… + Částka (sats) + Popis (volitelné) + Vytvořit fakturu + Vytváření faktury… + Kopírovat fakturu + Zatím žádné transakce + Načítání… + Přijato + Odesláno + Obnovit Bezpečnostní filtry Importovat sledované Nový příspěvek @@ -1529,28 +1551,4 @@ %1$d%% dostupnost Nastavení Namecoin Průzkumník Bitcoin (OTS) - Peněženka - Zůstatek - Odeslat - Přijmout - Transakce - Žádná peněženka není připojena - Nastavte připojení Nostr Wallet Connect (NWC) v nastavení zapů, abyste mohli peněženku používat. - Nastavit peněženku - sats - Vložte fakturu BOLT-11 - Zaplatit - Platba úspěšná - Odesílání platby… - Částka (sats) - Popis (volitelné) - Vytvořit fakturu - Vytváření faktury… - Kopírovat fakturu - Zatím žádné transakce - Načítání… - Přijato - Odesláno - Obnovit - %1$s → %2$s diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 477dd288c..a1df34e69 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1510,7 +1510,7 @@ anz der Bedingungen ist erforderlich Folgeliste importieren Benutzer zum Folgen auswählen Profil, aus dem importiert werden soll - Namenssuche, npub1…, alice@example.com + Namenssuche, npub1…, alice@example.com Unterstützt npub, NIP-05, Hex und Namecoin (.bit / d/ / id/) Folgeliste nachschlagen Tipp @@ -1533,28 +1533,4 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen - Wallet - Guthaben - Senden - Empfangen - Transaktionen - Keine Wallet verbunden - Richte eine Nostr Wallet Connect (NWC)-Verbindung in deinen Zap-Einstellungen ein, um die Wallet zu nutzen. - Wallet einrichten - Sats - BOLT-11-Rechnung einfügen - Bezahlen - Zahlung erfolgreich - Zahlung wird gesendet… - Betrag (Sats) - Beschreibung (optional) - Rechnung erstellen - Rechnung wird erstellt… - Rechnung kopieren - Noch keine Transaktionen - Wird geladen… - Empfangen - Gesendet - Aktualisieren - %1$s → %2$s diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index ef5a066e7..4ab3d5dec 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1505,7 +1505,7 @@ Importar lista de seguidos Selecionar usuários para seguir Perfil para importar - busca por nome, npub1…, alice@example.com + busca por nome, npub1…, alice@example.com Suporta npub, NIP-05, hex e Namecoin (.bit / d/ / id/) Buscar lista de seguidos Dica @@ -1529,28 +1529,4 @@ %1$d%% de disponibilidade Configurações do Namecoin Explorador Bitcoin (OTS) - Carteira - Saldo - Enviar - Receber - Transações - Nenhuma carteira conectada - Configure uma conexão Nostr Wallet Connect (NWC) nas configurações de zap para usar a carteira. - Configurar Carteira - sats - Cole uma fatura BOLT-11 - Pagar - Pagamento bem-sucedido - Enviando pagamento… - Valor (sats) - Descrição (opcional) - Criar Fatura - Criando fatura… - Copiar Fatura - Nenhuma transação ainda - Carregando… - Recebido - Enviado - Atualizar - %1$s → %2$s diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 4040381c6..775a0a0f5 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1046,6 +1046,28 @@ Globalt Kortfilmer Schack + Plånbok + Saldo + Skicka + Ta emot + Transaktioner + Ingen plånbok ansluten + Konfigurera en Nostr Wallet Connect (NWC)-anslutning i dina zap-inställningar för att använda plånboken. + Konfigurera plånbok + Klistra in en BOLT-11-faktura + Betala + Betalning lyckades + Skickar betalning… + Belopp (sats) + Beskrivning (valfritt) + Skapa faktura + Skapar faktura… + Kopiera faktura + Inga transaktioner ännu + Laddar… + Mottagen + Skickad + Uppdatera Säkerhetsfilter Importera följare Nytt inlägg @@ -1527,28 +1549,4 @@ Välj alla %1$d%% drifttid Namecoin-inställningar - Plånbok - Saldo - Skicka - Ta emot - Transaktioner - Ingen plånbok ansluten - Konfigurera en Nostr Wallet Connect (NWC)-anslutning i dina zap-inställningar för att använda plånboken. - Konfigurera plånbok - sats - Klistra in en BOLT-11-faktura - Betala - Betalning lyckades - Skickar betalning… - Belopp (sats) - Beskrivning (valfritt) - Skapa faktura - Skapar faktura… - Kopiera faktura - Inga transaktioner ännu - Laddar… - Mottagen - Skickad - Uppdatera - %1$s → %2$s From 00fb924dbb6a344a0c3f5814cb55b4650a8a3571 Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Sun, 15 Mar 2026 11:50:24 +0000 Subject: [PATCH 043/130] Update README with build instructions and requirements Added instructions for full build and requirements. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index d1d9dbe7b..a19f16550 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,13 @@ Build and run the Desktop app (requires Java 21+): ```bash ./gradlew :desktopApp:run ``` +Full build (including tests) +```bash +./gradlew build +``` +Requirements: +- Xcode and iOS simulator +- libsodium installed (e.g. via brew: `brew install libsodium` ## Testing ```bash From 79990103908cf73538f9712256a87f9706dc3f9c Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 15 Mar 2026 11:51:55 +0000 Subject: [PATCH 044/130] New Crowdin translations by GitHub Action --- .../src/main/res/values-cs-rCZ/strings.xml | 2 ++ .../src/main/res/values-de-rDE/strings.xml | 25 +++++++++++++++++++ .../src/main/res/values-hi-rIN/strings.xml | 16 ++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 24 ++++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 3 +++ 5 files changed, 70 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 54e559e8c..fa6683bf4 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1055,6 +1055,7 @@ Žádná peněženka není připojena Nastavte připojení Nostr Wallet Connect (NWC) v nastavení zapů, abyste mohli peněženku používat. Nastavit peněženku + sats Vložte fakturu BOLT-11 Zaplatit Platba úspěšná @@ -1304,6 +1305,7 @@ Vyberte jazyk, do kterého chcete obsah přeložit. Předvolby zobrazení jazyka Pro každý přeložený jazykový pár zvolte, který jazyk zobrazit jako první. + %1$s → %2$s Hledat jazyky Přidat jazyk Přidat jazykový pár diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index a1df34e69..cfe786197 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1052,6 +1052,29 @@ anz der Bedingungen ist erforderlich Global Kurzfilme Schach + Wallet + Guthaben + Senden + Empfangen + Transaktionen + Keine Wallet verbunden + Richte eine Nostr Wallet Connect (NWC)-Verbindung in deinen Zap-Einstellungen ein, um die Wallet zu nutzen. + Wallet einrichten + Sats + BOLT-11-Rechnung einfügen + Bezahlen + Zahlung erfolgreich + Zahlung wird gesendet… + Betrag (Sats) + Beschreibung (optional) + Rechnung erstellen + Rechnung wird erstellt… + Rechnung kopieren + Noch keine Transaktionen + Wird geladen… + Empfangen + Gesendet + Aktualisieren Sicherheitsfilter Folgeliste importieren Neuer Beitrag @@ -1287,6 +1310,7 @@ anz der Bedingungen ist erforderlich Wähle die Sprache, in die der Inhalt übersetzt werden soll. Sprachanzeigeeinstellungen Wähle für jedes übersetzte Sprachpaar, welche Sprache zuerst angezeigt werden soll. + %1$s → %2$s Sprachen suchen Sprache hinzufügen Sprachpaar hinzufügen @@ -1533,4 +1557,5 @@ anz der Bedingungen ist erforderlich Alle auswählen %1$d%% Verfügbarkeit Namecoin-Einstellungen + Bitcoin Explorer (OTS) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index be1bd8761..6332634f0 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1052,6 +1052,22 @@ वैश्विक छोटे चतुरंग + धनकोष + शेष + भेजें + प्राप्त करें + लेनदेन + कोई धनकोष संयोजित नहीं + एक नोस्टर धनकोष संयोजन (एनडबल्यूसी॰) स्थापित करें आपके ज्साप स्थापना विकल्पों में धनकोष का उपयोग करने के लिए। + धनकोष की स्थापना करें + साट्स + बोल्ट॰-११ चालान चिपकाएँ + भुगतान करें + भुगतान सफल + भुगतान भेजा जा रहा है… + संख्या (साट्स) + विवरण (विकल्पात्मक) + चालान बनाएँ सुरक्षार्थ छलनियाँ अनुचरित आयात करें नया पत्र प्रकाशन diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 4ab3d5dec..3816f9dfb 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1047,6 +1047,29 @@ Global Vídeos Curtos Xadrez + Carteira + Saldo + Enviar + Receber + Transações + Nenhuma carteira conectada + Configure uma conexão Nostr Wallet Connect (NWC) nas configurações de zap para usar a carteira. + Configurar Carteira + sats + Cole uma fatura BOLT-11 + Pagar + Pagamento bem-sucedido + Enviando pagamento… + Valor (sats) + Descrição (opcional) + Criar Fatura + Criando fatura… + Copiar Fatura + Nenhuma transação ainda + Carregando… + Recebido + Enviado + Atualizar Filtros de Segurança Importar Seguidos Novo Post @@ -1282,6 +1305,7 @@ Escolha o idioma para o qual o conteúdo será traduzido. Preferências de exibição de idioma Para cada par de idiomas traduzido, escolha qual idioma exibir primeiro. + %1$s → %2$s Buscar idiomas Adicionar idioma Adicionar par de idiomas diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 775a0a0f5..d48eddc07 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1054,6 +1054,7 @@ Ingen plånbok ansluten Konfigurera en Nostr Wallet Connect (NWC)-anslutning i dina zap-inställningar för att använda plånboken. Konfigurera plånbok + sats Klistra in en BOLT-11-faktura Betala Betalning lyckades @@ -1303,6 +1304,7 @@ Välj det språk du vill översätta innehållet till. Språkvisningsinställningar Välj för varje översatt språkpar vilket språk som ska visas först. + %1$s → %2$s Sök språk Lägg till språk Lägg till språkpar @@ -1549,4 +1551,5 @@ Välj alla %1$d%% drifttid Namecoin-inställningar + Bitcoin Explorer (OTS) From 997a59dc23fdf09360eb7a679d812e1dfd6e251a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 14:30:26 +0000 Subject: [PATCH 045/130] feat: add file encryption toggle for encrypted DMs with retry on failure Add an "Encrypt files" toggle (default: on) to the file upload dialog in NIP-17 encrypted DMs. When encrypted upload fails, show an informative dialog explaining that many servers don't accept encrypted files on free accounts, with an option to retry without encryption. The retry dialog warns that without encryption anyone with the link can see the content. https://claude.ai/code/session_012xvKzHrZPq3ZTAzBN2LvrR --- .../privateDM/send/ChatNewMessageViewModel.kt | 89 ++++++++++++++++++- .../chats/privateDM/send/NewGroupDMScreen.kt | 9 ++ .../send/PrivateMessageEditFieldRow.kt | 44 +++++++++ .../send/upload/ChatFileUploadDialog.kt | 1 + .../privateDM/send/upload/ChatFileUploader.kt | 56 +++++++++++- .../chats/utils/ChatFileUploadDialog.kt | 14 ++- .../chats/utils/ChatFileUploadState.kt | 3 + amethyst/src/main/res/values/strings.xml | 7 ++ 8 files changed, 216 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 8ae6448bc..26c3d530c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -403,7 +403,16 @@ class ChatNewMessageViewModel : accountViewModel.launchSigner { if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.HOLD + }, + context, + ) { uploadsWaitingToBeSent += it draftTag.newVersion() onceUploaded() @@ -428,7 +437,19 @@ class ChatNewMessageViewModel : accountViewModel.launchSigner { if (nip17) { - ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { + ChatFileUploader(account).justUploadNIP17( + uploadState, + onError, + onEncryptedUploadError = { title, message -> + encryptedUploadErrorTitle = title + encryptedUploadErrorMessage = message + pendingRetryMode = RetryMode.SEND + pendingRetryOnError = onError + pendingRetryContext = context + pendingRetryOnceUploaded = onceUploaded + }, + context, + ) { ChatFileSender(room, account).sendNIP17(it) draftTag.newVersion() onceUploaded() @@ -443,6 +464,68 @@ class ChatNewMessageViewModel : } } + // Encrypted upload error state for retry dialog + var encryptedUploadErrorTitle by mutableStateOf(null) + var encryptedUploadErrorMessage by mutableStateOf(null) + var pendingRetryMode by mutableStateOf(null) + var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null) + var pendingRetryContext by mutableStateOf(null) + var pendingRetryOnceUploaded by mutableStateOf<(() -> Unit)?>(null) + + enum class RetryMode { HOLD, SEND } + + fun dismissEncryptedUploadError() { + encryptedUploadErrorTitle = null + encryptedUploadErrorMessage = null + pendingRetryMode = null + pendingRetryOnError = null + pendingRetryContext = null + pendingRetryOnceUploaded = null + } + + fun retryWithoutEncryption() { + val mode = pendingRetryMode ?: return + val onError = pendingRetryOnError + val context = pendingRetryContext + val onceUploaded = pendingRetryOnceUploaded + val room = room + val uploadState = uploadState + + dismissEncryptedUploadError() + + if (uploadState == null || context == null) return + + uploadState.encryptFiles = false + + accountViewModel.launchSigner { + when (mode) { + RetryMode.HOLD -> { + ChatFileUploader(account).justUploadNIP17Unencrypted( + uploadState, + onError ?: accountViewModel.toastManager::toast, + context, + ) { + uploadsWaitingToBeSent += it + draftTag.newVersion() + onceUploaded?.invoke() + } + } + RetryMode.SEND -> { + if (room == null) return@launchSigner + ChatFileUploader(account).justUploadNIP17Unencrypted( + uploadState, + onError ?: accountViewModel.toastManager::toast, + context, + ) { + ChatFileSender(room, account).sendNIP17(it) + draftTag.newVersion() + onceUploaded?.invoke() + } + } + } + } + } + private suspend fun innerSendPost(draftTag: String?) { val room = room ?: return @@ -563,6 +646,8 @@ class ChatNewMessageViewModel : uploadsWaitingToBeSent = emptyList() uploadState?.reset() + dismissEncryptedUploadError() + iMetaAttachments.reset() emojiSuggestions?.reset() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index c8f1bf6c9..117f96d36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -302,6 +302,15 @@ fun GroupDMScreenContent( ) } } + + postViewModel.encryptedUploadErrorTitle?.let { title -> + EncryptedUploadErrorDialog( + title = title, + message = postViewModel.encryptedUploadErrorMessage ?: "", + onDismiss = postViewModel::dismissEncryptedUploadError, + onRetryWithoutEncryption = postViewModel::retryWithoutEncryption, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 2615ac2ec..a0b45b9d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -27,8 +27,10 @@ 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.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -114,6 +116,15 @@ fun PrivateMessageEditFieldRow( } } + channelScreenModel.encryptedUploadErrorTitle?.let { title -> + EncryptedUploadErrorDialog( + title = title, + message = channelScreenModel.encryptedUploadErrorMessage ?: "", + onDismiss = channelScreenModel::dismissEncryptedUploadError, + onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption, + ) + } + Column( modifier = EditFieldModifier, ) { @@ -215,3 +226,36 @@ fun KeyboardLeadingIcon( ToggleNip17Button(channelScreenModel, accountViewModel) } } + +@Composable +fun EncryptedUploadErrorDialog( + title: String, + message: String, + onDismiss: () -> Unit, + onRetryWithoutEncryption: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(message) + Text( + stringRes(R.string.upload_without_encryption_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + }, + confirmButton = { + TextButton(onClick = onRetryWithoutEncryption) { + Text(stringRes(R.string.retry_without_encryption)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index 1dca30b9f..d5c06cfcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog( onCancel, accountViewModel, nav, + isNip17 = channelScreenModel.nip17, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index 33a9e456e..2770d85f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -39,20 +39,68 @@ class ChatFileUploader( suspend fun justUploadNIP17( viewState: ChatFileUploadState, onError: (title: String, message: String) -> Unit, + onEncryptedUploadError: (title: String, message: String) -> Unit, context: Context, onceUploaded: suspend (List) -> Unit, ) { val orchestrator = viewState.multiOrchestrator ?: return viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) - val cipher = AESGCM() + if (viewState.encryptFiles) { + val cipher = AESGCM() + + val results = + orchestrator.uploadEncrypted( + viewState.caption, + viewState.contentWarningReason, + MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), + cipher, + viewState.selectedServer, + account, + context, + ) + + if (results.allGood) { + val list = + results.successful.mapNotNull { state -> + if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) + } else { + null + } + } + + onceUploaded(list) + viewState.reset() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onEncryptedUploadError( + stringRes(context, R.string.failed_to_upload_encrypted_media_title), + stringRes(context, R.string.failed_to_upload_encrypted_media_message) + "\n\n" + errorMessages.joinToString(".\n"), + ) + } + } else { + justUploadNIP17Unencrypted(viewState, onError, context, onceUploaded) + } + + viewState.mediaUploadTracker.finishUpload() + } + + suspend fun justUploadNIP17Unencrypted( + viewState: ChatFileUploadState, + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: suspend (List) -> Unit, + ) { + val orchestrator = viewState.multiOrchestrator ?: return + viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) val results = - orchestrator.uploadEncrypted( + orchestrator.upload( viewState.caption, viewState.contentWarningReason, MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), - cipher, viewState.selectedServer, account, context, @@ -62,7 +110,7 @@ class ChatFileUploader( val list = results.successful.mapNotNull { state -> if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { - SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) + SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null) } else { null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index f1ce97525..c05fa0c4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -81,6 +81,7 @@ fun ChatFileUploadDialog( onCancel: () -> Unit, accountViewModel: AccountViewModel, nav: INav, + isNip17: Boolean = false, ) { val scrollState = rememberScrollState() @@ -132,7 +133,7 @@ fun ChatFileUploadDialog( ) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { - ImageVideoPostChat(state, accountViewModel) + ImageVideoPostChat(state, accountViewModel, isNip17) } } } @@ -144,6 +145,7 @@ fun ChatFileUploadDialog( private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, + isNip17: Boolean = false, ) { val fileServers by accountViewModel.account.blossomServers.hostNameFlow .collectAsState() @@ -190,6 +192,16 @@ private fun ImageVideoPostChat( onCheckedChange = fileUploadState::updateContentWarning, ) + if (isNip17) { + SettingSwitchItem( + title = R.string.encrypt_files_label, + description = R.string.encrypt_files_description, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + checked = fileUploadState.encryptFiles, + onCheckedChange = { fileUploadState.encryptFiles = it }, + ) + } + SettingsRow(R.string.file_server, R.string.file_server_description) { TextSpinner( label = "", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt index 32105a29c..1e6717ae0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadState.kt @@ -55,6 +55,8 @@ class ChatFileUploadState( // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED var mediaQualitySlider by mutableIntStateOf(1) + var encryptFiles by mutableStateOf(true) + fun load(uris: ImmutableList) { reset() this.multiOrchestrator = MultiOrchestrator(uris) @@ -70,6 +72,7 @@ class ChatFileUploadState( mediaUploadTracker.finishUpload() caption = "" selectedServer = defaultServer + encryptFiles = true } fun deleteMediaToUpload(selected: SelectedMediaProcessing) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 36ce30142..c649661e2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1215,6 +1215,13 @@ Compression Cancelled Compression failed to return a file + Encrypt files + Encrypt files before uploading for privacy. Some servers may not accept encrypted files on free accounts. + Encrypted upload failed + Many servers do not accept encrypted files on free accounts. You can retry without encryption. + Retry without encryption + Warning: Without encryption, anyone with the file link can see the content. + Media Quality Select Low quality to compress your media to a smaller file with less quality, High quality to compress to a larger file with higher quality or Uncompressed to upload the media without compression. Low From b1723a5dfb68c6994dc7105f27e9192ba87deebf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 14:41:19 +0000 Subject: [PATCH 046/130] feat: add relay event count stats to AllRelay settings screen Use NostrClient's NIP-45 COUNT queries to show how many events each relay stores, with filters specific to each relay role: - Outbox: events authored by the user - Inbox: events where the user is p-tagged - DM Inbox: DM events (kind 4, 1059) tagging the user - Private Home: events authored by the user - Proxy: total event count - Search: total event count - Indexer: kind 0 and kind 10002 counts separately - Broadcast: no count (as specified) https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA --- .../loggedIn/relays/AllRelayListScreen.kt | 102 ++++++++- .../common/BasicRelaySetupInfoClickableRow.kt | 6 + .../common/BasicRelaySetupInfoDialog.kt | 2 + .../relays/common/RelayEventCountRow.kt | 85 +++++++ .../relays/common/RelayEventCountViewModel.kt | 214 ++++++++++++++++++ .../loggedIn/relays/dm/DMRelayListView.kt | 4 + .../relays/indexer/IndexerRelayListView.kt | 4 + .../nip37/PrivateOutboxRelayListView.kt | 4 + .../relays/nip65/Nip65RelayListView.kt | 6 + .../relays/proxy/ProxyRelayListView.kt | 4 + .../relays/search/SearchRelayListView.kt | 4 + amethyst/src/main/res/values/strings.xml | 1 + 12 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index e2b79d62c..a9b79bb71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelay import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.renderBlockedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.BroadcastRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.renderBroadcastItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayEventCountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayExporter import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayListCollection import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayZipExporter @@ -109,6 +110,13 @@ fun AllRelayListScreen( val indexerViewModel: IndexerRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel() val relayFeedsViewModel: RelayFeedsListViewModel = viewModel() + val outboxCountViewModel: RelayEventCountViewModel = viewModel(key = "outboxCount") + val inboxCountViewModel: RelayEventCountViewModel = viewModel(key = "inboxCount") + val dmCountViewModel: RelayEventCountViewModel = viewModel(key = "dmCount") + val privateHomeCountViewModel: RelayEventCountViewModel = viewModel(key = "privateHomeCount") + val proxyCountViewModel: RelayEventCountViewModel = viewModel(key = "proxyCount") + val indexerCountViewModel: RelayEventCountViewModel = viewModel(key = "indexerCount") + val searchCountViewModel: RelayEventCountViewModel = viewModel(key = "searchCount") dmViewModel.init(accountViewModel) nip65ViewModel.init(accountViewModel) @@ -151,6 +159,13 @@ fun AllRelayListScreen( indexerViewModel, proxyViewModel, relayFeedsViewModel, + outboxCountViewModel, + inboxCountViewModel, + dmCountViewModel, + privateHomeCountViewModel, + proxyCountViewModel, + indexerCountViewModel, + searchCountViewModel, accountViewModel, nav, ) @@ -171,6 +186,13 @@ fun MappedAllRelayListView( indexerViewModel: IndexerRelayListViewModel, proxyViewModel: ProxyRelayListViewModel, relayFeedsViewModel: RelayFeedsListViewModel, + outboxCountViewModel: RelayEventCountViewModel, + inboxCountViewModel: RelayEventCountViewModel, + dmCountViewModel: RelayEventCountViewModel, + privateHomeCountViewModel: RelayEventCountViewModel, + proxyCountViewModel: RelayEventCountViewModel, + indexerCountViewModel: RelayEventCountViewModel, + searchCountViewModel: RelayEventCountViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -188,6 +210,72 @@ fun MappedAllRelayListView( val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle() + val outboxCounts by outboxCountViewModel.counts.collectAsStateWithLifecycle() + val inboxCounts by inboxCountViewModel.counts.collectAsStateWithLifecycle() + val dmCounts by dmCountViewModel.counts.collectAsStateWithLifecycle() + val privateHomeCounts by privateHomeCountViewModel.counts.collectAsStateWithLifecycle() + val proxyCounts by proxyCountViewModel.counts.collectAsStateWithLifecycle() + val indexerCounts by indexerCountViewModel.counts.collectAsStateWithLifecycle() + val searchCounts by searchCountViewModel.counts.collectAsStateWithLifecycle() + + val userPubKey = accountViewModel.account.pubKey + + LaunchedEffect(homeFeedState) { + if (homeFeedState.isNotEmpty()) { + outboxCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.authorCountFilters(userPubKey, homeFeedState.map { it.relay }), + ) + } + } + + LaunchedEffect(notifFeedState) { + if (notifFeedState.isNotEmpty()) { + inboxCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.pTagCountFilters(userPubKey, notifFeedState.map { it.relay }), + ) + } + } + + LaunchedEffect(dmFeedState) { + if (dmFeedState.isNotEmpty()) { + dmCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.dmCountFilters(userPubKey, dmFeedState.map { it.relay }), + ) + } + } + + LaunchedEffect(privateOutboxFeedState) { + if (privateOutboxFeedState.isNotEmpty()) { + privateHomeCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.authorCountFilters(userPubKey, privateOutboxFeedState.map { it.relay }), + ) + } + } + + LaunchedEffect(proxyRelays) { + if (proxyRelays.isNotEmpty()) { + proxyCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.totalCountFilters(proxyRelays.map { it.relay }), + ) + } + } + + LaunchedEffect(indexerRelays) { + if (indexerRelays.isNotEmpty()) { + indexerCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.indexerCountFilters(indexerRelays.map { it.relay }), + ) + } + } + + LaunchedEffect(searchFeedState) { + if (searchFeedState.isNotEmpty()) { + searchCountViewModel.queryCountsForRelays( + RelayEventCountViewModel.totalCountFilters(searchFeedState.map { it.relay }), + ) + } + } + Scaffold( topBar = { SavingTopBar( @@ -261,7 +349,7 @@ fun MappedAllRelayListView( SettingsCategoryFirstModifier, ) } - renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav) + renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, nav, outboxCounts) item { SettingsCategory( @@ -270,7 +358,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav) + renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, nav, inboxCounts) item { SettingsCategoryWithButton( @@ -282,7 +370,7 @@ fun MappedAllRelayListView( }, ) } - renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav) + renderDMItems(dmFeedState, dmViewModel, accountViewModel, nav, dmCounts) item { SettingsCategory( @@ -291,7 +379,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav) + renderPrivateOutboxItems(privateOutboxFeedState, privateOutboxViewModel, accountViewModel, nav, privateHomeCounts) item { SettingsCategory( @@ -300,7 +388,7 @@ fun MappedAllRelayListView( SettingsCategorySpacingModifier, ) } - renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav) + renderProxyItems(proxyRelays, proxyViewModel, accountViewModel, nav, proxyCounts) item { SettingsCategory( @@ -320,7 +408,7 @@ fun MappedAllRelayListView( ResetIndexerRelays(indexerViewModel) } } - renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav) + renderIndexerItems(indexerRelays, indexerViewModel, accountViewModel, nav, indexerCounts) item { SettingsCategoryWithButton( @@ -331,7 +419,7 @@ fun MappedAllRelayListView( ResetSearchRelays(searchViewModel) } } - renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav) + renderSearchItems(searchFeedState, searchViewModel, accountViewModel, nav, searchCounts) item { SettingsCategory( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index 73fad951f..625fde7c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -63,6 +63,7 @@ fun BasicRelaySetupInfoClickableRow( onClick: () -> Unit, nip11CachedRetriever: Nip11CachedRetriever, modifier: Modifier = Modifier, + countResult: RelayCountResult? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -104,6 +105,11 @@ fun BasicRelaySetupInfoClickableRow( UsedBy(item, accountViewModel, nav) + RelayEventCountRow( + countResult = countResult, + modifier = ReactionRowHeightChatMaxWidth, + ) + RelayStatusRow( item = item, onClick = onClick, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt index 1a85e2aec..3196a1174 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoDialog.kt @@ -32,6 +32,7 @@ fun BasicRelaySetupInfoDialog( item: BasicRelaySetupInfo, nip11CachedRetriever: Nip11CachedRetriever, onDelete: ((BasicRelaySetupInfo) -> Unit)?, + countResult: RelayCountResult? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -43,6 +44,7 @@ fun BasicRelaySetupInfoDialog( onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, nip11CachedRetriever = nip11CachedRetriever, modifier = HalfVertPadding, + countResult = countResult, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt new file mode 100644 index 000000000..c1858eb9d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt @@ -0,0 +1,85 @@ +/* + * 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.ui.screen.loggedIn.relays.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.countToHumanReadable +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun RelayEventCountRow( + countResult: RelayCountResult?, + modifier: Modifier, +) { + if (countResult == null || countResult.counts.isEmpty()) return + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = modifier, + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = stringRes(R.string.relay_event_count), + modifier = Size15Modifier, + tint = MaterialTheme.colorScheme.allGoodColor, + ) + + countResult.counts.forEachIndexed { index, entry -> + if (index > 0) { + Spacer(modifier = Modifier.width(8.dp)) + } + + val text = + if (entry.approximate) { + "~${countToHumanReadable(entry.count, entry.label)}" + } else { + countToHumanReadable(entry.count, entry.label) + } + + Text( + text = text, + maxLines = 1, + fontSize = Font12SP, + modifier = HalfStartPadding, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt new file mode 100644 index 000000000..5529c2d92 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt @@ -0,0 +1,214 @@ +/* + * 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.ui.screen.loggedIn.relays.common + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +@Immutable +data class RelayCountResult( + val counts: List = emptyList(), +) { + @Immutable + data class CountEntry( + val label: String, + val count: Int, + val approximate: Boolean = false, + ) +} + +class RelayEventCountViewModel : ViewModel(), IRelayClientListener { + private val client: INostrClient get() = Amethyst.instance.client + + private val _counts = MutableStateFlow>(emptyMap()) + val counts = _counts.asStateFlow() + + // Maps subId -> (relay, filterIndex) so we can route count results + private val subIdToRelay = mutableMapOf>() + // Maps relay -> list of (subId, label) to track active queries + private val relayQueries = mutableMapOf>() + + private data class QueryInfo( + val subId: String, + val label: String, + val filterIndex: Int, + ) + + fun queryCountsForRelays(queries: Map>) { + cleanup() + client.subscribe(this) + + queries.forEach { (relay, filters) -> + val queryInfos = mutableListOf() + + filters.forEachIndexed { index, countFilter -> + val subId = newSubId() + subIdToRelay[subId] = Pair(relay, index) + queryInfos.add(QueryInfo(subId, countFilter.label, index)) + + client.queryCount( + subId = subId, + filters = mapOf(relay to listOf(countFilter.filter)), + ) + } + + relayQueries[relay] = queryInfos + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val (relayUrl, filterIndex) = subIdToRelay[msg.queryId] ?: return + val queryInfos = relayQueries[relayUrl] ?: return + val queryInfo = queryInfos.find { it.filterIndex == filterIndex } ?: return + + _counts.update { currentMap -> + val currentResult = currentMap[relayUrl] ?: RelayCountResult() + val updatedEntries = currentResult.counts.toMutableList() + + // Replace or add the entry for this filter + val existingIndex = updatedEntries.indexOfFirst { it.label == queryInfo.label } + val newEntry = + RelayCountResult.CountEntry( + label = queryInfo.label, + count = msg.result.count, + approximate = msg.result.approximate, + ) + + if (existingIndex >= 0) { + updatedEntries[existingIndex] = newEntry + } else { + updatedEntries.add(newEntry) + } + + currentMap + (relayUrl to RelayCountResult(updatedEntries)) + } + } + } + + private fun cleanup() { + // Close existing queries + subIdToRelay.keys.forEach { subId -> + client.close(subId) + } + subIdToRelay.clear() + relayQueries.clear() + _counts.value = emptyMap() + client.unsubscribe(this) + } + + override fun onCleared() { + cleanup() + super.onCleared() + } + + companion object { + fun authorCountFilters( + userPubKey: HexKey, + relays: List, + ): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "events", + filter = Filter(authors = listOf(userPubKey)), + ), + ) + } + + fun pTagCountFilters( + userPubKey: HexKey, + relays: List, + ): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "events", + filter = Filter(tags = mapOf("p" to listOf(userPubKey))), + ), + ) + } + + fun dmCountFilters( + userPubKey: HexKey, + relays: List, + ): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "events", + filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(userPubKey)), + ), + ), + ) + } + + fun totalCountFilters(relays: List): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "events", + filter = Filter(kinds = null), + ), + ) + } + + fun indexerCountFilters(relays: List): Map> = + relays.associateWith { + listOf( + CountFilter( + label = "kind 0", + filter = Filter(kinds = listOf(0)), + ), + CountFilter( + label = "kind 10002", + filter = Filter(kinds = listOf(10002)), + ), + ) + } + } +} + +data class CountFilter( + val label: String, + val filter: Filter, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt index 37e02b99b..f6e939571 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun DMRelayList( @@ -64,12 +66,14 @@ fun LazyListScope.renderDMItems( postViewModel: DMRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "DM" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt index 1af2490cd..56dbf8018 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun IndexerRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderIndexerItems( postViewModel: IndexerRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Indexer" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt index 212e1b3b5..a372d7286 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun PrivateOutboxRelayList( @@ -64,12 +66,14 @@ fun LazyListScope.renderPrivateOutboxItems( postViewModel: PrivateOutboxRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Outbox" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt index 7ac2497f0..73c201b6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun Nip65RelayList( @@ -107,12 +109,14 @@ fun LazyListScope.renderNip65HomeItems( postViewModel: Nip65RelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteHomeRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) @@ -133,12 +137,14 @@ fun LazyListScope.renderNip65NotifItems( postViewModel: Nip65RelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteNotifRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt index 2997a5c39..a737641f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun ProxyRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderProxyItems( postViewModel: ProxyRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Proxy" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt index 15f697596..1077d42e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListView.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Composable fun SearchRelayList( @@ -65,12 +67,14 @@ fun LazyListScope.renderSearchItems( postViewModel: SearchRelayListViewModel, accountViewModel: AccountViewModel, nav: INav, + countResults: Map = emptyMap(), ) { itemsIndexed(feedState, key = { _, item -> "Search" + item.relay.url }) { index, item -> BasicRelaySetupInfoDialog( item, onDelete = { postViewModel.deleteRelay(item) }, nip11CachedRetriever = Amethyst.instance.nip11Cache, + countResult = countResults[item.relay], accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 36ce30142..e322c05e7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -776,6 +776,7 @@ Write to Relay The amount in bytes that was sent to this relay, including filters and events The amount in bytes that was received from this relay, including filters and events + Events stored An error occurred trying to get relay information from %1$s Owner Used By From 764c2241cae17c65939de0395f064fbe1a422153 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 15:02:09 +0000 Subject: [PATCH 047/130] refactor: integrate relay event counts into each ViewModel Move count query logic from separate RelayEventCountViewModel into each relay list's own ViewModel. BasicRelaySetupInfoModel now manages counts via countFilters() override. Nip65RelayListViewModel gets its own count infrastructure for home (outbox) and notification (inbox) relay lists. Simplify AllRelayListScreen by removing 7 extra count ViewModels and their LaunchedEffects. https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA --- .../loggedIn/relays/AllRelayListScreen.kt | 94 +--------- .../relays/common/BasicRelaySetupInfoModel.kt | 104 ++++++++++- .../relays/common/RelayEventCountViewModel.kt | 173 ------------------ .../relays/dm/DMRelayListViewModel.kt | 16 ++ .../indexer/IndexerRelayListViewModel.kt | 14 ++ .../nip37/PrivateOutboxRelayListViewModel.kt | 10 + .../relays/nip65/Nip65RelayListViewModel.kt | 92 +++++++++- .../relays/proxy/ProxyRelayListViewModel.kt | 10 + .../relays/search/SearchRelayListViewModel.kt | 10 + 9 files changed, 261 insertions(+), 262 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index a9b79bb71..e45562fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelay import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.renderBlockedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.BroadcastRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast.renderBroadcastItems -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayEventCountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayExporter import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayListCollection import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayZipExporter @@ -110,13 +109,6 @@ fun AllRelayListScreen( val indexerViewModel: IndexerRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel() val relayFeedsViewModel: RelayFeedsListViewModel = viewModel() - val outboxCountViewModel: RelayEventCountViewModel = viewModel(key = "outboxCount") - val inboxCountViewModel: RelayEventCountViewModel = viewModel(key = "inboxCount") - val dmCountViewModel: RelayEventCountViewModel = viewModel(key = "dmCount") - val privateHomeCountViewModel: RelayEventCountViewModel = viewModel(key = "privateHomeCount") - val proxyCountViewModel: RelayEventCountViewModel = viewModel(key = "proxyCount") - val indexerCountViewModel: RelayEventCountViewModel = viewModel(key = "indexerCount") - val searchCountViewModel: RelayEventCountViewModel = viewModel(key = "searchCount") dmViewModel.init(accountViewModel) nip65ViewModel.init(accountViewModel) @@ -159,13 +151,6 @@ fun AllRelayListScreen( indexerViewModel, proxyViewModel, relayFeedsViewModel, - outboxCountViewModel, - inboxCountViewModel, - dmCountViewModel, - privateHomeCountViewModel, - proxyCountViewModel, - indexerCountViewModel, - searchCountViewModel, accountViewModel, nav, ) @@ -186,13 +171,6 @@ fun MappedAllRelayListView( indexerViewModel: IndexerRelayListViewModel, proxyViewModel: ProxyRelayListViewModel, relayFeedsViewModel: RelayFeedsListViewModel, - outboxCountViewModel: RelayEventCountViewModel, - inboxCountViewModel: RelayEventCountViewModel, - dmCountViewModel: RelayEventCountViewModel, - privateHomeCountViewModel: RelayEventCountViewModel, - proxyCountViewModel: RelayEventCountViewModel, - indexerCountViewModel: RelayEventCountViewModel, - searchCountViewModel: RelayEventCountViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -210,71 +188,13 @@ fun MappedAllRelayListView( val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle() - val outboxCounts by outboxCountViewModel.counts.collectAsStateWithLifecycle() - val inboxCounts by inboxCountViewModel.counts.collectAsStateWithLifecycle() - val dmCounts by dmCountViewModel.counts.collectAsStateWithLifecycle() - val privateHomeCounts by privateHomeCountViewModel.counts.collectAsStateWithLifecycle() - val proxyCounts by proxyCountViewModel.counts.collectAsStateWithLifecycle() - val indexerCounts by indexerCountViewModel.counts.collectAsStateWithLifecycle() - val searchCounts by searchCountViewModel.counts.collectAsStateWithLifecycle() - - val userPubKey = accountViewModel.account.pubKey - - LaunchedEffect(homeFeedState) { - if (homeFeedState.isNotEmpty()) { - outboxCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.authorCountFilters(userPubKey, homeFeedState.map { it.relay }), - ) - } - } - - LaunchedEffect(notifFeedState) { - if (notifFeedState.isNotEmpty()) { - inboxCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.pTagCountFilters(userPubKey, notifFeedState.map { it.relay }), - ) - } - } - - LaunchedEffect(dmFeedState) { - if (dmFeedState.isNotEmpty()) { - dmCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.dmCountFilters(userPubKey, dmFeedState.map { it.relay }), - ) - } - } - - LaunchedEffect(privateOutboxFeedState) { - if (privateOutboxFeedState.isNotEmpty()) { - privateHomeCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.authorCountFilters(userPubKey, privateOutboxFeedState.map { it.relay }), - ) - } - } - - LaunchedEffect(proxyRelays) { - if (proxyRelays.isNotEmpty()) { - proxyCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.totalCountFilters(proxyRelays.map { it.relay }), - ) - } - } - - LaunchedEffect(indexerRelays) { - if (indexerRelays.isNotEmpty()) { - indexerCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.indexerCountFilters(indexerRelays.map { it.relay }), - ) - } - } - - LaunchedEffect(searchFeedState) { - if (searchFeedState.isNotEmpty()) { - searchCountViewModel.queryCountsForRelays( - RelayEventCountViewModel.totalCountFilters(searchFeedState.map { it.relay }), - ) - } - } + val outboxCounts by nip65ViewModel.homeCountResults.collectAsStateWithLifecycle() + val inboxCounts by nip65ViewModel.notifCountResults.collectAsStateWithLifecycle() + val dmCounts by dmViewModel.countResults.collectAsStateWithLifecycle() + val privateHomeCounts by privateOutboxViewModel.countResults.collectAsStateWithLifecycle() + val proxyCounts by proxyViewModel.countResults.collectAsStateWithLifecycle() + val indexerCounts by indexerViewModel.countResults.collectAsStateWithLifecycle() + val searchCounts by searchViewModel.countResults.collectAsStateWithLifecycle() Scaffold( topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 758c39646..466cc1682 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -26,6 +26,11 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -33,13 +38,19 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -abstract class BasicRelaySetupInfoModel : ViewModel() { +abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { lateinit var accountViewModel: AccountViewModel lateinit var account: Account private val _relays = MutableStateFlow>(emptyList()) val relays = _relays.asStateFlow() + private val _countResults = MutableStateFlow>(emptyMap()) + val countResults = _countResults.asStateFlow() + + private val subIdToRelay = mutableMapOf>() + private val relayQueryInfos = mutableMapOf>() + var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -50,12 +61,15 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { fun load() { clear() loadRelayDocuments() + loadCounts() } abstract fun getRelayList(): List? abstract suspend fun saveRelayList(urlList: List) + open fun countFilters(relayUrl: NormalizedRelayUrl): List = emptyList() + fun create() { if (hasModified) { accountViewModel.launchSigner { @@ -79,6 +93,83 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { } } + private fun loadCounts() { + val client = Amethyst.instance.client + cleanupCounts() + + val relayList = _relays.value + if (relayList.isEmpty()) return + + val hasFilters = relayList.any { countFilters(it.relay).isNotEmpty() } + if (!hasFilters) return + + client.subscribe(this) + + relayList.forEach { item -> + val filters = countFilters(item.relay) + if (filters.isEmpty()) return@forEach + + val queryInfos = mutableListOf() + + filters.forEachIndexed { index, countFilter -> + val subId = newSubId() + subIdToRelay[subId] = Pair(item.relay, index) + queryInfos.add(CountQueryInfo(subId, countFilter.label, index)) + + client.queryCount( + subId = subId, + filters = mapOf(item.relay to listOf(countFilter.filter)), + ) + } + + relayQueryInfos[item.relay] = queryInfos + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val (relayUrl, filterIndex) = subIdToRelay[msg.queryId] ?: return + val queryInfos = relayQueryInfos[relayUrl] ?: return + val queryInfo = queryInfos.find { it.filterIndex == filterIndex } ?: return + + _countResults.update { currentMap -> + val currentResult = currentMap[relayUrl] ?: RelayCountResult() + val updatedEntries = currentResult.counts.toMutableList() + + val existingIndex = updatedEntries.indexOfFirst { it.label == queryInfo.label } + val newEntry = + RelayCountResult.CountEntry( + label = queryInfo.label, + count = msg.result.count, + approximate = msg.result.approximate, + ) + + if (existingIndex >= 0) { + updatedEntries[existingIndex] = newEntry + } else { + updatedEntries.add(newEntry) + } + + currentMap + (relayUrl to RelayCountResult(updatedEntries)) + } + } + } + + private fun cleanupCounts() { + val client = Amethyst.instance.client + subIdToRelay.keys.forEach { subId -> + client.close(subId) + } + subIdToRelay.clear() + relayQueryInfos.clear() + _countResults.value = emptyMap() + client.unsubscribe(this) + } + open fun relayListBuilder(): List { val relayList = getRelayList() ?: emptyList() @@ -122,4 +213,15 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { ) { _relays.update { it.replace(relay, relay.copy(paidRelay = paid)) } } + + override fun onCleared() { + cleanupCounts() + super.onCleared() + } + + private data class CountQueryInfo( + val subId: String, + val label: String, + val filterIndex: Int, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt index 5529c2d92..6f77913ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt @@ -21,21 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update @Immutable data class RelayCountResult( @@ -49,165 +35,6 @@ data class RelayCountResult( ) } -class RelayEventCountViewModel : ViewModel(), IRelayClientListener { - private val client: INostrClient get() = Amethyst.instance.client - - private val _counts = MutableStateFlow>(emptyMap()) - val counts = _counts.asStateFlow() - - // Maps subId -> (relay, filterIndex) so we can route count results - private val subIdToRelay = mutableMapOf>() - // Maps relay -> list of (subId, label) to track active queries - private val relayQueries = mutableMapOf>() - - private data class QueryInfo( - val subId: String, - val label: String, - val filterIndex: Int, - ) - - fun queryCountsForRelays(queries: Map>) { - cleanup() - client.subscribe(this) - - queries.forEach { (relay, filters) -> - val queryInfos = mutableListOf() - - filters.forEachIndexed { index, countFilter -> - val subId = newSubId() - subIdToRelay[subId] = Pair(relay, index) - queryInfos.add(QueryInfo(subId, countFilter.label, index)) - - client.queryCount( - subId = subId, - filters = mapOf(relay to listOf(countFilter.filter)), - ) - } - - relayQueries[relay] = queryInfos - } - } - - override fun onIncomingMessage( - relay: IRelayClient, - msgStr: String, - msg: Message, - ) { - if (msg is CountMessage) { - val (relayUrl, filterIndex) = subIdToRelay[msg.queryId] ?: return - val queryInfos = relayQueries[relayUrl] ?: return - val queryInfo = queryInfos.find { it.filterIndex == filterIndex } ?: return - - _counts.update { currentMap -> - val currentResult = currentMap[relayUrl] ?: RelayCountResult() - val updatedEntries = currentResult.counts.toMutableList() - - // Replace or add the entry for this filter - val existingIndex = updatedEntries.indexOfFirst { it.label == queryInfo.label } - val newEntry = - RelayCountResult.CountEntry( - label = queryInfo.label, - count = msg.result.count, - approximate = msg.result.approximate, - ) - - if (existingIndex >= 0) { - updatedEntries[existingIndex] = newEntry - } else { - updatedEntries.add(newEntry) - } - - currentMap + (relayUrl to RelayCountResult(updatedEntries)) - } - } - } - - private fun cleanup() { - // Close existing queries - subIdToRelay.keys.forEach { subId -> - client.close(subId) - } - subIdToRelay.clear() - relayQueries.clear() - _counts.value = emptyMap() - client.unsubscribe(this) - } - - override fun onCleared() { - cleanup() - super.onCleared() - } - - companion object { - fun authorCountFilters( - userPubKey: HexKey, - relays: List, - ): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "events", - filter = Filter(authors = listOf(userPubKey)), - ), - ) - } - - fun pTagCountFilters( - userPubKey: HexKey, - relays: List, - ): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "events", - filter = Filter(tags = mapOf("p" to listOf(userPubKey))), - ), - ) - } - - fun dmCountFilters( - userPubKey: HexKey, - relays: List, - ): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "events", - filter = - Filter( - kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), - tags = mapOf("p" to listOf(userPubKey)), - ), - ), - ) - } - - fun totalCountFilters(relays: List): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "events", - filter = Filter(kinds = null), - ), - ) - } - - fun indexerCountFilters(relays: List): Map> = - relays.associateWith { - listOf( - CountFilter( - label = "kind 0", - filter = Filter(kinds = listOf(0)), - ), - CountFilter( - label = "kind 10002", - filter = Filter(kinds = listOf(10002)), - ), - ) - } - } -} - data class CountFilter( val label: String, val filter: Filter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index d08612a48..a78df4519 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -22,7 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @Stable class DMRelayListViewModel : BasicRelaySetupInfoModel() { @@ -31,4 +35,16 @@ class DMRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveDMRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "events", + filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(account.pubKey)), + ), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index 837966624..c875c5a63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +35,16 @@ class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveIndexerRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "kind 0", + filter = Filter(kinds = listOf(0)), + ), + CountFilter( + label = "kind 10002", + filter = Filter(kinds = listOf(10002)), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index a3e5f9f41..5cd3e96ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +35,12 @@ class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.savePrivateOutboxRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "events", + filter = Filter(authors = listOf(account.pubKey)), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 134f7425c..fa1427b89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -28,7 +28,15 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import kotlinx.coroutines.Dispatchers @@ -38,7 +46,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Stable -class Nip65RelayListViewModel : ViewModel() { +class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account @@ -48,6 +56,14 @@ class Nip65RelayListViewModel : ViewModel() { private val _notificationRelays = MutableStateFlow>(emptyList()) val notificationRelays = _notificationRelays.asStateFlow() + private val _homeCountResults = MutableStateFlow>(emptyMap()) + val homeCountResults = _homeCountResults.asStateFlow() + + private val _notifCountResults = MutableStateFlow>(emptyMap()) + val notifCountResults = _notifCountResults.asStateFlow() + + private val subIdToRelay = mutableMapOf>() + var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -58,6 +74,7 @@ class Nip65RelayListViewModel : ViewModel() { fun load() { clear() loadRelayDocuments() + loadCounts() } fun create() { @@ -111,6 +128,74 @@ class Nip65RelayListViewModel : ViewModel() { } } + private fun loadCounts() { + val client = Amethyst.instance.client + cleanupCounts() + + val homeList = _homeRelays.value + val notifList = _notificationRelays.value + + if (homeList.isEmpty() && notifList.isEmpty()) return + + client.subscribe(this) + + homeList.forEach { item -> + val subId = newSubId() + subIdToRelay[subId] = Pair(item.relay, true) + client.queryCount( + subId = subId, + filters = mapOf(item.relay to listOf(Filter(authors = listOf(account.pubKey)))), + ) + } + + notifList.forEach { item -> + val subId = newSubId() + subIdToRelay[subId] = Pair(item.relay, false) + client.queryCount( + subId = subId, + filters = mapOf(item.relay to listOf(Filter(tags = mapOf("p" to listOf(account.pubKey))))), + ) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val (relayUrl, isHome) = subIdToRelay[msg.queryId] ?: return + + val newResult = + RelayCountResult( + listOf( + RelayCountResult.CountEntry( + label = "events", + count = msg.result.count, + approximate = msg.result.approximate, + ), + ), + ) + + if (isHome) { + _homeCountResults.update { it + (relayUrl to newResult) } + } else { + _notifCountResults.update { it + (relayUrl to newResult) } + } + } + } + + private fun cleanupCounts() { + val client = Amethyst.instance.client + subIdToRelay.keys.forEach { subId -> + client.close(subId) + } + subIdToRelay.clear() + _homeCountResults.value = emptyMap() + _notifCountResults.value = emptyMap() + client.unsubscribe(this) + } + fun clear() { hasModified = false _homeRelays.update { @@ -181,4 +266,9 @@ class Nip65RelayListViewModel : ViewModel() { ) { _notificationRelays.update { it.replace(relay, relay.copy(paidRelay = paid)) } } + + override fun onCleared() { + cleanupCounts() + super.onCleared() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt index e2a0f1359..01d07c40b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +35,12 @@ class ProxyRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveProxyRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "events", + filter = Filter(), + ), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index e2e3ba0aa..aaf592a68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -33,4 +35,12 @@ class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveSearchRelayList(urlList) } + + override fun countFilters(relayUrl: NormalizedRelayUrl): List = + listOf( + CountFilter( + label = "events", + filter = Filter(), + ), + ) } From 2c91fdea156be897d7a59fa3d457882a0bcb907e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 11:03:50 -0400 Subject: [PATCH 048/130] Avoids using `format` to write hex-encoded strings. --- .../domain/nip46/NostrConnectLoginUseCase.kt | 2 +- .../LnZapPaymentRequestEventTest.kt | 15 ++++++++------- .../namecoin/ElectrumXClient.kt | 3 ++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt index 674a38219..6725ef332 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt @@ -213,6 +213,6 @@ object NostrConnectLoginUseCase { private fun generateSecret(): String { val bytes = ByteArray(32) SecureRandom().nextBytes(bytes) - return bytes.joinToString("") { "%02x".format(it) } + return bytes.toHexKey() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt index bc5af2432..271c2783b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest @@ -44,7 +45,7 @@ class LnZapPaymentRequestEventTest { val walletKeyPair = KeyPair() val clientSigner = NostrSignerInternal(clientKeyPair) val walletServicePubkey: HexKey = - walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + walletKeyPair.pubKey.toHexKey() val event = LnZapPaymentRequestEvent.create( @@ -68,7 +69,7 @@ class LnZapPaymentRequestEventTest { val walletKeyPair = KeyPair() val clientSigner = NostrSignerInternal(clientKeyPair) val walletServicePubkey: HexKey = - walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + walletKeyPair.pubKey.toHexKey() val request = GetBalanceMethod.create() val event = @@ -92,7 +93,7 @@ class LnZapPaymentRequestEventTest { val walletKeyPair = KeyPair() val clientSigner = NostrSignerInternal(clientKeyPair) val walletServicePubkey: HexKey = - walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + walletKeyPair.pubKey.toHexKey() val request = GetBalanceMethod.create() val event = @@ -116,7 +117,7 @@ class LnZapPaymentRequestEventTest { val clientSigner = NostrSignerInternal(clientKeyPair) val walletSigner = NostrSignerInternal(walletKeyPair) val walletServicePubkey: HexKey = - walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + walletKeyPair.pubKey.toHexKey() val event = LnZapPaymentRequestEvent.create( @@ -139,7 +140,7 @@ class LnZapPaymentRequestEventTest { val clientSigner = NostrSignerInternal(clientKeyPair) val walletSigner = NostrSignerInternal(walletKeyPair) val walletServicePubkey: HexKey = - walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + walletKeyPair.pubKey.toHexKey() val request = MakeInvoiceMethod.create(5000L, "test payment") val event = @@ -163,7 +164,7 @@ class LnZapPaymentRequestEventTest { val clientSigner = NostrSignerInternal(clientKeyPair) val walletSigner = NostrSignerInternal(walletKeyPair) val walletServicePubkey: HexKey = - walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + walletKeyPair.pubKey.toHexKey() val request = GetInfoMethod.create() val event = @@ -191,7 +192,7 @@ class LnZapPaymentRequestEventTest { val walletSigner = NostrSignerInternal(walletKeyPair) val otherSigner = NostrSignerInternal(otherKeyPair) val walletServicePubkey: HexKey = - walletKeyPair.pubKey.joinToString("") { "%02x".format(it) } + walletKeyPair.pubKey.toHexKey() val event = LnZapPaymentRequestEvent.create( diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index c56dd72b1..b0ba1d6b7 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -277,7 +278,7 @@ class ElectrumXClient( */ private fun electrumScriptHash(script: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256").digest(script) - return digest.reversedArray().joinToString("") { "%02x".format(it) } + return digest.reversedArray().toHexKey() } /** From 0380927f7e31b5cbca394dc2df8505cf9840b641 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 11:05:40 -0400 Subject: [PATCH 049/130] fromJsonTo calls throw, they should always be wrapped if we want null results. --- .../vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt index b06d3710c..163d25841 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt @@ -91,7 +91,7 @@ class NotificationTest { @Test fun testUnknownNotificationTypeReturnsNull() { val json = """{"notification_type":"unknown_type","notification":{}}""" - val notification = OptimizedJsonMapper.fromJsonTo(json) + val notification = runCatching { OptimizedJsonMapper.fromJsonTo(json) }.getOrNull() assertNull(notification) } } From 68f58c3b8d2cadce9dc2304e0a9988edf520f9e6 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 15 Mar 2026 15:06:39 +0000 Subject: [PATCH 050/130] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 41 ++++++++++++++++--- .../src/main/res/values-hu-rHU/strings.xml | 8 ++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 6332634f0..0c808a742 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1068,6 +1068,13 @@ संख्या (साट्स) विवरण (विकल्पात्मक) चालान बनाएँ + चालान बनाया जा रहा है… + चालान अनुकृति + अभी कोई लेनदेन नहीं + आवहन चालू… + प्राप्त + भेजा गया + नवीकरण सुरक्षार्थ छलनियाँ अनुचरित आयात करें नया पत्र प्रकाशन @@ -1104,8 +1111,8 @@ पुनःप्रसारक सूची चयनकर्ता मतदान मतदान अक्षम करें - बिटकोयिन चालान - बिटकोयिन चालान निरस्त करें + द्व्यंकरूप्य चालान + द्व्यंकरूप्य चालान निरस्त करें वस्तु बिक्री निरस्त करें ज्सापोपार्जन योजना ज्सापोपार्जन योजना निरस्त करें @@ -1210,7 +1217,7 @@ अनुकृति : ओ॰टी॰एस : %1$s समयांकन प्रमाण - प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया बिटकोयिन खण्डश्रृंखला में उस समय उस दिन पर। + प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर। पत्र का सम्पादन करें पत्र शोधन के लिए प्रस्ताव परिवर्तनों का साराम्श @@ -1445,8 +1452,8 @@ अभिलेख शीर्षक परिचय चित्रालय अभिलेख सेवासंगणक - अंकीय तथ्याभिलेख - अंकीय अभिलेख शीर्षक + द्व्यंकीय अभिलेख + द्व्यंकीय अभिलेख शीर्षक चिकित्सा अभिलेख अनुचरण पोटलियाँ पुनःप्रकाशन (१६) @@ -1527,4 +1534,28 @@ अनुचरित सूची आयात प्रयोक्ता चुनें अनुचरण करने के लिए प्रयोक्ता परिचय जिससे आयात करना है + खोज, एनपुब॰१…, alice@example.com + एनपुब॰ एन॰परिचय निप॰०५ षोडशांक तथा नामरूप्य का आलम्बन करता है (.bit, d/, id/) + अनुचरण सूची देखें + पारितोषिक + %1$d लेखाएँ प्राप्त + %1$d चयनित + नामरूप्य द्वारा सुलझा गया + अब %1$d लेखाएँ अनुचरित + आपकी सूचनावली उपलब्ध है। + छोडें + अन्य खोजें + %1$d लेखाओं का अनुचरण करें + अधिक आयात + चलते रहें + अभी के लिए छोडें + %1$s का सुलझन चालू… + अनुचरण सूची प्राप्त की जा रही है… + कोई अनुचरित नहीं + %1$d लेखाओं का अनुचरण… + "किसी मित्र अथवा समूह नेता का परिचय प्रविष्ट करें। उनके एनपुब॰ अथवा निप॰०५ पता अथवा नामरूप्य नाम जैसे कि alice@example.com अथवा id/alice का उपयोग आप कर सकते हैं खण्डश्रृंखला सत्यापित विभेदकों के लिए।" + सभी चुनें + %1$d%% समय निरन्तर उपलब्ध + नामरूप्य स्थापना विकल्प + द्व्यंकरूप्य समन्वेषक (ओटीएस॰) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index cdaa9bb88..f4b6a7428 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1052,6 +1052,14 @@ Globális Rövidek Sakk + Pénztárca + Küldés + Fogadás + Tranzakciók + Pénztárca beállítása + Egy BOLT-11 számla beillesztése + Fizetés + Sikeres fizetés Biztonsági szűrők Követettek importálása Új bejegyzés From 2b157cc3833e425d1633c868c30bf513799379cd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 11:18:39 -0400 Subject: [PATCH 051/130] Fixes throw vs null test cases and nsec hallucinations --- .../nip47WalletConnect/NotificationTest.kt | 51 ++++++++++--------- .../nip47WalletConnect/NwcInfoEventTest.kt | 2 +- .../quartz/nip47WalletConnect/RequestTest.kt | 6 ++- .../core/OptimizedJsonMapper.jvmAndroid.kt | 5 ++ 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt index 163d25841..39fd0f56b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NotificationTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -35,16 +36,16 @@ class NotificationTest { val notification = OptimizedJsonMapper.fromJsonTo(json) assertIs(notification) assertNotNull(notification.notification) - assertEquals("incoming", notification.notification?.type) - assertEquals("lnbc50n1...", notification.notification?.invoice) - assertEquals("coffee", notification.notification?.description) - assertEquals("abc", notification.notification?.preimage) - assertEquals("hash123", notification.notification?.payment_hash) - assertEquals(5000L, notification.notification?.amount) - assertEquals(10L, notification.notification?.fees_paid) - assertEquals(1693876497L, notification.notification?.created_at) - assertEquals(1694876497L, notification.notification?.expires_at) - assertEquals(1694876500L, notification.notification?.settled_at) + assertEquals("incoming", notification.notification.type) + assertEquals("lnbc50n1...", notification.notification.invoice) + assertEquals("coffee", notification.notification.description) + assertEquals("abc", notification.notification.preimage) + assertEquals("hash123", notification.notification.payment_hash) + assertEquals(5000L, notification.notification.amount) + assertEquals(10L, notification.notification.fees_paid) + assertEquals(1693876497L, notification.notification.created_at) + assertEquals(1694876497L, notification.notification.expires_at) + assertEquals(1694876500L, notification.notification.settled_at) } @Test @@ -54,11 +55,11 @@ class NotificationTest { val notification = OptimizedJsonMapper.fromJsonTo(json) assertIs(notification) assertNotNull(notification.notification) - assertEquals("outgoing", notification.notification?.type) - assertEquals("lnbc100n1...", notification.notification?.invoice) - assertEquals("def456", notification.notification?.preimage) - assertEquals(10000L, notification.notification?.amount) - assertEquals(50L, notification.notification?.fees_paid) + assertEquals("outgoing", notification.notification.type) + assertEquals("lnbc100n1...", notification.notification.invoice) + assertEquals("def456", notification.notification.preimage) + assertEquals(10000L, notification.notification.amount) + assertEquals(50L, notification.notification.fees_paid) } @Test @@ -68,13 +69,13 @@ class NotificationTest { val notification = OptimizedJsonMapper.fromJsonTo(json) assertIs(notification) assertNotNull(notification.notification) - assertEquals("incoming", notification.notification?.type) - assertEquals("lnbc200n1...", notification.notification?.invoice) - assertEquals("hash789", notification.notification?.payment_hash) - assertEquals(20000L, notification.notification?.amount) - assertEquals(800000L, notification.notification?.settle_deadline) - assertEquals(1693876497L, notification.notification?.created_at) - assertEquals(1694876497L, notification.notification?.expires_at) + assertEquals("incoming", notification.notification.type) + assertEquals("lnbc200n1...", notification.notification.invoice) + assertEquals("hash789", notification.notification.payment_hash) + assertEquals(20000L, notification.notification.amount) + assertEquals(800000L, notification.notification.settle_deadline) + assertEquals(1693876497L, notification.notification.created_at) + assertEquals(1694876497L, notification.notification.expires_at) } @Test @@ -89,9 +90,11 @@ class NotificationTest { } @Test + @Throws(IllegalArgumentException::class) fun testUnknownNotificationTypeReturnsNull() { val json = """{"notification_type":"unknown_type","notification":{}}""" - val notification = runCatching { OptimizedJsonMapper.fromJsonTo(json) }.getOrNull() - assertNull(notification) + assertFailsWith { + OptimizedJsonMapper.fromJsonTo(json) + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt index 3a8b15ca8..82d55cc64 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcInfoEventTest.kt @@ -28,7 +28,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class NwcInfoEventTest { - private val signer = DeterministicSigner("nsec1w4uucmeyyng0kegm7486r23sv4majkmvqsj6eypprq0xttxss55s5mgg9t".nsecToKeyPair()) + private val signer = DeterministicSigner("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) @Test fun testBuildInfoEvent() { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt index 21816b647..5b43e7bf9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/RequestTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip47WalletConnect import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -396,7 +397,8 @@ class RequestTest { @Test fun testUnknownMethodReturnsNull() { val json = """{"method":"unknown_method","params":{}}""" - val request = OptimizedJsonMapper.fromJsonTo(json) - assertNull(request) + assertFailsWith { + OptimizedJsonMapper.fromJsonTo(json) + } } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt index 65af77421..5a501c070 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.core +import com.fasterxml.jackson.databind.RuntimeJsonMappingException import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command @@ -78,6 +79,10 @@ actual object OptimizedJsonMapper { JacksonMapper.fromJsonTo(json) } catch (e: com.fasterxml.jackson.core.JsonParseException) { throw IllegalArgumentException(e.message, e) + } catch (e: com.fasterxml.jackson.core.JsonProcessingException) { + throw IllegalArgumentException(e.message, e) + } catch (e: RuntimeJsonMappingException) { + throw IllegalArgumentException(e.message, e) } actual fun toJson(value: OptimizedSerializable): String = JacksonMapper.toJson(value) From 3bd6ef5f6c5f943cdc7d8dc6d4d33ca927ff897c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 11:44:20 -0400 Subject: [PATCH 052/130] changes from damus to nos.lol for more stable relay behavior in tests --- .../quartz/nip01Core/relay/NostrClientManualSubTest.kt | 2 +- .../quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt | 2 +- .../quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt | 2 +- .../relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index bff422a78..338a832e8 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -81,7 +81,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { client.openReqSubscription(mySubId, filters, listener) - withTimeoutOrNull(30000) { + withTimeoutOrNull(10000) { while (events.size < 101) { val event = resultChannel.receive() events.add(event) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index 5f289a4c6..e942fe134 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -48,7 +48,7 @@ class NostrClientSendAndWaitTest : BaseNostrClientTest() { val resultDamus = client.sendAndWaitForResponse( event = event, - relayList = setOf("wss://relay.damus.io".normalizeRelayUrl()), + relayList = setOf("wss://nostr.bitcoiner.social".normalizeRelayUrl()), ) val resultNos = diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index f7faca43c..e28b887f2 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -54,7 +54,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { val flow = client.reqAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index ddcb9fde9..913bf5eff 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -54,7 +54,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { val flow = client.reqUntilEoseAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), From 17c3611f503951c19612331b4d3eee63c65d236c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 15:46:56 +0000 Subject: [PATCH 053/130] feat: redesign RelayEventCountRow with pill chip style Replace the plain icon+text count display with rounded pill chips that have a subtle green border and tinted background. Each count entry gets its own pill with an icon and bold text, making the event counts more visually distinct and scannable. https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA --- .../relays/common/RelayEventCountRow.kt | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt index c1858eb9d..b62432f4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt @@ -20,10 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.Icon @@ -32,15 +36,17 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.countToHumanReadable import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Font10SP +import com.vitorpamplona.amethyst.ui.theme.Size10Modifier import com.vitorpamplona.amethyst.ui.theme.allGoodColor -import com.vitorpamplona.amethyst.ui.theme.placeholderText + +private val PillShape = RoundedCornerShape(12.dp) @Composable fun RelayEventCountRow( @@ -49,37 +55,51 @@ fun RelayEventCountRow( ) { if (countResult == null || countResult.counts.isEmpty()) return + val pillColor = MaterialTheme.colorScheme.allGoodColor + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, modifier = modifier, ) { - Icon( - imageVector = Icons.Default.Storage, - contentDescription = stringRes(R.string.relay_event_count), - modifier = Size15Modifier, - tint = MaterialTheme.colorScheme.allGoodColor, - ) - countResult.counts.forEachIndexed { index, entry -> if (index > 0) { - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(6.dp)) } - val text = + val countText = if (entry.approximate) { "~${countToHumanReadable(entry.count, entry.label)}" } else { countToHumanReadable(entry.count, entry.label) } - Text( - text = text, - maxLines = 1, - fontSize = Font12SP, - modifier = HalfStartPadding, - color = MaterialTheme.colorScheme.placeholderText, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .clip(PillShape) + .border(width = 1.dp, color = pillColor.copy(alpha = 0.4f), shape = PillShape) + .background(pillColor.copy(alpha = 0.1f)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = stringRes(R.string.relay_event_count), + modifier = Size10Modifier, + tint = pillColor, + ) + + Spacer(modifier = Modifier.width(3.dp)) + + Text( + text = countText, + maxLines = 1, + fontSize = Font10SP, + fontWeight = FontWeight.Medium, + color = pillColor, + ) + } } } } From d8de0a9b84c740a00455efd58b8a35c72745df14 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 15 Mar 2026 15:47:20 +0000 Subject: [PATCH 054/130] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hu-rHU/strings.xml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index f4b6a7428..8615b4bfb 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -418,7 +418,7 @@ Áthelyezés a nyilvános könyvjelzőkbe Áthelyezés a privát könyvjelzőkbe Wallet Connect szolgáltatás - Hitelesíti, hogy a Nostr Secret az alkalmazásból való kilépés nélkül fizessen a Zap-et. Tartsa biztonságban a titkot, és lehetőség szerint használjon privát átjátszót + Lehetővé teszi az Amethyst számára, hogy az alkalmazásból kilépés nélkül fizessen. Tartsa biztonságos helyen a titkot! Wallet Connect nyilvános kulcs Wallet Connect átjátszó Wallet Connect titok @@ -1053,13 +1053,28 @@ Rövidek Sakk Pénztárca + Egyenleg Küldés Fogadás Tranzakciók + Nincs pénztárca összekapcsolva + A pénztárca használatához állítson be egy Nostr Wallet Connect (NWC) kapcsolatot a zap-beállításokban. Pénztárca beállítása + satoshik Egy BOLT-11 számla beillesztése Fizetés Sikeres fizetés + Fizetés küldése… + Összeg (satoshiban) + Leírás (nem kötelező) + Számla létrehozása + Számla létrehozása… + Számla másolása + Még nincsenek tranzakciók + Betöltés… + Fogadott + Elküldött + Frissítés Biztonsági szűrők Követettek importálása Új bejegyzés From 22945b7faa5ae8b418461eef2c3a79d029fcbaff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 16:14:24 +0000 Subject: [PATCH 055/130] feat: add queryCountSuspend utility to Quartz and simplify ViewModels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add INostrClient.queryCountSuspend() extension functions in Quartz that wrap NIP-45 COUNT queries as suspend functions, managing subscription lifecycle internally. Two overloads: single-relay and multi-relay. Simplify BasicRelaySetupInfoModel and Nip65RelayListViewModel to use the new utility — removes IRelayClientListener implementation, subId tracking maps, onIncomingMessage handlers, and cleanup logic from both ViewModels. https://claude.ai/code/session_016158D5mq5BygS1uBbLNbsA --- .../relays/common/BasicRelaySetupInfoModel.kt | 105 +++----------- .../relays/nip65/Nip65RelayListViewModel.kt | 118 ++++++--------- .../client/accessories/NostrClientCountExt.kt | 136 ++++++++++++++++++ 3 files changed, 200 insertions(+), 159 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index 466cc1682..e198edcc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -26,11 +26,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -38,7 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { +abstract class BasicRelaySetupInfoModel : ViewModel() { lateinit var accountViewModel: AccountViewModel lateinit var account: Account @@ -48,9 +44,6 @@ abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { private val _countResults = MutableStateFlow>(emptyMap()) val countResults = _countResults.asStateFlow() - private val subIdToRelay = mutableMapOf>() - private val relayQueryInfos = mutableMapOf>() - var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -94,82 +87,39 @@ abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { } private fun loadCounts() { - val client = Amethyst.instance.client - cleanupCounts() + _countResults.value = emptyMap() + val client = Amethyst.instance.client val relayList = _relays.value if (relayList.isEmpty()) return - val hasFilters = relayList.any { countFilters(it.relay).isNotEmpty() } - if (!hasFilters) return - - client.subscribe(this) - relayList.forEach { item -> val filters = countFilters(item.relay) if (filters.isEmpty()) return@forEach - val queryInfos = mutableListOf() - - filters.forEachIndexed { index, countFilter -> - val subId = newSubId() - subIdToRelay[subId] = Pair(item.relay, index) - queryInfos.add(CountQueryInfo(subId, countFilter.label, index)) - - client.queryCount( - subId = subId, - filters = mapOf(item.relay to listOf(countFilter.filter)), - ) - } - - relayQueryInfos[item.relay] = queryInfos - } - } - - override fun onIncomingMessage( - relay: IRelayClient, - msgStr: String, - msg: Message, - ) { - if (msg is CountMessage) { - val (relayUrl, filterIndex) = subIdToRelay[msg.queryId] ?: return - val queryInfos = relayQueryInfos[relayUrl] ?: return - val queryInfo = queryInfos.find { it.filterIndex == filterIndex } ?: return - - _countResults.update { currentMap -> - val currentResult = currentMap[relayUrl] ?: RelayCountResult() - val updatedEntries = currentResult.counts.toMutableList() - - val existingIndex = updatedEntries.indexOfFirst { it.label == queryInfo.label } - val newEntry = - RelayCountResult.CountEntry( - label = queryInfo.label, - count = msg.result.count, - approximate = msg.result.approximate, - ) - - if (existingIndex >= 0) { - updatedEntries[existingIndex] = newEntry - } else { - updatedEntries.add(newEntry) + filters.forEach { countFilter -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, countFilter.filter) + if (result != null) { + _countResults.update { currentMap -> + val current = currentMap[item.relay] ?: RelayCountResult() + val entries = current.counts.toMutableList() + val newEntry = + RelayCountResult.CountEntry( + label = countFilter.label, + count = result.count, + approximate = result.approximate, + ) + val existing = entries.indexOfFirst { it.label == countFilter.label } + if (existing >= 0) entries[existing] = newEntry else entries.add(newEntry) + currentMap + (item.relay to RelayCountResult(entries)) + } + } } - - currentMap + (relayUrl to RelayCountResult(updatedEntries)) } } } - private fun cleanupCounts() { - val client = Amethyst.instance.client - subIdToRelay.keys.forEach { subId -> - client.close(subId) - } - subIdToRelay.clear() - relayQueryInfos.clear() - _countResults.value = emptyMap() - client.unsubscribe(this) - } - open fun relayListBuilder(): List { val relayList = getRelayList() ?: emptyList() @@ -213,15 +163,4 @@ abstract class BasicRelaySetupInfoModel : ViewModel(), IRelayClientListener { ) { _relays.update { it.replace(relay, relay.copy(paidRelay = paid)) } } - - override fun onCleared() { - cleanupCounts() - super.onCleared() - } - - private data class CountQueryInfo( - val subId: String, - val label: String, - val filterIndex: Int, - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index fa1427b89..0894a0f04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -30,11 +30,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayCountResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder -import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo @@ -46,7 +42,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @Stable -class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { +class Nip65RelayListViewModel : ViewModel() { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account @@ -62,8 +58,6 @@ class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { private val _notifCountResults = MutableStateFlow>(emptyMap()) val notifCountResults = _notifCountResults.asStateFlow() - private val subIdToRelay = mutableMapOf>() - var hasModified = false fun init(accountViewModel: AccountViewModel) { @@ -129,71 +123,48 @@ class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { } private fun loadCounts() { - val client = Amethyst.instance.client - cleanupCounts() - - val homeList = _homeRelays.value - val notifList = _notificationRelays.value - - if (homeList.isEmpty() && notifList.isEmpty()) return - - client.subscribe(this) - - homeList.forEach { item -> - val subId = newSubId() - subIdToRelay[subId] = Pair(item.relay, true) - client.queryCount( - subId = subId, - filters = mapOf(item.relay to listOf(Filter(authors = listOf(account.pubKey)))), - ) - } - - notifList.forEach { item -> - val subId = newSubId() - subIdToRelay[subId] = Pair(item.relay, false) - client.queryCount( - subId = subId, - filters = mapOf(item.relay to listOf(Filter(tags = mapOf("p" to listOf(account.pubKey))))), - ) - } - } - - override fun onIncomingMessage( - relay: IRelayClient, - msgStr: String, - msg: Message, - ) { - if (msg is CountMessage) { - val (relayUrl, isHome) = subIdToRelay[msg.queryId] ?: return - - val newResult = - RelayCountResult( - listOf( - RelayCountResult.CountEntry( - label = "events", - count = msg.result.count, - approximate = msg.result.approximate, - ), - ), - ) - - if (isHome) { - _homeCountResults.update { it + (relayUrl to newResult) } - } else { - _notifCountResults.update { it + (relayUrl to newResult) } - } - } - } - - private fun cleanupCounts() { - val client = Amethyst.instance.client - subIdToRelay.keys.forEach { subId -> - client.close(subId) - } - subIdToRelay.clear() _homeCountResults.value = emptyMap() _notifCountResults.value = emptyMap() - client.unsubscribe(this) + + val client = Amethyst.instance.client + + _homeRelays.value.forEach { item -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, Filter(authors = listOf(account.pubKey))) + if (result != null) { + val countResult = + RelayCountResult( + listOf( + RelayCountResult.CountEntry( + label = "events", + count = result.count, + approximate = result.approximate, + ), + ), + ) + _homeCountResults.update { it + (item.relay to countResult) } + } + } + } + + _notificationRelays.value.forEach { item -> + viewModelScope.launch(Dispatchers.IO) { + val result = client.queryCountSuspend(item.relay, Filter(tags = mapOf("p" to listOf(account.pubKey)))) + if (result != null) { + val countResult = + RelayCountResult( + listOf( + RelayCountResult.CountEntry( + label = "events", + count = result.count, + approximate = result.approximate, + ), + ), + ) + _notifCountResults.update { it + (item.relay to countResult) } + } + } + } } fun clear() { @@ -266,9 +237,4 @@ class Nip65RelayListViewModel : ViewModel(), IRelayClientListener { ) { _notificationRelays.update { it.replace(relay, relay.copy(paidRelay = paid)) } } - - override fun onCleared() { - cleanupCounts() - super.onCleared() - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt new file mode 100644 index 000000000..b1ea3d05d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientCountExt.kt @@ -0,0 +1,136 @@ +/* + * 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.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Sends a NIP-45 COUNT query to a single relay and suspends until + * the result arrives or the timeout expires. + * + * @param relay Target relay to query. + * @param filter The filter to count against. + * @param timeoutMs How long to wait for a response (default 15 s). + * @return The [CountResult], or `null` on timeout. + */ +suspend fun INostrClient.queryCountSuspend( + relay: NormalizedRelayUrl, + filter: Filter, + timeoutMs: Long = 15_000, +): CountResult? { + val subId = newSubId() + val resultChannel = Channel(UNLIMITED) + + val listener = + object : IRelayClientListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage && msg.queryId == subId) { + resultChannel.trySend(msg.result) + } + } + } + + subscribe(listener) + + queryCount(subId = subId, filters = mapOf(relay to listOf(filter))) + + val result = + withTimeoutOrNull(timeoutMs) { + resultChannel.receive() + } + + close(subId) + unsubscribe(listener) + resultChannel.close() + + return result +} + +/** + * Sends NIP-45 COUNT queries to multiple relays in parallel + * (one filter per relay) and suspends until all results arrive + * or the timeout expires. + * + * @param filters Map of relay -> filter to count. + * @param timeoutMs How long to wait for all responses (default 15 s). + * @return Map of relay -> [CountResult] for every relay that responded in time. + */ +suspend fun INostrClient.queryCountSuspend( + filters: Map>, + timeoutMs: Long = 15_000, +): Map { + if (filters.isEmpty()) return emptyMap() + + val subIdToRelay = mutableMapOf() + val resultChannel = Channel>(UNLIMITED) + + val listener = + object : IRelayClientListener { + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + if (msg is CountMessage) { + val relayUrl = subIdToRelay[msg.queryId] ?: return + resultChannel.trySend(relayUrl to msg.result) + } + } + } + + subscribe(listener) + + filters.forEach { (relay, filterList) -> + val subId = newSubId() + subIdToRelay[subId] = relay + queryCount(subId = subId, filters = mapOf(relay to filterList)) + } + + val results = mutableMapOf() + + withTimeoutOrNull(timeoutMs) { + while (results.size < filters.size) { + val (relay, result) = resultChannel.receive() + results[relay] = result + } + } + + subIdToRelay.keys.forEach { close(it) } + unsubscribe(listener) + resultChannel.close() + + return results +} From ffac6822380c663ff798ebbeac3c45ae47308a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 16:37:22 +0000 Subject: [PATCH 056/130] feat: Add kotlinx.serialization KSerializers mirroring all Jackson custom serializers Add 15 KSerializer implementations in commonMain that replicate the exact behavior of all 21 Jackson serializer/deserializer registrations: - EventKSerializer, TagArrayKSerializer, FilterKSerializer - EventTemplateKSerializer, RumorKSerializer, CountResultKSerializer - MessageKSerializer (8 message types), CommandKSerializer (5 command types) - BunkerRequest/Response/MessageKSerializer (NIP-46) - Nip47Request/Response/NotificationKSerializer (NIP-47) - KotlinSerializationMapper as the main entry point Uses JsonElement-based approach for performance. All serializers produce identical JSON output to Jackson. Includes comprehensive test suite (35+ tests) verifying exact output parity and cross-deserialization between Jackson and kotlinx.serialization. https://claude.ai/code/session_01QBc4Pb7a6m4TfLZSJKVdjw --- .../BunkerMessageKSerializer.kt | 97 +++ .../BunkerRequestKSerializer.kt | 100 +++ .../BunkerResponseKSerializer.kt | 110 +++ .../kotlinSerialization/CommandKSerializer.kt | 116 +++ .../CountResultKSerializer.kt | 72 ++ .../kotlinSerialization/EventKSerializer.kt | 111 +++ .../EventTemplateKSerializer.kt | 87 ++ .../kotlinSerialization/FilterKSerializer.kt | 141 ++++ .../KotlinSerializationMapper.kt | 102 +++ .../kotlinSerialization/MessageKSerializer.kt | 136 ++++ .../Nip47NotificationKSerializer.kt | 94 +++ .../Nip47RequestKSerializer.kt | 241 ++++++ .../Nip47ResponseKSerializer.kt | 263 +++++++ .../kotlinSerialization/RumorKSerializer.kt | 93 +++ .../TagArrayKSerializer.kt | 101 +++ .../KotlinSerializationMapperTest.kt | 740 ++++++++++++++++++ 16 files changed, 2604 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt new file mode 100644 index 000000000..157be1b95 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt @@ -0,0 +1,97 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object BunkerMessageKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerMessage") + + override fun serialize( + encoder: Encoder, + value: BunkerMessage, + ) { + val jsonEncoder = encoder as JsonEncoder + when (value) { + is BunkerRequest -> BunkerRequestKSerializer.serialize(jsonEncoder, value) + is BunkerResponse -> BunkerResponseKSerializer.serialize(jsonEncoder, value) + else -> throw IllegalArgumentException("Unknown BunkerMessage type") + } + } + + override fun deserialize(decoder: Decoder): BunkerMessage { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val isRequest = jsonObject.containsKey("method") + + return if (isRequest) { + val id = jsonObject["id"]!!.jsonPrimitive.content + val method = jsonObject["method"]!!.jsonPrimitive.content + val params = + jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() + ?: emptyArray() + dispatchBunkerRequest(id, method, params) + } else { + BunkerResponseKSerializer.deserializeFromElement(jsonObject) + } + } + + private fun dispatchBunkerRequest( + id: String, + method: String, + params: Array, + ): BunkerRequest { + return when (method) { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.parse(id, params) + else -> BunkerRequest(id, method, params) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt new file mode 100644 index 000000000..3d748bfd0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt @@ -0,0 +1,100 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object BunkerRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerRequest") { + element("id") + element("method") + element>("params") + } + + override fun serialize( + encoder: Encoder, + value: BunkerRequest, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("id", value.id) + put("method", value.method) + put( + "params", + buildJsonArray { + for (p in value.params) { + add(JsonPrimitive(p)) + } + }, + ) + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): BunkerRequest { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val id = jsonObject["id"]!!.jsonPrimitive.content + val method = jsonObject["method"]!!.jsonPrimitive.content + val params = + jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() + ?: emptyArray() + + return when (method) { + BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) + BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) + BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) + BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) + BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) + BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) + BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) + BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) + BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) + else -> BunkerRequest(id, method, params) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt new file mode 100644 index 000000000..bf4cbbaf6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt @@ -0,0 +1,110 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object BunkerResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerResponse") { + element("id") + element("result") + element("error") + } + + override fun serialize( + encoder: Encoder, + value: BunkerResponse, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("id", value.id) + value.result?.let { put("result", it) } + value.error?.let { put("error", it) } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): BunkerResponse { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): BunkerResponse { + val id = jsonObject["id"]!!.jsonPrimitive.content + val result = jsonObject["result"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + val error = jsonObject["error"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + + if (error != null) { + return BunkerResponseError.parse(id, result, error) + } + + if (result != null) { + when (result) { + BunkerResponseAck.RESULT -> return BunkerResponseAck.parse(id, result, error) + BunkerResponsePong.RESULT -> return BunkerResponsePong.parse(id, result, error) + else -> { + if (result.length == 64 && Hex.isHex(result)) { + return BunkerResponsePublicKey.parse(id, result) + } + + if (result.isNotEmpty() && result[0] == '{') { + try { + return BunkerResponseEvent.parse(id, result) + } catch (_: Exception) { + } + + try { + return BunkerResponseGetRelays.parse(id, result) + } catch (_: Exception) { + } + } + + return BunkerResponse(id, result, error) + } + } + } + + return BunkerResponse(id, result, error) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt new file mode 100644 index 000000000..790a710dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -0,0 +1,116 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object CommandKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Command") + + override fun serialize( + encoder: Encoder, + value: Command, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + add(JsonPrimitive(value.label())) + when (value) { + is ReqCmd -> { + add(JsonPrimitive(value.subId)) + for (filter in value.filters) { + add(FilterKSerializer.serializeToElement(filter)) + } + } + is EventCmd -> { + add(EventKSerializer.serializeToElement(value.event)) + } + is CloseCmd -> { + add(JsonPrimitive(value.subId)) + } + is AuthCmd -> { + add(EventKSerializer.serializeToElement(value.event)) + } + is CountCmd -> { + add(JsonPrimitive(value.queryId)) + for (filter in value.filters) { + add(FilterKSerializer.serializeToElement(filter)) + } + } + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Command { + val jsonDecoder = decoder as JsonDecoder + val array = jsonDecoder.decodeJsonElement().jsonArray + val type = array[0].jsonPrimitive.content + + return when (type) { + ReqCmd.LABEL -> { + val subId = array[1].jsonPrimitive.content + val filters = + (2 until array.size).map { i -> + FilterKSerializer.deserializeFromElement(array[i].jsonObject) + } + ReqCmd(subId, filters) + } + CountCmd.LABEL -> { + val queryId = array[1].jsonPrimitive.content + val filters = + (2 until array.size).map { i -> + FilterKSerializer.deserializeFromElement(array[i].jsonObject) + } + CountCmd(queryId, filters) + } + EventCmd.LABEL -> { + EventCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject)) + } + CloseCmd.LABEL -> { + CloseCmd(array[1].jsonPrimitive.content) + } + AuthCmd.LABEL -> { + AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent) + } + else -> throw IllegalArgumentException("Message $type is not supported") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt new file mode 100644 index 000000000..ed0ff95ae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -0,0 +1,72 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object CountResultKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CountResult") { + element("count") + element("pubkey") + } + + override fun serialize( + encoder: Encoder, + value: CountResult, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(value: CountResult): JsonObject = + buildJsonObject { + put("count", value.count) + // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate + put("pubkey", value.approximate) + } + + override fun deserialize(decoder: Decoder): CountResult { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): CountResult = + CountResult( + count = jsonObject["count"]!!.jsonPrimitive.int, + approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt new file mode 100644 index 000000000..57d2d56ca --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt @@ -0,0 +1,111 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.utils.EventFactory +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object EventKSerializer : KSerializer { + private val emptyTagArray = emptyArray>() + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Event") { + element("id") + element("pubkey") + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + element("sig") + } + + override fun serialize( + encoder: Encoder, + value: Event, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(event: Event): JsonObject = + buildJsonObject { + put("id", event.id) + put("pubkey", event.pubKey) + put("created_at", event.createdAt) + put("kind", event.kind) + put("tags", TagArrayKSerializer.serializeToElement(event.tags)) + put("content", event.content) + put("sig", event.sig) + } + + override fun deserialize(decoder: Decoder): Event { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): Event { + var id: HexKey = "" + var pubKey: HexKey = "" + var createdAt: Long = 0 + var kind: Kind = 0 + var tags: TagArray = emptyTagArray + var content: String = "" + var sig: HexKey = "" + + for ((key, value) in jsonObject) { + when (key) { + "id" -> id = value.jsonPrimitive.content + "pubkey" -> pubKey = value.jsonPrimitive.content + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + "sig" -> sig = value.jsonPrimitive.content + } + } + + if (pubKey.isEmpty()) { + throw IllegalArgumentException("Event not found") + } + + return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt new file mode 100644 index 000000000..58ca13df3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt @@ -0,0 +1,87 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object EventTemplateKSerializer : KSerializer> { + private val emptyTagArray = emptyArray>() + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("EventTemplate") { + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + } + + override fun serialize( + encoder: Encoder, + value: EventTemplate, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("created_at", value.createdAt) + put("kind", value.kind) + put("tags", TagArrayKSerializer.serializeToElement(value.tags)) + put("content", value.content) + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): EventTemplate { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + var createdAt = 0L + var kind = 0 + var tags: TagArray = emptyTagArray + var content = "" + + for ((key, value) in jsonObject) { + when (key) { + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + } + } + + return EventTemplate(createdAt, kind, tags, content) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt new file mode 100644 index 000000000..318af177e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt @@ -0,0 +1,141 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +object FilterKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Filter") + + override fun serialize( + encoder: Encoder, + value: Filter, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(filter: Filter): JsonObject = + buildJsonObject { + filter.kinds?.let { kinds -> + put( + "kinds", + buildJsonArray { + for (k in kinds) add(JsonPrimitive(k)) + }, + ) + } + filter.ids?.let { ids -> + put( + "ids", + buildJsonArray { + for (id in ids) add(JsonPrimitive(id)) + }, + ) + } + filter.authors?.let { authors -> + put( + "authors", + buildJsonArray { + for (a in authors) add(JsonPrimitive(a)) + }, + ) + } + filter.tags?.let { tags -> + for ((key, values) in tags) { + put( + "#$key", + buildJsonArray { + for (v in values) add(JsonPrimitive(v)) + }, + ) + } + } + filter.tagsAll?.let { tagsAll -> + for ((key, values) in tagsAll) { + put( + "&$key", + buildJsonArray { + for (v in values) add(JsonPrimitive(v)) + }, + ) + } + } + filter.since?.let { put("since", it) } + filter.until?.let { put("until", it) } + filter.limit?.let { put("limit", it) } + filter.search?.let { put("search", it) } + } + + override fun deserialize(decoder: Decoder): Filter { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): Filter { + val tags = mutableMapOf>() + val tagsAll = mutableMapOf>() + + for ((key, value) in jsonObject) { + when { + key.startsWith("#") -> { + tags[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } + } + key.startsWith("&") -> { + tagsAll[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } + } + } + } + + return Filter( + ids = jsonObject["ids"]?.jsonArray?.mapNotNull { it.jsonPrimitive.content }, + authors = jsonObject["authors"]?.jsonArray?.mapNotNull { it.jsonPrimitive.content }, + kinds = jsonObject["kinds"]?.jsonArray?.mapNotNull { it.jsonPrimitive.intOrNull }, + tags = tags.ifEmpty { null }, + tagsAll = tagsAll.ifEmpty { null }, + since = jsonObject["since"]?.jsonPrimitive?.longOrNull, + until = jsonObject["until"]?.jsonPrimitive?.longOrNull, + limit = jsonObject["limit"]?.jsonPrimitive?.intOrNull, + search = jsonObject["search"]?.jsonPrimitive?.content, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt new file mode 100644 index 000000000..901bdf099 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -0,0 +1,102 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlinx.serialization.json.Json + +class KotlinSerializationMapper { + companion object { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + fun fromJson(jsonStr: String): Event = json.decodeFromString(EventKSerializer, jsonStr) + + fun fromJsonToMessage(jsonStr: String): Message = json.decodeFromString(MessageKSerializer, jsonStr) + + fun fromJsonToCommand(jsonStr: String): Command = json.decodeFromString(CommandKSerializer, jsonStr) + + fun fromJsonToTagArray(jsonStr: String): TagArray = json.decodeFromString(TagArrayKSerializer, jsonStr) + + fun fromJsonToRumor(jsonStr: String): Rumor = json.decodeFromString(RumorKSerializer, jsonStr) + + fun fromJsonToEventTemplate(jsonStr: String): EventTemplate = json.decodeFromString(EventTemplateKSerializer, jsonStr) + + fun toJson(event: Event): String = json.encodeToString(EventKSerializer, event) + + fun toJson(tags: TagArray): String = json.encodeToString(TagArrayKSerializer, tags) + + fun toJson(value: OptimizedSerializable): String = + when (value) { + is Event -> json.encodeToString(EventKSerializer, value) + is Filter -> json.encodeToString(FilterKSerializer, value) + is Rumor -> json.encodeToString(RumorKSerializer, value) + is EventTemplate<*> -> { + @Suppress("UNCHECKED_CAST") + json.encodeToString(EventTemplateKSerializer, value as EventTemplate) + } + is Message -> json.encodeToString(MessageKSerializer, value) + is Command -> json.encodeToString(CommandKSerializer, value) + is BunkerRequest -> json.encodeToString(BunkerRequestKSerializer, value) + is BunkerResponse -> json.encodeToString(BunkerResponseKSerializer, value) + is BunkerMessage -> json.encodeToString(BunkerMessageKSerializer, value) + else -> throw IllegalArgumentException("Unsupported type: ${value::class}") + } + + inline fun fromJsonTo(jsonStr: String): T { + val result: Any = + when (T::class) { + Event::class -> fromJson(jsonStr) + Filter::class -> json.decodeFromString(FilterKSerializer, jsonStr) + Rumor::class -> fromJsonToRumor(jsonStr) + EventTemplate::class -> fromJsonToEventTemplate(jsonStr) + Message::class -> fromJsonToMessage(jsonStr) + Command::class -> fromJsonToCommand(jsonStr) + BunkerRequest::class -> json.decodeFromString(BunkerRequestKSerializer, jsonStr) + BunkerResponse::class -> json.decodeFromString(BunkerResponseKSerializer, jsonStr) + BunkerMessage::class -> json.decodeFromString(BunkerMessageKSerializer, jsonStr) + Response::class -> json.decodeFromString(Nip47ResponseKSerializer, jsonStr) + Request::class -> json.decodeFromString(Nip47RequestKSerializer, jsonStr) + Notification::class -> json.decodeFromString(Nip47NotificationKSerializer, jsonStr) + else -> throw IllegalArgumentException("Unsupported type: ${T::class}") + } + @Suppress("UNCHECKED_CAST") + return result as T + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt new file mode 100644 index 000000000..f6e66da70 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -0,0 +1,136 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object MessageKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Message") + + override fun serialize( + encoder: Encoder, + value: Message, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + add(JsonPrimitive(value.label())) + when (value) { + is EventMessage -> { + add(JsonPrimitive(value.subId)) + add(EventKSerializer.serializeToElement(value.event)) + } + is NoticeMessage -> { + add(JsonPrimitive(value.message)) + } + is OkMessage -> { + add(JsonPrimitive(value.eventId)) + // Jackson writes success as a string, not boolean + add(JsonPrimitive(value.success.toString())) + if (value.message.isNotBlank()) { + add(JsonPrimitive(value.message)) + } + } + is AuthMessage -> { + add(JsonPrimitive(value.challenge)) + } + is NotifyMessage -> { + add(JsonPrimitive(value.message)) + } + is ClosedMessage -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.message)) + } + is CountMessage -> { + add(CountResultKSerializer.serializeToElement(value.result)) + } + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Message { + val jsonDecoder = decoder as JsonDecoder + val array = jsonDecoder.decodeJsonElement().jsonArray + val type = array[0].jsonPrimitive.content + + return when (type) { + EventMessage.LABEL -> { + val subId = array[1].jsonPrimitive.content + val event = EventKSerializer.deserializeFromElement(array[2].jsonObject) + EventMessage(subId, event) + } + EoseMessage.LABEL -> { + EoseMessage(array[1].jsonPrimitive.content) + } + NoticeMessage.LABEL -> { + NoticeMessage(array[1].jsonPrimitive.content) + } + OkMessage.LABEL -> { + OkMessage( + eventId = array[1].jsonPrimitive.content, + success = array[2].jsonPrimitive.boolean, + message = if (array.size > 3) array[3].jsonPrimitive.content else "", + ) + } + AuthMessage.LABEL -> { + AuthMessage(array[1].jsonPrimitive.content) + } + NotifyMessage.LABEL -> { + NotifyMessage(array[1].jsonPrimitive.content) + } + ClosedMessage.LABEL -> { + ClosedMessage( + subId = array[1].jsonPrimitive.content, + message = if (array.size > 2) array[2].jsonPrimitive.content else "", + ) + } + CountMessage.LABEL -> { + val queryId = array[1].jsonPrimitive.content + val result = CountResultKSerializer.deserializeFromElement(array[2].jsonObject) + CountMessage(queryId, result) + } + else -> throw IllegalArgumentException("Message $type is not supported") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt new file mode 100644 index 000000000..5ac1a412a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -0,0 +1,94 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedData +import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +object Nip47NotificationKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Notification") + + override fun serialize( + encoder: Encoder, + value: Notification, + ) { + throw UnsupportedOperationException("NIP-47 Notification serialization not supported") + } + + override fun deserialize(decoder: Decoder): Notification { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val notificationType = + jsonObject["notification_type"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + + return when (notificationType) { + NwcNotificationType.PAYMENT_RECEIVED -> { + PaymentReceivedNotification( + notification = + Nip47ResponseKSerializer.parseTransaction( + jsonObject["notification"]?.jsonObject, + ), + ) + } + NwcNotificationType.PAYMENT_SENT -> { + PaymentSentNotification( + notification = + Nip47ResponseKSerializer.parseTransaction( + jsonObject["notification"]?.jsonObject, + ), + ) + } + NwcNotificationType.HOLD_INVOICE_ACCEPTED -> { + val notifObj = jsonObject["notification"]?.jsonObject + HoldInvoiceAcceptedNotification( + notification = + notifObj?.let { + HoldInvoiceAcceptedData( + type = it["type"]?.jsonPrimitive?.content, + invoice = it["invoice"]?.jsonPrimitive?.content, + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + created_at = it["created_at"]?.jsonPrimitive?.longOrNull, + expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull, + settle_deadline = it["settle_deadline"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + else -> throw IllegalArgumentException("Unknown notification type: $notificationType") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt new file mode 100644 index 000000000..4bf4f67c6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt @@ -0,0 +1,241 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionParams +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsParams +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendParams +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageParams +import com.vitorpamplona.quartz.nip47WalletConnect.TlvRecord +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +object Nip47RequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Request") + + override fun serialize( + encoder: Encoder, + value: Request, + ) { + throw UnsupportedOperationException("NIP-47 Request serialization not supported") + } + + override fun deserialize(decoder: Decoder): Request { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val method = jsonObject["method"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + + return when (method) { + NwcMethod.PAY_INVOICE -> parsePayInvoice(jsonObject) + NwcMethod.PAY_KEYSEND -> parsePayKeysend(jsonObject) + NwcMethod.MAKE_INVOICE -> parseMakeInvoice(jsonObject) + NwcMethod.LOOKUP_INVOICE -> parseLookupInvoice(jsonObject) + NwcMethod.LIST_TRANSACTIONS -> parseListTransactions(jsonObject) + NwcMethod.GET_BALANCE -> GetBalanceMethod() + NwcMethod.GET_INFO -> GetInfoMethod() + NwcMethod.GET_BUDGET -> GetBudgetMethod() + NwcMethod.SIGN_MESSAGE -> parseSignMessage(jsonObject) + NwcMethod.CREATE_CONNECTION -> parseCreateConnection(jsonObject) + NwcMethod.MAKE_HOLD_INVOICE -> parseMakeHoldInvoice(jsonObject) + NwcMethod.CANCEL_HOLD_INVOICE -> parseCancelHoldInvoice(jsonObject) + NwcMethod.SETTLE_HOLD_INVOICE -> parseSettleHoldInvoice(jsonObject) + else -> throw IllegalArgumentException("Unknown NWC method: $method") + } + } + + private fun parsePayInvoice(json: JsonObject): PayInvoiceMethod { + val params = json["params"]?.jsonObject + return PayInvoiceMethod( + params?.let { + PayInvoiceParams( + invoice = it["invoice"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parsePayKeysend(json: JsonObject): PayKeysendMethod { + val params = json["params"]?.jsonObject + return PayKeysendMethod( + params?.let { + PayKeysendParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + pubkey = it["pubkey"]?.jsonPrimitive?.content, + preimage = it["preimage"]?.jsonPrimitive?.content, + tlv_records = + it["tlv_records"]?.jsonArray?.map { record -> + val obj = record.jsonObject + TlvRecord( + type = obj["type"]?.jsonPrimitive?.longOrNull, + value = obj["value"]?.jsonPrimitive?.content, + ) + }, + ) + }, + ) + } + + private fun parseMakeInvoice(json: JsonObject): MakeInvoiceMethod { + val params = json["params"]?.jsonObject + return MakeInvoiceMethod( + params?.let { + MakeInvoiceParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + description = it["description"]?.jsonPrimitive?.content, + description_hash = it["description_hash"]?.jsonPrimitive?.content, + expiry = it["expiry"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseLookupInvoice(json: JsonObject): LookupInvoiceMethod { + val params = json["params"]?.jsonObject + return LookupInvoiceMethod( + params?.let { + LookupInvoiceParams( + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + invoice = it["invoice"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseListTransactions(json: JsonObject): ListTransactionsMethod { + val params = json["params"]?.jsonObject + return ListTransactionsMethod( + params?.let { + ListTransactionsParams( + from = it["from"]?.jsonPrimitive?.longOrNull, + until = it["until"]?.jsonPrimitive?.longOrNull, + limit = it["limit"]?.jsonPrimitive?.intOrNull, + offset = it["offset"]?.jsonPrimitive?.intOrNull, + unpaid = it["unpaid"]?.jsonPrimitive?.booleanOrNull, + unpaid_outgoing = it["unpaid_outgoing"]?.jsonPrimitive?.booleanOrNull, + unpaid_incoming = it["unpaid_incoming"]?.jsonPrimitive?.booleanOrNull, + type = it["type"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseSignMessage(json: JsonObject): SignMessageMethod { + val params = json["params"]?.jsonObject + return SignMessageMethod( + params?.let { + SignMessageParams( + message = it["message"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseCreateConnection(json: JsonObject): CreateConnectionMethod { + val params = json["params"]?.jsonObject + return CreateConnectionMethod( + params?.let { + CreateConnectionParams( + pubkey = it["pubkey"]?.jsonPrimitive?.content, + name = it["name"]?.jsonPrimitive?.content, + request_methods = it["request_methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content }, + notification_types = it["notification_types"]?.jsonArray?.map { n -> n.jsonPrimitive.content }, + max_amount = it["max_amount"]?.jsonPrimitive?.longOrNull, + budget_renewal = it["budget_renewal"]?.jsonPrimitive?.content, + expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull, + isolated = it["isolated"]?.jsonPrimitive?.booleanOrNull, + ) + }, + ) + } + + private fun parseMakeHoldInvoice(json: JsonObject): MakeHoldInvoiceMethod { + val params = json["params"]?.jsonObject + return MakeHoldInvoiceMethod( + params?.let { + MakeHoldInvoiceParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + description = it["description"]?.jsonPrimitive?.content, + description_hash = it["description_hash"]?.jsonPrimitive?.content, + expiry = it["expiry"]?.jsonPrimitive?.longOrNull, + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + min_cltv_expiry_delta = it["min_cltv_expiry_delta"]?.jsonPrimitive?.intOrNull, + ) + }, + ) + } + + private fun parseCancelHoldInvoice(json: JsonObject): CancelHoldInvoiceMethod { + val params = json["params"]?.jsonObject + return CancelHoldInvoiceMethod( + params?.let { + CancelHoldInvoiceParams( + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseSettleHoldInvoice(json: JsonObject): SettleHoldInvoiceMethod { + val params = json["params"]?.jsonObject + return SettleHoldInvoiceMethod( + params?.let { + SettleHoldInvoiceParams( + preimage = it["preimage"]?.jsonPrimitive?.content, + ) + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt new file mode 100644 index 000000000..b01c098a5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -0,0 +1,263 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +object Nip47ResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Response") + + override fun serialize( + encoder: Encoder, + value: Response, + ) { + throw UnsupportedOperationException("NIP-47 Response serialization not supported") + } + + override fun deserialize(decoder: Decoder): Response { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val resultType = jsonObject["result_type"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + val hasError = jsonObject["error"]?.let { it !is JsonNull } ?: false + val hasResult = jsonObject["result"]?.let { it !is JsonNull } ?: false + + if (hasError) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> parsePayInvoiceError(jsonObject) + else -> { + val error = jsonObject["error"]?.jsonObject?.let { parseNwcError(it) } + NwcErrorResponse(resultType ?: "", error) + } + } + } + + if (hasResult || resultType != null) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> parsePayInvoiceSuccess(jsonObject) + NwcMethod.PAY_KEYSEND -> parsePayKeysendSuccess(jsonObject) + NwcMethod.MAKE_INVOICE -> MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + NwcMethod.LOOKUP_INVOICE -> LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + NwcMethod.LIST_TRANSACTIONS -> parseListTransactionsSuccess(jsonObject) + NwcMethod.GET_BALANCE -> parseGetBalanceSuccess(jsonObject) + NwcMethod.GET_INFO -> parseGetInfoSuccess(jsonObject) + NwcMethod.GET_BUDGET -> parseGetBudgetSuccess(jsonObject) + NwcMethod.SIGN_MESSAGE -> parseSignMessageSuccess(jsonObject) + NwcMethod.CREATE_CONNECTION -> parseCreateConnectionSuccess(jsonObject) + NwcMethod.MAKE_HOLD_INVOICE -> MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + NwcMethod.CANCEL_HOLD_INVOICE -> CancelHoldInvoiceSuccessResponse() + NwcMethod.SETTLE_HOLD_INVOICE -> SettleHoldInvoiceSuccessResponse() + else -> { + // backward compatibility: guess by result content + val resultObj = jsonObject["result"]?.jsonObject + if (resultObj?.containsKey("preimage") == true) { + return parsePayInvoiceSuccess(jsonObject) + } + throw IllegalArgumentException("Unknown NWC response type: $resultType") + } + } + } + + throw IllegalArgumentException("NWC response has neither result nor error") + } + + private fun parseNwcError(obj: JsonObject): NwcError { + val code = obj["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } + } + return NwcError(code, obj["message"]?.jsonPrimitive?.content) + } + + fun parseTransaction(obj: JsonObject?): NwcTransaction? { + if (obj == null) return null + return NwcTransaction( + type = obj["type"]?.jsonPrimitive?.content, + state = obj["state"]?.jsonPrimitive?.content, + invoice = obj["invoice"]?.jsonPrimitive?.content, + description = obj["description"]?.jsonPrimitive?.content, + description_hash = obj["description_hash"]?.jsonPrimitive?.content, + preimage = obj["preimage"]?.jsonPrimitive?.content, + payment_hash = obj["payment_hash"]?.jsonPrimitive?.content, + amount = obj["amount"]?.jsonPrimitive?.longOrNull, + fees_paid = obj["fees_paid"]?.jsonPrimitive?.longOrNull, + created_at = obj["created_at"]?.jsonPrimitive?.longOrNull, + expires_at = obj["expires_at"]?.jsonPrimitive?.longOrNull, + settled_at = obj["settled_at"]?.jsonPrimitive?.longOrNull, + settle_deadline = obj["settle_deadline"]?.jsonPrimitive?.longOrNull, + ) + } + + private fun parsePayInvoiceSuccess(json: JsonObject): PayInvoiceSuccessResponse { + val result = json["result"]?.jsonObject + return PayInvoiceSuccessResponse( + result?.let { + PayInvoiceSuccessResponse.PayInvoiceResultParams( + preimage = it["preimage"]?.jsonPrimitive?.content, + fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parsePayInvoiceError(json: JsonObject): PayInvoiceErrorResponse { + val error = json["error"]?.jsonObject + return PayInvoiceErrorResponse( + error?.let { + PayInvoiceErrorResponse.PayInvoiceErrorParams( + code = it["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } + }, + message = it["message"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse { + val result = json["result"]?.jsonObject + return PayKeysendSuccessResponse( + result?.let { + PayKeysendSuccessResponse.PayKeysendResult( + preimage = it["preimage"]?.jsonPrimitive?.content, + fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseListTransactionsSuccess(json: JsonObject): ListTransactionsSuccessResponse { + val result = json["result"]?.jsonObject + return ListTransactionsSuccessResponse( + result?.let { + ListTransactionsSuccessResponse.ListTransactionsResult( + transactions = it["transactions"]?.jsonArray?.map { t -> parseTransaction(t.jsonObject) }, + total_count = it["total_count"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseGetBalanceSuccess(json: JsonObject): GetBalanceSuccessResponse { + val result = json["result"]?.jsonObject + return GetBalanceSuccessResponse( + result?.let { + GetBalanceSuccessResponse.GetBalanceResult( + balance = it["balance"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseGetInfoSuccess(json: JsonObject): GetInfoSuccessResponse { + val result = json["result"]?.jsonObject + return GetInfoSuccessResponse( + result?.let { + GetInfoSuccessResponse.GetInfoResult( + alias = it["alias"]?.jsonPrimitive?.content, + color = it["color"]?.jsonPrimitive?.content, + pubkey = it["pubkey"]?.jsonPrimitive?.content, + network = it["network"]?.jsonPrimitive?.content, + block_height = it["block_height"]?.jsonPrimitive?.longOrNull, + block_hash = it["block_hash"]?.jsonPrimitive?.content, + methods = it["methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content }, + notifications = it["notifications"]?.jsonArray?.map { n -> n.jsonPrimitive.content }, + lud16 = it["lud16"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseGetBudgetSuccess(json: JsonObject): GetBudgetSuccessResponse { + val result = json["result"]?.jsonObject + return GetBudgetSuccessResponse( + result?.let { + GetBudgetSuccessResponse.GetBudgetResult( + used_budget = it["used_budget"]?.jsonPrimitive?.longOrNull, + total_budget = it["total_budget"]?.jsonPrimitive?.longOrNull, + renews_at = it["renews_at"]?.jsonPrimitive?.longOrNull, + renewal_period = it["renewal_period"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseSignMessageSuccess(json: JsonObject): SignMessageSuccessResponse { + val result = json["result"]?.jsonObject + return SignMessageSuccessResponse( + result?.let { + SignMessageSuccessResponse.SignMessageResult( + message = it["message"]?.jsonPrimitive?.content, + signature = it["signature"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseCreateConnectionSuccess(json: JsonObject): CreateConnectionSuccessResponse { + val result = json["result"]?.jsonObject + return CreateConnectionSuccessResponse( + result?.let { + CreateConnectionSuccessResponse.CreateConnectionResult( + wallet_pubkey = it["wallet_pubkey"]?.jsonPrimitive?.content, + ) + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt new file mode 100644 index 000000000..400eb9017 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt @@ -0,0 +1,93 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object RumorKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Rumor") { + element("id") + element("pubkey") + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + } + + override fun serialize( + encoder: Encoder, + value: Rumor, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + value.id?.let { put("id", it) } + value.pubKey?.let { put("pubkey", it) } + value.createdAt?.let { put("created_at", it) } + value.kind?.let { put("kind", it) } + value.tags?.let { put("tags", TagArrayKSerializer.serializeToElement(it)) } + value.content?.let { put("content", it) } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Rumor { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + var id: HexKey? = null + var pubKey: HexKey? = null + var createdAt: Long? = null + var kind: Int? = null + var tags: TagArray? = null + var content: String? = null + + for ((key, value) in jsonObject) { + when (key) { + "id" -> id = value.jsonPrimitive.content + "pubkey" -> pubKey = value.jsonPrimitive.content + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + } + } + + return Rumor(id, pubKey, createdAt, kind, tags, content) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt new file mode 100644 index 000000000..3e6562808 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt @@ -0,0 +1,101 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +object TagArrayKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + ListSerializer(ListSerializer(String.serializer())).descriptor + + override fun serialize( + encoder: Encoder, + value: TagArray, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + for (tag in value) { + add( + buildJsonArray { + for (s in tag) { + add(JsonPrimitive(s)) + } + }, + ) + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): TagArray { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement()) + } + + fun deserializeFromElement(element: JsonElement): TagArray { + val array = element.jsonArray + val outerList = ArrayList>(array.size) + for (inner in array) { + val innerArray = inner.jsonArray + val innerList = ArrayList(innerArray.size.coerceAtLeast(5)) + for (s in innerArray) { + if (s is JsonNull) { + innerList.add("") + } else { + innerList.add(s.jsonPrimitive.content) + } + } + @Suppress("UNCHECKED_CAST") + outerList.add(innerList.toArray(arrayOfNulls(innerList.size)) as Array) + } + @Suppress("UNCHECKED_CAST") + return outerList.toArray(arrayOfNulls>(outerList.size)) as TagArray + } + + fun serializeToElement(value: TagArray): JsonArray = + buildJsonArray { + for (tag in value) { + add( + buildJsonArray { + for (s in tag) { + add(JsonPrimitive(s)) + } + }, + ) + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt new file mode 100644 index 000000000..2520242a0 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -0,0 +1,740 @@ +/* + * 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.quartz.nip01Core.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KotlinSerializationMapperTest { + val tags = + arrayOf( + arrayOf("title", "Retro Computer Fans"), + arrayOf("d", "xmbspe8rddsq"), + arrayOf("image", "https://blog.johnnovak.net/2022/04/15/achieving-period-correct-graphics-in-personal-computer-emulators-part-1-the-amiga/img/dream-setup.jpg"), + arrayOf("p", "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da"), + arrayOf("p", "9a9a4aa0e43e57873380ab22e8a3df12f3c4cf5bb3a804c6e3fed0069a6e2740"), + arrayOf("p", "4f5dd82517b11088ce00f23d99f06fe8f3e2e45ecf47bc9c2f90f34d5c6f7382"), + arrayOf("p", "ac92102a2ecb873c488e0125354ef5a97075a16198668c360eda050007ed42cd"), + arrayOf("p", "47f54409a4620eb35208a3bc1b53555bf3d0656b246bf0471a93208e20672f6f"), + arrayOf("p", "2624911545afb7a2b440cf10f5c69308afa33aae26fca664d8c94623dc0f1baf"), + arrayOf("p", "6641f26f5c59f7010dbe3e42e4593398e27c087497cb7d20e0e7633a17e48a94"), + arrayOf("description", "Retro computer fans and enthusiasts "), + ) + + val followCard = + FollowListEvent( + id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", + pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", + createdAt = 1761736286, + tags = tags, + content = "", + sig = "3aa388edafad151e81cb0228fe04e115dbbcaa851c666bfe3c8740b6cd99575f0fc3ba2d47acda86f7626564a05e9dbc05ef452a7bd0ac00f828dbad0e1bae6c", + ) + + val followCardRumor = + Rumor( + id = followCard.id, + pubKey = followCard.pubKey, + createdAt = followCard.createdAt, + kind = followCard.kind, + tags = followCard.tags, + content = followCard.content, + ) + + val followCardTemplate = + EventTemplate( + createdAt = followCard.createdAt, + kind = followCard.kind, + tags = followCard.tags, + content = followCard.content, + ) + + // ========================================================================= + // TagArray Tests + // ========================================================================= + + @Test + fun serializeTagArray_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(tags) + val kotlinJson = KotlinSerializationMapper.toJson(tags) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeTagArray_matchesJackson() { + val json = JacksonMapper.toJson(tags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized[index]) + } + } + + @Test + fun tagArrayRoundTrip() { + val json = KotlinSerializationMapper.toJson(tags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(tags.size, deserialized.size) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized[index]) + } + } + + @Test + fun tagArrayWithNullValues() { + val json = """[["key",null,"value"]]""" + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(1, deserialized.size) + assertEquals("key", deserialized[0][0]) + assertEquals("", deserialized[0][1]) // null -> "" + assertEquals("value", deserialized[0][2]) + } + + @Test + fun emptyTagArray() { + val json = "[]" + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(0, deserialized.size) + } + + // ========================================================================= + // Event Tests + // ========================================================================= + + @Test + fun serializeEvent_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCard) + val kotlinJson = KotlinSerializationMapper.toJson(followCard) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEvent_matchesJackson() { + val json = JacksonMapper.toJson(followCard) + val deserialized = KotlinSerializationMapper.fromJson(json) + + assertEquals(followCard.id, deserialized.id) + assertEquals(followCard.pubKey, deserialized.pubKey) + assertEquals(followCard.createdAt, deserialized.createdAt) + assertEquals(followCard.kind, deserialized.kind) + assertEquals(followCard.content, deserialized.content) + assertEquals(followCard.sig, deserialized.sig) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized.tags[index]) + } + } + + @Test + fun eventRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCard) + val deserialized = KotlinSerializationMapper.fromJson(json) + + assertEquals(followCard.id, deserialized.id) + assertEquals(followCard.kind, deserialized.kind) + assertEquals(followCard.createdAt, deserialized.createdAt) + assertEquals(followCard.pubKey, deserialized.pubKey) + assertEquals(followCard.content, deserialized.content) + assertEquals(followCard.sig, deserialized.sig) + } + + @Test + fun deserializeEventWithUnknownFields() { + val json = + """{"id":"abc123","pubkey":"def456","created_at":12345,"kind":1,"tags":[],"content":"test","sig":"sig123","unknown_field":"ignored"}""" + // Should not throw with unknown fields, should be ignored + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals("abc123", deserialized.id) + assertEquals("def456", deserialized.pubKey) + assertEquals("test", deserialized.content) + } + + @Test + fun deserializeEventWithSpecialCharactersInContent() { + val content = "Hello \"world\" \n\ttab\\backslash" + val event = + FollowListEvent( + id = "abc", + pubKey = "def", + createdAt = 1000, + tags = emptyArray(), + content = content, + sig = "sig", + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals(content, deserialized.content) + } + + @Test + fun crossDeserializationEvent() { + // Serialize with Jackson, deserialize with Kotlin Serialization + val jacksonJson = JacksonMapper.toJson(followCard) + val kotlinDeserialized = KotlinSerializationMapper.fromJson(jacksonJson) + + assertEquals(followCard.id, kotlinDeserialized.id) + assertEquals(followCard.pubKey, kotlinDeserialized.pubKey) + assertEquals(followCard.kind, kotlinDeserialized.kind) + + // Serialize with Kotlin Serialization, deserialize with Jackson + val kotlinJson = KotlinSerializationMapper.toJson(followCard) + val jacksonDeserialized = JacksonMapper.fromJson(kotlinJson) + + assertEquals(followCard.id, jacksonDeserialized.id) + assertEquals(followCard.pubKey, jacksonDeserialized.pubKey) + assertEquals(followCard.kind, jacksonDeserialized.kind) + } + + // ========================================================================= + // Rumor Tests + // ========================================================================= + + @Test + fun serializeRumor_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCardRumor) + val kotlinJson = KotlinSerializationMapper.toJson(followCardRumor) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeRumor_matchesJackson() { + val json = JacksonMapper.toJson(followCardRumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals(followCardRumor.id, deserialized.id) + assertEquals(followCardRumor.pubKey, deserialized.pubKey) + assertEquals(followCardRumor.createdAt, deserialized.createdAt) + assertEquals(followCardRumor.kind, deserialized.kind) + assertEquals(followCardRumor.content, deserialized.content) + } + + @Test + fun rumorRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCardRumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals(followCardRumor.id, deserialized.id) + assertEquals(followCardRumor.kind, deserialized.kind) + } + + @Test + fun rumorWithNullFields() { + val rumor = Rumor(null, null, null, null, null, null) + val json = KotlinSerializationMapper.toJson(rumor) + assertEquals("{}", json) + + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + assertNull(deserialized.id) + assertNull(deserialized.pubKey) + assertNull(deserialized.createdAt) + assertNull(deserialized.kind) + assertNull(deserialized.tags) + assertNull(deserialized.content) + } + + @Test + fun rumorPartialFields() { + val rumor = Rumor("abc", null, 1000, 1, null, "content") + val json = KotlinSerializationMapper.toJson(rumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals("abc", deserialized.id) + assertNull(deserialized.pubKey) + assertEquals(1000L, deserialized.createdAt) + assertEquals(1, deserialized.kind) + assertNull(deserialized.tags) + assertEquals("content", deserialized.content) + } + + // ========================================================================= + // EventTemplate Tests + // ========================================================================= + + @Test + fun serializeTemplate_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCardTemplate) + val kotlinJson = KotlinSerializationMapper.toJson(followCardTemplate) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeTemplate_matchesJackson() { + val json = JacksonMapper.toJson(followCardTemplate) + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + + assertEquals(followCardTemplate.kind, deserialized.kind) + assertEquals(followCardTemplate.createdAt, deserialized.createdAt) + assertEquals(followCardTemplate.content, deserialized.content) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized.tags[index]) + } + } + + @Test + fun templateRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCardTemplate) + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + + assertEquals(followCardTemplate.kind, deserialized.kind) + assertEquals(followCardTemplate.createdAt, deserialized.createdAt) + assertEquals(followCardTemplate.content, deserialized.content) + } + + @Test + fun templateWithDifferentFieldOrder() { + // Fields in different order than expected + val json = """{"kind":1,"content":"test","created_at":1234,"tags":[]}""" + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + assertEquals(1, deserialized.kind) + assertEquals("test", deserialized.content) + assertEquals(1234L, deserialized.createdAt) + } + + // ========================================================================= + // Filter Tests + // ========================================================================= + + @Test + fun emptyFilter_matchesJackson() { + val filter = Filter() + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun filterWithAllFields_matchesJackson() { + val filter = + Filter( + ids = listOf("abc123" + "0".repeat(58)), + authors = listOf("def456" + "0".repeat(58)), + kinds = listOf(1, 2, 3), + tags = mapOf("p" to listOf("3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da")), + tagsAll = mapOf("p" to listOf("3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da")), + since = 1000L, + until = 2000L, + limit = 50, + search = "hello", + ) + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun filterRoundTrip() { + val expectedTagValue = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da" + val filter = + Filter( + tags = mapOf("p" to listOf(expectedTagValue)), + tagsAll = mapOf("p" to listOf(expectedTagValue)), + ) + val json = KotlinSerializationMapper.toJson(filter) + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + + assertEquals(true, deserialized.tags?.keys?.contains("p")) + assertEquals(listOf(expectedTagValue), deserialized.tags?.get("p")) + assertEquals(true, deserialized.tagsAll?.keys?.contains("p")) + assertEquals(listOf(expectedTagValue), deserialized.tagsAll?.get("p")) + } + + @Test + fun deserializeEmptyFilter() { + val json = Filter().toJson() + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertNull(deserialized.ids) + } + + @Test + fun crossDeserializationFilter() { + val expectedTagValue = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da" + val filter = + Filter( + tags = mapOf("p" to listOf(expectedTagValue)), + tagsAll = mapOf("p" to listOf(expectedTagValue)), + ) + + // Jackson serialized -> Kotlin deserialized + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonTo(jacksonJson) + assertEquals(listOf(expectedTagValue), kotlinDeserialized.tags?.get("p")) + assertEquals(listOf(expectedTagValue), kotlinDeserialized.tagsAll?.get("p")) + + // Kotlin serialized -> Jackson deserialized + val kotlinJson = KotlinSerializationMapper.toJson(filter) + val jacksonDeserialized = JacksonMapper.fromJsonTo(kotlinJson) + assertEquals(listOf(expectedTagValue), jacksonDeserialized.tags?.get("p")) + assertEquals(listOf(expectedTagValue), jacksonDeserialized.tagsAll?.get("p")) + } + + // ========================================================================= + // Message Tests + // ========================================================================= + + @Test + fun serializeEventMessage_matchesJackson() { + val msg = EventMessage("sub1", followCard) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEventMessage() { + val msg = EventMessage("sub1", followCard) + val json = KotlinSerializationMapper.toJson(msg) + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + + assertTrue(deserialized is EventMessage) + assertEquals("sub1", deserialized.subId) + assertEquals(followCard.id, deserialized.event.id) + } + + @Test + fun serializeNoticeMessage_matchesJackson() { + val msg = NoticeMessage("something went wrong") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeOkMessage_matchesJackson() { + val msg = OkMessage("abc123", true, "success") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeOkMessage() { + val msg = OkMessage("abc123", false, "rate limited") + val json = KotlinSerializationMapper.toJson(msg) + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + + assertTrue(deserialized is OkMessage) + assertEquals("abc123", deserialized.eventId) + assertEquals(false, deserialized.success) + assertEquals("rate limited", deserialized.message) + } + + @Test + fun serializeAuthMessage_matchesJackson() { + val msg = AuthMessage("challenge123") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeNotifyMessage_matchesJackson() { + val msg = NotifyMessage("notification text") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeClosedMessage_matchesJackson() { + val msg = ClosedMessage("sub1", "subscription closed") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEoseMessage() { + val json = """["EOSE","sub123"]""" + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(deserialized is EoseMessage) + assertEquals("sub123", (deserialized as EoseMessage).subId) + } + + @Test + fun serializeCountMessage_matchesJackson() { + val msg = CountMessage("q1", CountResult(42, false)) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun crossDeserializationMessages() { + val messages = + listOf( + NoticeMessage("test"), + AuthMessage("challenge"), + NotifyMessage("notify"), + ClosedMessage("sub1", "reason"), + ) + + for (msg in messages) { + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson) + assertEquals(msg.label(), kotlinDeserialized.label()) + + val kotlinJson = KotlinSerializationMapper.toJson(msg) + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson) + assertEquals(msg.label(), jacksonDeserialized.label()) + } + } + + // ========================================================================= + // Command Tests + // ========================================================================= + + @Test + fun serializeReqCmd_matchesJackson() { + val filter = + Filter( + kinds = listOf(1), + limit = 10, + ) + val cmd = ReqCmd("sub1", listOf(filter)) + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeReqCmd() { + val filter = Filter(kinds = listOf(1), limit = 10) + val cmd = ReqCmd("sub1", listOf(filter)) + val json = KotlinSerializationMapper.toJson(cmd) + val deserialized = KotlinSerializationMapper.fromJsonToCommand(json) + + assertTrue(deserialized is ReqCmd) + assertEquals("sub1", deserialized.subId) + assertEquals(1, deserialized.filters.size) + assertEquals(listOf(1), deserialized.filters[0].kinds) + assertEquals(10, deserialized.filters[0].limit) + } + + @Test + fun serializeEventCmd_matchesJackson() { + val cmd = EventCmd(followCard) + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeCloseCmd_matchesJackson() { + val cmd = CloseCmd("sub1") + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun crossDeserializationCommands() { + val cmd = CloseCmd("sub1") + + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToCommand(jacksonJson) + assertTrue(kotlinDeserialized is CloseCmd) + assertEquals("sub1", (kotlinDeserialized as CloseCmd).subId) + + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + val jacksonDeserialized = JacksonMapper.fromJsonToCommand(kotlinJson) + assertTrue(jacksonDeserialized is CloseCmd) + assertEquals("sub1", (jacksonDeserialized as CloseCmd).subId) + } + + // ========================================================================= + // BunkerRequest Tests + // ========================================================================= + + @Test + fun serializeBunkerRequest_matchesJackson() { + val req = BunkerRequest("id1", "connect", arrayOf("pubkey", "secret")) + val jacksonJson = JacksonMapper.toJson(req) + val kotlinJson = KotlinSerializationMapper.toJson(req) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeBunkerRequest() { + val json = """{"id":"id1","method":"ping","params":[]}""" + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertEquals("id1", deserialized.id) + assertEquals("ping", deserialized.method) + } + + @Test + fun crossDeserializationBunkerRequest() { + val req = BunkerRequest("id1", "sign_event", arrayOf("eventJson")) + val jacksonJson = JacksonMapper.toJson(req) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonTo(jacksonJson) + assertEquals(req.id, kotlinDeserialized.id) + assertEquals(req.method, kotlinDeserialized.method) + assertContentEquals(req.params, kotlinDeserialized.params) + + val kotlinJson = KotlinSerializationMapper.toJson(req) + val jacksonDeserialized = JacksonMapper.fromJsonTo(kotlinJson) + assertEquals(req.id, jacksonDeserialized.id) + assertEquals(req.method, jacksonDeserialized.method) + assertContentEquals(req.params, jacksonDeserialized.params) + } + + // ========================================================================= + // BunkerResponse Tests + // ========================================================================= + + @Test + fun serializeBunkerResponse_matchesJackson() { + val resp = BunkerResponse("id1", "ok", null) + val jacksonJson = JacksonMapper.toJson(resp) + val kotlinJson = KotlinSerializationMapper.toJson(resp) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeBunkerResponseWithError_matchesJackson() { + val resp = BunkerResponse("id1", null, "something went wrong") + val jacksonJson = JacksonMapper.toJson(resp) + val kotlinJson = KotlinSerializationMapper.toJson(resp) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeBunkerResponse() { + val json = """{"id":"id1","result":"pong"}""" + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertEquals("id1", deserialized.id) + assertNotNull(deserialized.result) + } + + // ========================================================================= + // OptimizedSerializable toJson dispatch Tests + // ========================================================================= + + @Test + fun toJsonDispatchForFilter() { + val filter = Filter(kinds = listOf(1)) + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun toJsonDispatchForRumor() { + val jacksonJson = JacksonMapper.toJson(followCardRumor) + val kotlinJson = KotlinSerializationMapper.toJson(followCardRumor) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun toJsonDispatchForEventTemplate() { + val jacksonJson = JacksonMapper.toJson(followCardTemplate) + val kotlinJson = KotlinSerializationMapper.toJson(followCardTemplate) + assertEquals(jacksonJson, kotlinJson) + } + + // ========================================================================= + // Edge Cases + // ========================================================================= + + @Test + fun emptyContentEvent() { + val event = + FollowListEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 0, + tags = emptyArray(), + content = "", + sig = "c".repeat(64), + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals("", deserialized.content) + assertEquals(0, deserialized.tags.size) + } + + @Test + fun largeTagArray() { + val largeTags = Array(100) { i -> arrayOf("p", "key$i") } + val json = KotlinSerializationMapper.toJson(largeTags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(100, deserialized.size) + assertEquals("key99", deserialized[99][1]) + } + + @Test + fun eventWithUnicodeContent() { + val content = "Hello \uD83D\uDE00 world \u00E9\u00E8\u00EA" + val event = + FollowListEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1000, + tags = emptyArray(), + content = content, + sig = "c".repeat(64), + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals(content, deserialized.content) + } + + @Test + fun filterWithMultipleTagTypes() { + val filter = + Filter( + tags = + mapOf( + "p" to listOf("pubkey1", "pubkey2"), + "e" to listOf("eventid1"), + "t" to listOf("nostr", "bitcoin"), + ), + ) + val json = KotlinSerializationMapper.toJson(filter) + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + + assertEquals(3, deserialized.tags?.size) + assertEquals(listOf("pubkey1", "pubkey2"), deserialized.tags?.get("p")) + assertEquals(listOf("eventid1"), deserialized.tags?.get("e")) + assertEquals(listOf("nostr", "bitcoin"), deserialized.tags?.get("t")) + } +} From 3bea2f795cee8d658e89ee3983cea0c683fe1fd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 19:55:19 +0000 Subject: [PATCH 057/130] refactor: Move KSerializers to their respective NIP packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bunker serializers → nip46RemoteSigner/kotlinSerialization/ - NIP-47 serializers → nip47WalletConnect/kotlinSerialization/ - Rumor serializer → nip59Giftwrap/rumors/kotlinSerialization/ Mirrors the existing Jackson serializer directory structure which uses a jackson/ subpackage within each NIP package. https://claude.ai/code/session_01QBc4Pb7a6m4TfLZSJKVdjw --- .../kotlinSerialization/KotlinSerializationMapper.kt | 7 +++++++ .../kotlinSerialization/BunkerMessageKSerializer.kt | 2 +- .../kotlinSerialization/BunkerRequestKSerializer.kt | 2 +- .../kotlinSerialization/BunkerResponseKSerializer.kt | 2 +- .../kotlinSerialization/Nip47NotificationKSerializer.kt | 2 +- .../kotlinSerialization/Nip47RequestKSerializer.kt | 2 +- .../kotlinSerialization/Nip47ResponseKSerializer.kt | 2 +- .../rumors}/kotlinSerialization/RumorKSerializer.kt | 4 +++- 8 files changed, 16 insertions(+), 7 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip01Core => nip46RemoteSigner}/kotlinSerialization/BunkerMessageKSerializer.kt (98%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip01Core => nip46RemoteSigner}/kotlinSerialization/BunkerRequestKSerializer.kt (98%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip01Core => nip46RemoteSigner}/kotlinSerialization/BunkerResponseKSerializer.kt (98%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip01Core => nip47WalletConnect}/kotlinSerialization/Nip47NotificationKSerializer.kt (98%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip01Core => nip47WalletConnect}/kotlinSerialization/Nip47RequestKSerializer.kt (99%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip01Core => nip47WalletConnect}/kotlinSerialization/Nip47ResponseKSerializer.kt (99%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip01Core => nip59Giftwrap/rumors}/kotlinSerialization/RumorKSerializer.kt (96%) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt index 901bdf099..086ed8951 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -30,10 +30,17 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerMessageKSerializer +import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerRequestKSerializer +import com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization.BunkerResponseKSerializer import com.vitorpamplona.quartz.nip47WalletConnect.Notification import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47NotificationKSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47RequestKSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.Nip47ResponseKSerializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization.RumorKSerializer import kotlinx.serialization.json.Json class KotlinSerializationMapper { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt index 157be1b95..97502f068 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt @@ -18,7 +18,7 @@ * 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.quartz.nip01Core.kotlinSerialization +package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt index 3d748bfd0..7797cf337 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerRequestKSerializer.kt @@ -18,7 +18,7 @@ * 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.quartz.nip01Core.kotlinSerialization +package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt index bf4cbbaf6..1127255aa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt @@ -18,7 +18,7 @@ * 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.quartz.nip01Core.kotlinSerialization +package com.vitorpamplona.quartz.nip46RemoteSigner.kotlinSerialization import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt index 5ac1a412a..8a105e1cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -18,7 +18,7 @@ * 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.quartz.nip01Core.kotlinSerialization +package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedData import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index 4bf4f67c6..0996ce75a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -18,7 +18,7 @@ * 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.quartz.nip01Core.kotlinSerialization +package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceParams diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index b01c098a5..da7dfadc7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -18,7 +18,7 @@ * 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.quartz.nip01Core.kotlinSerialization +package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt index 400eb9017..a8f4d5956 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt @@ -18,7 +18,9 @@ * 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.quartz.nip01Core.kotlinSerialization +package com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization + +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.TagArrayKSerializer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray From d10687d6fea7a8d0307a65f1cccc3bcdd4689485 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 19:58:40 +0000 Subject: [PATCH 058/130] feat: Implement iOS OptimizedJsonMapper using KotlinSerializationMapper Replace all TODO stubs in the iOS actual implementation with delegation to KotlinSerializationMapper. Wraps SerializationException into IllegalArgumentException to match the jvmAndroid error handling pattern. https://claude.ai/code/session_01QBc4Pb7a6m4TfLZSJKVdjw --- .../nip01Core/core/OptimizedJsonMapper.ios.kt | 57 +++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt index d77753912..05e0bbbc0 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.ios.kt @@ -20,29 +20,66 @@ */ package com.vitorpamplona.quartz.nip01Core.core +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlinx.serialization.SerializationException actual object OptimizedJsonMapper { - actual fun fromJson(json: String): Event = TODO("Not yet implemented") + actual fun fromJson(json: String): Event = + try { + KotlinSerializationMapper.fromJson(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun toJson(event: Event): String = TODO("Not yet implemented") + actual fun toJson(event: Event): String = KotlinSerializationMapper.toJson(event) - actual fun fromJsonToMessage(json: String): Message = TODO("Not yet implemented") + actual fun fromJsonToMessage(json: String): Message = + try { + KotlinSerializationMapper.fromJsonToMessage(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun fromJsonToCommand(json: String): Command = TODO("Not yet implemented") + actual fun fromJsonToCommand(json: String): Command = + try { + KotlinSerializationMapper.fromJsonToCommand(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun fromJsonToTagArray(json: String): Array> = TODO("Not yet implemented") + actual fun fromJsonToTagArray(json: String): Array> = + try { + KotlinSerializationMapper.fromJsonToTagArray(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun fromJsonToEventTemplate(json: String): EventTemplate = TODO("Not yet implemented") + actual fun fromJsonToEventTemplate(json: String): EventTemplate = + try { + KotlinSerializationMapper.fromJsonToEventTemplate(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun fromJsonToRumor(json: String): Rumor = TODO("Not yet implemented") + actual fun fromJsonToRumor(json: String): Rumor = + try { + KotlinSerializationMapper.fromJsonToRumor(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun toJson(tags: Array>): String = TODO("Not yet implemented") + actual fun toJson(tags: Array>): String = KotlinSerializationMapper.toJson(tags) - actual inline fun fromJsonTo(json: String): T = TODO("Not yet implemented") + actual inline fun fromJsonTo(json: String): T = + try { + KotlinSerializationMapper.fromJsonTo(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } - actual fun toJson(value: OptimizedSerializable): String = TODO("Not yet implemented") + actual fun toJson(value: OptimizedSerializable): String = KotlinSerializationMapper.toJson(value) } From 30b76347c59f872c8ec3c6fa53738fd334ab5457 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 16:15:44 -0400 Subject: [PATCH 059/130] Spotless --- .../kotlinSerialization/CommandKSerializer.kt | 13 ++- .../kotlinSerialization/EventKSerializer.kt | 2 - .../kotlinSerialization/FilterKSerializer.kt | 4 +- .../KotlinSerializationMapper.kt | 45 ++++++-- .../kotlinSerialization/MessageKSerializer.kt | 18 ++- .../BunkerMessageKSerializer.kt | 72 ++++++++---- .../BunkerResponseKSerializer.kt | 10 +- .../Nip47NotificationKSerializer.kt | 11 +- .../Nip47RequestKSerializer.kt | 4 +- .../Nip47ResponseKSerializer.kt | 104 ++++++++++++------ .../kotlinSerialization/RumorKSerializer.kt | 3 +- 11 files changed, 206 insertions(+), 80 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt index 790a710dd..4d3eba299 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -59,15 +59,19 @@ object CommandKSerializer : KSerializer { add(FilterKSerializer.serializeToElement(filter)) } } + is EventCmd -> { add(EventKSerializer.serializeToElement(value.event)) } + is CloseCmd -> { add(JsonPrimitive(value.subId)) } + is AuthCmd -> { add(EventKSerializer.serializeToElement(value.event)) } + is CountCmd -> { add(JsonPrimitive(value.queryId)) for (filter in value.filters) { @@ -93,6 +97,7 @@ object CommandKSerializer : KSerializer { } ReqCmd(subId, filters) } + CountCmd.LABEL -> { val queryId = array[1].jsonPrimitive.content val filters = @@ -101,16 +106,22 @@ object CommandKSerializer : KSerializer { } CountCmd(queryId, filters) } + EventCmd.LABEL -> { EventCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject)) } + CloseCmd.LABEL -> { CloseCmd(array[1].jsonPrimitive.content) } + AuthCmd.LABEL -> { AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent) } - else -> throw IllegalArgumentException("Message $type is not supported") + + else -> { + throw IllegalArgumentException("Message $type is not supported") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt index 57d2d56ca..843cf5b29 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt @@ -34,8 +34,6 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt index 318af177e..0ce754e0f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt @@ -27,18 +27,15 @@ import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put @@ -120,6 +117,7 @@ object FilterKSerializer : KSerializer { key.startsWith("#") -> { tags[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } } + key.startsWith("&") -> { tagsAll[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt index 086ed8951..d5b79dbf4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -70,19 +70,46 @@ class KotlinSerializationMapper { fun toJson(value: OptimizedSerializable): String = when (value) { - is Event -> json.encodeToString(EventKSerializer, value) - is Filter -> json.encodeToString(FilterKSerializer, value) - is Rumor -> json.encodeToString(RumorKSerializer, value) + is Event -> { + json.encodeToString(EventKSerializer, value) + } + + is Filter -> { + json.encodeToString(FilterKSerializer, value) + } + + is Rumor -> { + json.encodeToString(RumorKSerializer, value) + } + is EventTemplate<*> -> { @Suppress("UNCHECKED_CAST") json.encodeToString(EventTemplateKSerializer, value as EventTemplate) } - is Message -> json.encodeToString(MessageKSerializer, value) - is Command -> json.encodeToString(CommandKSerializer, value) - is BunkerRequest -> json.encodeToString(BunkerRequestKSerializer, value) - is BunkerResponse -> json.encodeToString(BunkerResponseKSerializer, value) - is BunkerMessage -> json.encodeToString(BunkerMessageKSerializer, value) - else -> throw IllegalArgumentException("Unsupported type: ${value::class}") + + is Message -> { + json.encodeToString(MessageKSerializer, value) + } + + is Command -> { + json.encodeToString(CommandKSerializer, value) + } + + is BunkerRequest -> { + json.encodeToString(BunkerRequestKSerializer, value) + } + + is BunkerResponse -> { + json.encodeToString(BunkerResponseKSerializer, value) + } + + is BunkerMessage -> { + json.encodeToString(BunkerMessageKSerializer, value) + } + + else -> { + throw IllegalArgumentException("Unsupported type: ${value::class}") + } } inline fun fromJsonTo(jsonStr: String): T { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index f6e66da70..964b77d35 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -60,9 +60,11 @@ object MessageKSerializer : KSerializer { add(JsonPrimitive(value.subId)) add(EventKSerializer.serializeToElement(value.event)) } + is NoticeMessage -> { add(JsonPrimitive(value.message)) } + is OkMessage -> { add(JsonPrimitive(value.eventId)) // Jackson writes success as a string, not boolean @@ -71,16 +73,20 @@ object MessageKSerializer : KSerializer { add(JsonPrimitive(value.message)) } } + is AuthMessage -> { add(JsonPrimitive(value.challenge)) } + is NotifyMessage -> { add(JsonPrimitive(value.message)) } + is ClosedMessage -> { add(JsonPrimitive(value.subId)) add(JsonPrimitive(value.message)) } + is CountMessage -> { add(CountResultKSerializer.serializeToElement(value.result)) } @@ -100,12 +106,15 @@ object MessageKSerializer : KSerializer { val event = EventKSerializer.deserializeFromElement(array[2].jsonObject) EventMessage(subId, event) } + EoseMessage.LABEL -> { EoseMessage(array[1].jsonPrimitive.content) } + NoticeMessage.LABEL -> { NoticeMessage(array[1].jsonPrimitive.content) } + OkMessage.LABEL -> { OkMessage( eventId = array[1].jsonPrimitive.content, @@ -113,24 +122,31 @@ object MessageKSerializer : KSerializer { message = if (array.size > 3) array[3].jsonPrimitive.content else "", ) } + AuthMessage.LABEL -> { AuthMessage(array[1].jsonPrimitive.content) } + NotifyMessage.LABEL -> { NotifyMessage(array[1].jsonPrimitive.content) } + ClosedMessage.LABEL -> { ClosedMessage( subId = array[1].jsonPrimitive.content, message = if (array.size > 2) array[2].jsonPrimitive.content else "", ) } + CountMessage.LABEL -> { val queryId = array[1].jsonPrimitive.content val result = CountResultKSerializer.deserializeFromElement(array[2].jsonObject) CountMessage(queryId, result) } - else -> throw IllegalArgumentException("Message $type is not supported") + + else -> { + throw IllegalArgumentException("Message $type is not supported") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt index 97502f068..025e586cc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt @@ -71,27 +71,55 @@ object BunkerMessageKSerializer : KSerializer { id: String, method: String, params: Array, - ): BunkerRequest { - return when (method) { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.parse(id, params) - else -> BunkerRequest(id, method, params) + ): BunkerRequest = + when (method) { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign + .parse(id, params) + } + + else -> { + BunkerRequest(id, method, params) + } } - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt index 1127255aa..9787b10eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt @@ -81,8 +81,14 @@ object BunkerResponseKSerializer : KSerializer { if (result != null) { when (result) { - BunkerResponseAck.RESULT -> return BunkerResponseAck.parse(id, result, error) - BunkerResponsePong.RESULT -> return BunkerResponsePong.parse(id, result, error) + BunkerResponseAck.RESULT -> { + return BunkerResponseAck.parse(id, result, error) + } + + BunkerResponsePong.RESULT -> { + return BunkerResponsePong.parse(id, result, error) + } + else -> { if (result.length == 64 && Hex.isHex(result)) { return BunkerResponsePublicKey.parse(id, result) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt index 8a105e1cf..811e46fe4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -44,9 +44,7 @@ object Nip47NotificationKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Notification, - ) { - throw UnsupportedOperationException("NIP-47 Notification serialization not supported") - } + ): Unit = throw UnsupportedOperationException("NIP-47 Notification serialization not supported") override fun deserialize(decoder: Decoder): Notification { val jsonDecoder = decoder as JsonDecoder @@ -63,6 +61,7 @@ object Nip47NotificationKSerializer : KSerializer { ), ) } + NwcNotificationType.PAYMENT_SENT -> { PaymentSentNotification( notification = @@ -71,6 +70,7 @@ object Nip47NotificationKSerializer : KSerializer { ), ) } + NwcNotificationType.HOLD_INVOICE_ACCEPTED -> { val notifObj = jsonObject["notification"]?.jsonObject HoldInvoiceAcceptedNotification( @@ -88,7 +88,10 @@ object Nip47NotificationKSerializer : KSerializer { }, ) } - else -> throw IllegalArgumentException("Unknown notification type: $notificationType") + + else -> { + throw IllegalArgumentException("Unknown notification type: $notificationType") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index 0996ce75a..691627409 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -68,9 +68,7 @@ object Nip47RequestKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Request, - ) { - throw UnsupportedOperationException("NIP-47 Request serialization not supported") - } + ): Unit = throw UnsupportedOperationException("NIP-47 Request serialization not supported") override fun deserialize(decoder: Decoder): Request { val jsonDecoder = decoder as JsonDecoder diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index da7dfadc7..7c124de3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -60,9 +60,7 @@ object Nip47ResponseKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Response, - ) { - throw UnsupportedOperationException("NIP-47 Response serialization not supported") - } + ): Unit = throw UnsupportedOperationException("NIP-47 Response serialization not supported") override fun deserialize(decoder: Decoder): Response { val jsonDecoder = decoder as JsonDecoder @@ -73,7 +71,10 @@ object Nip47ResponseKSerializer : KSerializer { if (hasError) { return when (resultType) { - NwcMethod.PAY_INVOICE -> parsePayInvoiceError(jsonObject) + NwcMethod.PAY_INVOICE -> { + parsePayInvoiceError(jsonObject) + } + else -> { val error = jsonObject["error"]?.jsonObject?.let { parseNwcError(it) } NwcErrorResponse(resultType ?: "", error) @@ -83,19 +84,58 @@ object Nip47ResponseKSerializer : KSerializer { if (hasResult || resultType != null) { return when (resultType) { - NwcMethod.PAY_INVOICE -> parsePayInvoiceSuccess(jsonObject) - NwcMethod.PAY_KEYSEND -> parsePayKeysendSuccess(jsonObject) - NwcMethod.MAKE_INVOICE -> MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) - NwcMethod.LOOKUP_INVOICE -> LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) - NwcMethod.LIST_TRANSACTIONS -> parseListTransactionsSuccess(jsonObject) - NwcMethod.GET_BALANCE -> parseGetBalanceSuccess(jsonObject) - NwcMethod.GET_INFO -> parseGetInfoSuccess(jsonObject) - NwcMethod.GET_BUDGET -> parseGetBudgetSuccess(jsonObject) - NwcMethod.SIGN_MESSAGE -> parseSignMessageSuccess(jsonObject) - NwcMethod.CREATE_CONNECTION -> parseCreateConnectionSuccess(jsonObject) - NwcMethod.MAKE_HOLD_INVOICE -> MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) - NwcMethod.CANCEL_HOLD_INVOICE -> CancelHoldInvoiceSuccessResponse() - NwcMethod.SETTLE_HOLD_INVOICE -> SettleHoldInvoiceSuccessResponse() + NwcMethod.PAY_INVOICE -> { + parsePayInvoiceSuccess(jsonObject) + } + + NwcMethod.PAY_KEYSEND -> { + parsePayKeysendSuccess(jsonObject) + } + + NwcMethod.MAKE_INVOICE -> { + MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.LOOKUP_INVOICE -> { + LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.LIST_TRANSACTIONS -> { + parseListTransactionsSuccess(jsonObject) + } + + NwcMethod.GET_BALANCE -> { + parseGetBalanceSuccess(jsonObject) + } + + NwcMethod.GET_INFO -> { + parseGetInfoSuccess(jsonObject) + } + + NwcMethod.GET_BUDGET -> { + parseGetBudgetSuccess(jsonObject) + } + + NwcMethod.SIGN_MESSAGE -> { + parseSignMessageSuccess(jsonObject) + } + + NwcMethod.CREATE_CONNECTION -> { + parseCreateConnectionSuccess(jsonObject) + } + + NwcMethod.MAKE_HOLD_INVOICE -> { + MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.CANCEL_HOLD_INVOICE -> { + CancelHoldInvoiceSuccessResponse() + } + + NwcMethod.SETTLE_HOLD_INVOICE -> { + SettleHoldInvoiceSuccessResponse() + } + else -> { // backward compatibility: guess by result content val resultObj = jsonObject["result"]?.jsonObject @@ -111,13 +151,14 @@ object Nip47ResponseKSerializer : KSerializer { } private fun parseNwcError(obj: JsonObject): NwcError { - val code = obj["code"]?.jsonPrimitive?.content?.let { codeName -> - try { - NwcErrorCode.valueOf(codeName) - } catch (_: Exception) { - null + val code = + obj["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } } - } return NwcError(code, obj["message"]?.jsonPrimitive?.content) } @@ -157,13 +198,14 @@ object Nip47ResponseKSerializer : KSerializer { return PayInvoiceErrorResponse( error?.let { PayInvoiceErrorResponse.PayInvoiceErrorParams( - code = it["code"]?.jsonPrimitive?.content?.let { codeName -> - try { - NwcErrorCode.valueOf(codeName) - } catch (_: Exception) { - null - } - }, + code = + it["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } + }, message = it["message"]?.jsonPrimitive?.content, ) }, @@ -187,7 +229,7 @@ object Nip47ResponseKSerializer : KSerializer { return ListTransactionsSuccessResponse( result?.let { ListTransactionsSuccessResponse.ListTransactionsResult( - transactions = it["transactions"]?.jsonArray?.map { t -> parseTransaction(t.jsonObject) }, + transactions = it["transactions"]?.jsonArray?.mapNotNull { t -> parseTransaction(t.jsonObject) }, total_count = it["total_count"]?.jsonPrimitive?.longOrNull, ) }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt index a8f4d5956..f6fb5622a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt @@ -20,10 +20,9 @@ */ package com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization -import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.TagArrayKSerializer - import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.TagArrayKSerializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor From cd9d345a0e60cac3b1dec1ed39083f59f7643731 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 15 Mar 2026 21:34:45 +0100 Subject: [PATCH 060/130] spotlessApply --- .../kotlinSerialization/CommandKSerializer.kt | 13 ++- .../kotlinSerialization/EventKSerializer.kt | 4 +- .../kotlinSerialization/FilterKSerializer.kt | 4 +- .../KotlinSerializationMapper.kt | 45 ++++++-- .../kotlinSerialization/MessageKSerializer.kt | 18 +++- .../BunkerMessageKSerializer.kt | 72 +++++++++---- .../BunkerResponseKSerializer.kt | 10 +- .../Nip47NotificationKSerializer.kt | 11 +- .../Nip47RequestKSerializer.kt | 4 +- .../Nip47ResponseKSerializer.kt | 102 ++++++++++++------ .../kotlinSerialization/RumorKSerializer.kt | 3 +- 11 files changed, 206 insertions(+), 80 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt index 790a710dd..4d3eba299 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -59,15 +59,19 @@ object CommandKSerializer : KSerializer { add(FilterKSerializer.serializeToElement(filter)) } } + is EventCmd -> { add(EventKSerializer.serializeToElement(value.event)) } + is CloseCmd -> { add(JsonPrimitive(value.subId)) } + is AuthCmd -> { add(EventKSerializer.serializeToElement(value.event)) } + is CountCmd -> { add(JsonPrimitive(value.queryId)) for (filter in value.filters) { @@ -93,6 +97,7 @@ object CommandKSerializer : KSerializer { } ReqCmd(subId, filters) } + CountCmd.LABEL -> { val queryId = array[1].jsonPrimitive.content val filters = @@ -101,16 +106,22 @@ object CommandKSerializer : KSerializer { } CountCmd(queryId, filters) } + EventCmd.LABEL -> { EventCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject)) } + CloseCmd.LABEL -> { CloseCmd(array[1].jsonPrimitive.content) } + AuthCmd.LABEL -> { AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent) } - else -> throw IllegalArgumentException("Message $type is not supported") + + else -> { + throw IllegalArgumentException("Message $type is not supported") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt index 57d2d56ca..4eba5b397 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt @@ -34,8 +34,6 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int import kotlinx.serialization.json.jsonObject @@ -87,7 +85,7 @@ object EventKSerializer : KSerializer { var createdAt: Long = 0 var kind: Kind = 0 var tags: TagArray = emptyTagArray - var content: String = "" + var content = "" var sig: HexKey = "" for ((key, value) in jsonObject) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt index 318af177e..0ce754e0f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt @@ -27,18 +27,15 @@ import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.int import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put @@ -120,6 +117,7 @@ object FilterKSerializer : KSerializer { key.startsWith("#") -> { tags[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } } + key.startsWith("&") -> { tagsAll[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt index 086ed8951..d5b79dbf4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -70,19 +70,46 @@ class KotlinSerializationMapper { fun toJson(value: OptimizedSerializable): String = when (value) { - is Event -> json.encodeToString(EventKSerializer, value) - is Filter -> json.encodeToString(FilterKSerializer, value) - is Rumor -> json.encodeToString(RumorKSerializer, value) + is Event -> { + json.encodeToString(EventKSerializer, value) + } + + is Filter -> { + json.encodeToString(FilterKSerializer, value) + } + + is Rumor -> { + json.encodeToString(RumorKSerializer, value) + } + is EventTemplate<*> -> { @Suppress("UNCHECKED_CAST") json.encodeToString(EventTemplateKSerializer, value as EventTemplate) } - is Message -> json.encodeToString(MessageKSerializer, value) - is Command -> json.encodeToString(CommandKSerializer, value) - is BunkerRequest -> json.encodeToString(BunkerRequestKSerializer, value) - is BunkerResponse -> json.encodeToString(BunkerResponseKSerializer, value) - is BunkerMessage -> json.encodeToString(BunkerMessageKSerializer, value) - else -> throw IllegalArgumentException("Unsupported type: ${value::class}") + + is Message -> { + json.encodeToString(MessageKSerializer, value) + } + + is Command -> { + json.encodeToString(CommandKSerializer, value) + } + + is BunkerRequest -> { + json.encodeToString(BunkerRequestKSerializer, value) + } + + is BunkerResponse -> { + json.encodeToString(BunkerResponseKSerializer, value) + } + + is BunkerMessage -> { + json.encodeToString(BunkerMessageKSerializer, value) + } + + else -> { + throw IllegalArgumentException("Unsupported type: ${value::class}") + } } inline fun fromJsonTo(jsonStr: String): T { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index f6e66da70..964b77d35 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -60,9 +60,11 @@ object MessageKSerializer : KSerializer { add(JsonPrimitive(value.subId)) add(EventKSerializer.serializeToElement(value.event)) } + is NoticeMessage -> { add(JsonPrimitive(value.message)) } + is OkMessage -> { add(JsonPrimitive(value.eventId)) // Jackson writes success as a string, not boolean @@ -71,16 +73,20 @@ object MessageKSerializer : KSerializer { add(JsonPrimitive(value.message)) } } + is AuthMessage -> { add(JsonPrimitive(value.challenge)) } + is NotifyMessage -> { add(JsonPrimitive(value.message)) } + is ClosedMessage -> { add(JsonPrimitive(value.subId)) add(JsonPrimitive(value.message)) } + is CountMessage -> { add(CountResultKSerializer.serializeToElement(value.result)) } @@ -100,12 +106,15 @@ object MessageKSerializer : KSerializer { val event = EventKSerializer.deserializeFromElement(array[2].jsonObject) EventMessage(subId, event) } + EoseMessage.LABEL -> { EoseMessage(array[1].jsonPrimitive.content) } + NoticeMessage.LABEL -> { NoticeMessage(array[1].jsonPrimitive.content) } + OkMessage.LABEL -> { OkMessage( eventId = array[1].jsonPrimitive.content, @@ -113,24 +122,31 @@ object MessageKSerializer : KSerializer { message = if (array.size > 3) array[3].jsonPrimitive.content else "", ) } + AuthMessage.LABEL -> { AuthMessage(array[1].jsonPrimitive.content) } + NotifyMessage.LABEL -> { NotifyMessage(array[1].jsonPrimitive.content) } + ClosedMessage.LABEL -> { ClosedMessage( subId = array[1].jsonPrimitive.content, message = if (array.size > 2) array[2].jsonPrimitive.content else "", ) } + CountMessage.LABEL -> { val queryId = array[1].jsonPrimitive.content val result = CountResultKSerializer.deserializeFromElement(array[2].jsonObject) CountMessage(queryId, result) } - else -> throw IllegalArgumentException("Message $type is not supported") + + else -> { + throw IllegalArgumentException("Message $type is not supported") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt index 97502f068..025e586cc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerMessageKSerializer.kt @@ -71,27 +71,55 @@ object BunkerMessageKSerializer : KSerializer { id: String, method: String, params: Array, - ): BunkerRequest { - return when (method) { - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.parse(id, params) - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> - com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.parse(id, params) - else -> BunkerRequest(id, method, params) + ): BunkerRequest = + when (method) { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing + .parse(id, params) + } + + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign + .parse(id, params) + } + + else -> { + BunkerRequest(id, method, params) + } } - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt index 1127255aa..9787b10eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt @@ -81,8 +81,14 @@ object BunkerResponseKSerializer : KSerializer { if (result != null) { when (result) { - BunkerResponseAck.RESULT -> return BunkerResponseAck.parse(id, result, error) - BunkerResponsePong.RESULT -> return BunkerResponsePong.parse(id, result, error) + BunkerResponseAck.RESULT -> { + return BunkerResponseAck.parse(id, result, error) + } + + BunkerResponsePong.RESULT -> { + return BunkerResponsePong.parse(id, result, error) + } + else -> { if (result.length == 64 && Hex.isHex(result)) { return BunkerResponsePublicKey.parse(id, result) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt index 8a105e1cf..811e46fe4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -44,9 +44,7 @@ object Nip47NotificationKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Notification, - ) { - throw UnsupportedOperationException("NIP-47 Notification serialization not supported") - } + ): Unit = throw UnsupportedOperationException("NIP-47 Notification serialization not supported") override fun deserialize(decoder: Decoder): Notification { val jsonDecoder = decoder as JsonDecoder @@ -63,6 +61,7 @@ object Nip47NotificationKSerializer : KSerializer { ), ) } + NwcNotificationType.PAYMENT_SENT -> { PaymentSentNotification( notification = @@ -71,6 +70,7 @@ object Nip47NotificationKSerializer : KSerializer { ), ) } + NwcNotificationType.HOLD_INVOICE_ACCEPTED -> { val notifObj = jsonObject["notification"]?.jsonObject HoldInvoiceAcceptedNotification( @@ -88,7 +88,10 @@ object Nip47NotificationKSerializer : KSerializer { }, ) } - else -> throw IllegalArgumentException("Unknown notification type: $notificationType") + + else -> { + throw IllegalArgumentException("Unknown notification type: $notificationType") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index 0996ce75a..691627409 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -68,9 +68,7 @@ object Nip47RequestKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Request, - ) { - throw UnsupportedOperationException("NIP-47 Request serialization not supported") - } + ): Unit = throw UnsupportedOperationException("NIP-47 Request serialization not supported") override fun deserialize(decoder: Decoder): Request { val jsonDecoder = decoder as JsonDecoder diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index da7dfadc7..78e4c435f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -60,9 +60,7 @@ object Nip47ResponseKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Response, - ) { - throw UnsupportedOperationException("NIP-47 Response serialization not supported") - } + ): Unit = throw UnsupportedOperationException("NIP-47 Response serialization not supported") override fun deserialize(decoder: Decoder): Response { val jsonDecoder = decoder as JsonDecoder @@ -73,7 +71,10 @@ object Nip47ResponseKSerializer : KSerializer { if (hasError) { return when (resultType) { - NwcMethod.PAY_INVOICE -> parsePayInvoiceError(jsonObject) + NwcMethod.PAY_INVOICE -> { + parsePayInvoiceError(jsonObject) + } + else -> { val error = jsonObject["error"]?.jsonObject?.let { parseNwcError(it) } NwcErrorResponse(resultType ?: "", error) @@ -83,19 +84,58 @@ object Nip47ResponseKSerializer : KSerializer { if (hasResult || resultType != null) { return when (resultType) { - NwcMethod.PAY_INVOICE -> parsePayInvoiceSuccess(jsonObject) - NwcMethod.PAY_KEYSEND -> parsePayKeysendSuccess(jsonObject) - NwcMethod.MAKE_INVOICE -> MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) - NwcMethod.LOOKUP_INVOICE -> LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) - NwcMethod.LIST_TRANSACTIONS -> parseListTransactionsSuccess(jsonObject) - NwcMethod.GET_BALANCE -> parseGetBalanceSuccess(jsonObject) - NwcMethod.GET_INFO -> parseGetInfoSuccess(jsonObject) - NwcMethod.GET_BUDGET -> parseGetBudgetSuccess(jsonObject) - NwcMethod.SIGN_MESSAGE -> parseSignMessageSuccess(jsonObject) - NwcMethod.CREATE_CONNECTION -> parseCreateConnectionSuccess(jsonObject) - NwcMethod.MAKE_HOLD_INVOICE -> MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) - NwcMethod.CANCEL_HOLD_INVOICE -> CancelHoldInvoiceSuccessResponse() - NwcMethod.SETTLE_HOLD_INVOICE -> SettleHoldInvoiceSuccessResponse() + NwcMethod.PAY_INVOICE -> { + parsePayInvoiceSuccess(jsonObject) + } + + NwcMethod.PAY_KEYSEND -> { + parsePayKeysendSuccess(jsonObject) + } + + NwcMethod.MAKE_INVOICE -> { + MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.LOOKUP_INVOICE -> { + LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.LIST_TRANSACTIONS -> { + parseListTransactionsSuccess(jsonObject) + } + + NwcMethod.GET_BALANCE -> { + parseGetBalanceSuccess(jsonObject) + } + + NwcMethod.GET_INFO -> { + parseGetInfoSuccess(jsonObject) + } + + NwcMethod.GET_BUDGET -> { + parseGetBudgetSuccess(jsonObject) + } + + NwcMethod.SIGN_MESSAGE -> { + parseSignMessageSuccess(jsonObject) + } + + NwcMethod.CREATE_CONNECTION -> { + parseCreateConnectionSuccess(jsonObject) + } + + NwcMethod.MAKE_HOLD_INVOICE -> { + MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + } + + NwcMethod.CANCEL_HOLD_INVOICE -> { + CancelHoldInvoiceSuccessResponse() + } + + NwcMethod.SETTLE_HOLD_INVOICE -> { + SettleHoldInvoiceSuccessResponse() + } + else -> { // backward compatibility: guess by result content val resultObj = jsonObject["result"]?.jsonObject @@ -111,13 +151,14 @@ object Nip47ResponseKSerializer : KSerializer { } private fun parseNwcError(obj: JsonObject): NwcError { - val code = obj["code"]?.jsonPrimitive?.content?.let { codeName -> - try { - NwcErrorCode.valueOf(codeName) - } catch (_: Exception) { - null + val code = + obj["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } } - } return NwcError(code, obj["message"]?.jsonPrimitive?.content) } @@ -157,13 +198,14 @@ object Nip47ResponseKSerializer : KSerializer { return PayInvoiceErrorResponse( error?.let { PayInvoiceErrorResponse.PayInvoiceErrorParams( - code = it["code"]?.jsonPrimitive?.content?.let { codeName -> - try { - NwcErrorCode.valueOf(codeName) - } catch (_: Exception) { - null - } - }, + code = + it["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } + }, message = it["message"]?.jsonPrimitive?.content, ) }, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt index a8f4d5956..f6fb5622a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/rumors/kotlinSerialization/RumorKSerializer.kt @@ -20,10 +20,9 @@ */ package com.vitorpamplona.quartz.nip59Giftwrap.rumors.kotlinSerialization -import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.TagArrayKSerializer - import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.TagArrayKSerializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor From 3cfa9e16194e5ca820c3ea7e931ecf5668c7324e Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 15 Mar 2026 21:36:53 +0100 Subject: [PATCH 061/130] compilation error fix: Changed map to mapNotNull --- .../kotlinSerialization/Nip47ResponseKSerializer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index 78e4c435f..7c124de3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -229,7 +229,7 @@ object Nip47ResponseKSerializer : KSerializer { return ListTransactionsSuccessResponse( result?.let { ListTransactionsSuccessResponse.ListTransactionsResult( - transactions = it["transactions"]?.jsonArray?.map { t -> parseTransaction(t.jsonObject) }, + transactions = it["transactions"]?.jsonArray?.mapNotNull { t -> parseTransaction(t.jsonObject) }, total_count = it["total_count"]?.jsonPrimitive?.longOrNull, ) }, From 0c939bd44702a100063a05f8bf41015f798e6695 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 16:50:34 -0400 Subject: [PATCH 062/130] converted from Java arraylist to kotlin arraylist --- .../nip01Core/kotlinSerialization/TagArrayKSerializer.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt index 3e6562808..c9ebbe5ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt @@ -79,11 +79,9 @@ object TagArrayKSerializer : KSerializer { innerList.add(s.jsonPrimitive.content) } } - @Suppress("UNCHECKED_CAST") - outerList.add(innerList.toArray(arrayOfNulls(innerList.size)) as Array) + outerList.add(innerList.toTypedArray()) } - @Suppress("UNCHECKED_CAST") - return outerList.toArray(arrayOfNulls>(outerList.size)) as TagArray + return outerList.toTypedArray() } fun serializeToElement(value: TagArray): JsonArray = From 6216f7a6180f87da6b6557da7a62c98e3347e17d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 16:55:00 -0400 Subject: [PATCH 063/130] no casts needed --- .../kotlinSerialization/KotlinSerializationMapperTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt index 2520242a0..95c2c9244 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -487,7 +487,7 @@ class KotlinSerializationMapperTest { val json = """["EOSE","sub123"]""" val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) assertTrue(deserialized is EoseMessage) - assertEquals("sub123", (deserialized as EoseMessage).subId) + assertEquals("sub123", (deserialized).subId) } @Test @@ -573,12 +573,12 @@ class KotlinSerializationMapperTest { val jacksonJson = JacksonMapper.toJson(cmd) val kotlinDeserialized = KotlinSerializationMapper.fromJsonToCommand(jacksonJson) assertTrue(kotlinDeserialized is CloseCmd) - assertEquals("sub1", (kotlinDeserialized as CloseCmd).subId) + assertEquals("sub1", (kotlinDeserialized).subId) val kotlinJson = KotlinSerializationMapper.toJson(cmd) val jacksonDeserialized = JacksonMapper.fromJsonToCommand(kotlinJson) assertTrue(jacksonDeserialized is CloseCmd) - assertEquals("sub1", (jacksonDeserialized as CloseCmd).subId) + assertEquals("sub1", (jacksonDeserialized).subId) } // ========================================================================= From 76c6f50fa811b46a71ba2bc018f6acc68d6efd0c Mon Sep 17 00:00:00 2001 From: David Kaspar Date: Sun, 15 Mar 2026 20:57:02 +0000 Subject: [PATCH 064/130] Rename build step for Benchmark APK in workflow --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61e76d85f..8387a5298 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,7 +54,7 @@ jobs: name: FDroid Debug APK path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk - - name: Build APK (gradle) + - name: Build Benchmark APK (gradle) run: ./gradlew assembleBenchmark - name: Upload Play APK Benchmark From cef8f5acdecf50d1463d1be32d63af10ea8254ad Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 17:14:57 -0400 Subject: [PATCH 065/130] Fixes test case --- .../kotlinSerialization/KotlinSerializationMapperTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt index 95c2c9244..959b4de53 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -603,7 +603,7 @@ class KotlinSerializationMapperTest { @Test fun crossDeserializationBunkerRequest() { - val req = BunkerRequest("id1", "sign_event", arrayOf("eventJson")) + val req = BunkerRequest("id1", "sign_event", arrayOf("{\"created_at\":1234,\"kind\":1,\"tags\":[],\"content\":\"This is an unsigned event.\"}")) val jacksonJson = JacksonMapper.toJson(req) val kotlinDeserialized = KotlinSerializationMapper.fromJsonTo(jacksonJson) assertEquals(req.id, kotlinDeserialized.id) From 2fd29ab365efe54cb2d8fa60d15cb3729f1f85fa Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 17:15:16 -0400 Subject: [PATCH 066/130] uses quiet mode to avoid lots of warnings in the commit screen --- .git-hooks/pre-push | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.git-hooks/pre-push b/.git-hooks/pre-push index 903d42dc6..2c0dbdc27 100755 --- a/.git-hooks/pre-push +++ b/.git-hooks/pre-push @@ -12,7 +12,7 @@ echo "$JAVA_HOME" echo "$(java -version)" echo "Running test... " -./gradlew test +./gradlew test --quiet status=$? From d996911cda68dc8682fadc1f52972dab3fce2765 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 21:34:18 +0000 Subject: [PATCH 067/130] feat: add cross-platform CI for Linux, macOS, and Windows - Split build.yml into lint, test (matrix: 3 OS), build-android, and build-desktop (matrix: 3 OS with native packaging) jobs - Tests now run on ubuntu, macos, and windows to validate cross-platform compatibility - Desktop distributions (DEB, DMG, MSI) built on their native OS and uploaded as artifacts - Split create-release.yml into create-release, deploy-android, and deploy-desktop jobs so desktop distributions are included in releases - Android APK/AAB builds and Quartz publishing remain on Linux https://claude.ai/code/session_016dxFUErY4P6WRir4xxhCEr --- .github/workflows/build.yml | 130 ++++++++++++++++++++++++--- .github/workflows/create-release.yml | 128 +++++++++++++++++++------- 2 files changed, 215 insertions(+), 43 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8387a5298..9bb6fd295 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Test/Build Android +name: Test/Build on: pull_request: @@ -7,10 +7,9 @@ on: branches: [main] jobs: - build: + lint: runs-on: ubuntu-latest - timeout-minutes: 30 - + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 @@ -24,20 +23,84 @@ jobs: - name: Cache gradle uses: actions/cache@v4 with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - name: Linter (gradle) run: ./gradlew spotlessCheck + test: + needs: lint + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 21 + + - name: Cache gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Test (gradle) run: ./gradlew test --no-daemon - name: Android Test Report uses: asadmansr/android-test-report-action@v1.2.0 - if: ${{ always() }} # IMPORTANT: run Android Test Report regardless + if: ${{ always() && matrix.os == 'ubuntu-latest' }} + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: ${{ always() && matrix.os == 'ubuntu-latest' }} + with: + name: Test Reports + path: amethyst/build/reports + + build-android: + needs: test + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 21 + + - name: Cache gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- - name: Build APK (gradle) run: ./gradlew assembleDebug @@ -75,9 +138,54 @@ jobs: name: Compose Reports path: amethyst/build/compose_compiler - - name: Upload Test Results + build-desktop: + needs: test + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + task: packageDeb + artifact-name: Desktop Linux DEB + artifact-path: desktopApp/build/compose/binaries/main/deb/*.deb + - os: macos-latest + task: packageDmg + artifact-name: Desktop macOS DMG + artifact-path: desktopApp/build/compose/binaries/main/dmg/*.dmg + - os: windows-latest + task: packageMsi + artifact-name: Desktop Windows MSI + artifact-path: desktopApp/build/compose/binaries/main/msi/*.msi + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 21 + + - name: Cache gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build Desktop Distribution + run: ./gradlew :desktopApp:${{ matrix.task }} + + - name: Upload Desktop Distribution uses: actions/upload-artifact@v4 with: - name: Test Reports - path: amethyst/build/reports - + name: ${{ matrix.artifact-name }} + path: ${{ matrix.artifact-path }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 44f3d56fd..70f337ecd 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -6,7 +6,24 @@ on: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 jobs: - deploy: + create-release: + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + steps: + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: true + + deploy-android: + needs: create-release runs-on: ubuntu-latest steps: - name: Checkout code @@ -21,8 +38,10 @@ jobs: - name: Cache gradle uses: actions/cache@v4 with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- @@ -38,7 +57,6 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "36.0.0" - name: Sign AAB (F-Droid) @@ -50,7 +68,6 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "36.0.0" - name: Build APK @@ -65,7 +82,6 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "36.0.0" - name: Sign APK (F-Droid) @@ -77,20 +93,8 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "36.0.0" - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: false - prerelease: true - # Google Play APK - name: Upload Play APK Universal Asset id: upload-release-asset-play-universal-apk @@ -98,7 +102,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-universal-release-unsigned-signed.apk asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -109,7 +113,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86-release-unsigned-signed.apk asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -120,7 +124,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-x86_64-release-unsigned-signed.apk asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -131,7 +135,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-arm64-v8a-release-unsigned-signed.apk asset_name: amethyst-googleplay-arm64-v8a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -142,7 +146,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/play/release/amethyst-play-armeabi-v7a-release-unsigned-signed.apk asset_name: amethyst-googleplay-armeabi-v7a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -154,7 +158,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-universal-release-unsigned-signed.apk asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -165,7 +169,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86-release-unsigned-signed.apk asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -176,7 +180,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-x86_64-release-unsigned-signed.apk asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -187,7 +191,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned-signed.apk asset_name: amethyst-fdroid-arm64-v8a-${{ github.ref_name }}.apk asset_content_type: application/zip @@ -198,13 +202,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-armeabi-v7a-release-unsigned-signed.apk asset_name: amethyst-fdroid-armeabi-v7a-${{ github.ref_name }}.apk asset_content_type: application/zip - - # Google Play AAB - name: Upload Google Play AAB Asset id: upload-release-asset-play-aab @@ -212,7 +214,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/bundle/playRelease/amethyst-play-release.aab asset_name: amethyst-googleplay-${{ github.ref_name }}.aab asset_content_type: application/zip @@ -224,7 +226,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} + upload_url: ${{ needs.create-release.outputs.upload_url }} asset_path: amethyst/build/outputs/bundle/fdroidRelease/amethyst-fdroid-release.aab asset_name: amethyst-fdroid-${{ github.ref_name }}.aab asset_content_type: application/zip @@ -236,3 +238,65 @@ jobs: ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }} + + deploy-desktop: + needs: create-release + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + task: packageDeb + format: deb + platform: linux + - os: macos-latest + task: packageDmg + format: dmg + platform: macos + - os: windows-latest + task: packageMsi + format: msi + platform: windows + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 21 + + - name: Cache gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build Desktop Distribution + run: ./gradlew :desktopApp:${{ matrix.task }} + + - name: Find distribution file + id: find-dist + run: | + DIST_FILE=$(find desktopApp/build/compose/binaries/main/${{ matrix.format }} -type f \( -name "*.deb" -o -name "*.dmg" -o -name "*.msi" \) | head -1) + echo "path=$DIST_FILE" >> $GITHUB_OUTPUT + echo "name=$(basename $DIST_FILE)" >> $GITHUB_OUTPUT + + - name: Upload Desktop Distribution to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ${{ steps.find-dist.outputs.path }} + asset_name: amethyst-desktop-${{ matrix.platform }}-${{ github.ref_name }}.${{ matrix.format }} + asset_content_type: application/octet-stream From 0e476c898839dd412f75f7ff4da068fbd2723d07 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 19:51:39 -0400 Subject: [PATCH 068/130] Adds test and some refinements. --- .../relays/common/BasicRelaySetupInfoModel.kt | 2 +- .../relays/common/RelayEventCountRow.kt | 4 +- .../relays/common/RelayEventCountViewModel.kt | 4 +- .../relays/dm/DMRelayListViewModel.kt | 3 +- .../indexer/IndexerRelayListViewModel.kt | 11 ++- .../nip37/PrivateOutboxRelayListViewModel.kt | 3 +- .../relays/nip65/Nip65RelayListViewModel.kt | 5 +- .../relays/proxy/ProxyRelayListViewModel.kt | 10 -- .../relays/search/SearchRelayListViewModel.kt | 3 +- amethyst/src/main/res/values/strings.xml | 4 + .../kotlinSerialization/MessageKSerializer.kt | 4 + .../relay/client/accessories/RelayLogger.kt | 2 + .../toClient/CountResultDeserializer.kt | 4 +- .../commands/toClient/MessageSerializer.kt | 4 +- .../relay/NostrClientQueryCountTest.kt | 93 +++++++++++++++++++ 15 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt index e198edcc7..91bc1f373 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoModel.kt @@ -89,7 +89,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() { private fun loadCounts() { _countResults.value = emptyMap() - val client = Amethyst.instance.client + val client = account.client val relayList = _relays.value if (relayList.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt index b62432f4b..835eb764d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountRow.kt @@ -69,9 +69,9 @@ fun RelayEventCountRow( val countText = if (entry.approximate) { - "~${countToHumanReadable(entry.count, entry.label)}" + "~${countToHumanReadable(entry.count, stringRes(entry.label))}" } else { - countToHumanReadable(entry.count, entry.label) + countToHumanReadable(entry.count, stringRes(entry.label)) } Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt index 6f77913ff..5aaa39b6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayEventCountViewModel.kt @@ -29,13 +29,13 @@ data class RelayCountResult( ) { @Immutable data class CountEntry( - val label: String, + val label: Int, val count: Int, val approximate: Boolean = false, ) } data class CountFilter( - val label: String, + val label: Int, val filter: Filter, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index a78df4519..53a6a5ed0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -39,7 +40,7 @@ class DMRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = "events", + label = R.string.dms, filter = Filter( kinds = listOf(GiftWrapEvent.KIND, PrivateDmEvent.KIND), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index c875c5a63..46da1f83e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -21,10 +21,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @Stable class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { @@ -39,12 +42,12 @@ class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = "kind 0", - filter = Filter(kinds = listOf(0)), + label = R.string.profiles, + filter = Filter(kinds = listOf(MetadataEvent.KIND)), ), CountFilter( - label = "kind 10002", - filter = Filter(kinds = listOf(10002)), + label = R.string.relay_settings_lower, + filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND)), ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index 5cd3e96ff..1003e9560 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -39,7 +40,7 @@ class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = "events", + label = R.string.events, filter = Filter(authors = listOf(account.pubKey)), ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt index 0894a0f04..e6fb7883a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip65/Nip65RelayListViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -136,7 +137,7 @@ class Nip65RelayListViewModel : ViewModel() { RelayCountResult( listOf( RelayCountResult.CountEntry( - label = "events", + label = R.string.events, count = result.count, approximate = result.approximate, ), @@ -155,7 +156,7 @@ class Nip65RelayListViewModel : ViewModel() { RelayCountResult( listOf( RelayCountResult.CountEntry( - label = "events", + label = R.string.events, count = result.count, approximate = result.approximate, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt index 01d07c40b..e2a0f1359 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @Stable @@ -35,12 +33,4 @@ class ProxyRelayListViewModel : BasicRelaySetupInfoModel() { override suspend fun saveRelayList(urlList: List) { account.saveProxyRelayList(urlList) } - - override fun countFilters(relayUrl: NormalizedRelayUrl): List = - listOf( - CountFilter( - label = "events", - filter = Filter(), - ), - ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index aaf592a68..2f219c534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.CountFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -39,7 +40,7 @@ class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override fun countFilters(relayUrl: NormalizedRelayUrl): List = listOf( CountFilter( - label = "events", + label = R.string.events, filter = Filter(), ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e322c05e7..d2ae0d724 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1802,4 +1802,8 @@ %1$d%% uptime Namecoin Settings Bitcoin Explorer (OTS) + events + DMs + profiles + relay settings diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt index 964b77d35..34ee1b491 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -90,6 +90,10 @@ object MessageKSerializer : KSerializer { is CountMessage -> { add(CountResultKSerializer.serializeToElement(value.result)) } + + is EoseMessage -> { + add(JsonPrimitive(value.subId)) + } } } jsonEncoder.encodeJsonElement(element) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt index eba39649b..ceb4b36de 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayLogger.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientLis import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message @@ -62,6 +63,7 @@ class RelayLogger( is OkMessage -> if (debugReceiving) Log.d(logTag, "OK: ${msg.eventId} ${msg.success} ${msg.message}") is AuthMessage -> if (debugReceiving) Log.d(logTag, "Auth: ${msg.challenge}") is NotifyMessage -> if (debugReceiving) Log.d(logTag, "Notify: ${msg.message}") + is CountMessage -> if (debugReceiving) Log.d(logTag, "Count: ${msg.result.count} approx: ${msg.result.approximate}") is ClosedMessage -> Log.w(logTag, "Closed: ${msg.subId} ${msg.message}") } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt index dd5cfa0bb..3858f3236 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultDeserializer.kt @@ -26,8 +26,8 @@ class CountResultDeserializer { companion object { fun fromJson(jsonObject: JsonNode): CountResult = CountResult( - count = jsonObject.get("count").asInt(), - approximate = jsonObject.get("approximate").asBoolean(), + count = jsonObject.get("count")?.asInt() ?: 0, + approximate = jsonObject.get("approximate")?.asBoolean() ?: false, ) } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt index 470360c80..345b9d9c4 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/MessageSerializer.kt @@ -72,8 +72,8 @@ class MessageSerializer : StdSerializer(Message::class.java) { countSerializer.serialize(msg.result, gen, provider) } - else -> { - null + is EoseMessage -> { + gen.writeString(msg.subId) } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt new file mode 100644 index 000000000..d58ed363f --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientQueryCountTest.kt @@ -0,0 +1,93 @@ +/* + * 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.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.queryCountSuspend +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlin.test.Test + +class NostrClientQueryCountTest : BaseNostrClientTest() { + val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl() + val utxo = "wss://news.utxo.one".normalizeRelayUrl() + + val metadata = Filter(kinds = listOf(0)) + val outboxRelays = Filter(kinds = listOf(10002)) + + @Test + fun testQueryCountSuspend() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = client.queryCountSuspend(relay = fiatjaf, filter = metadata) + + assertTrue((result?.count ?: 0) > 1) + + client.disconnect() + appScope.cancel() + } + + @Test + fun testQueryCountSuspendAllEvents() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = client.queryCountSuspend(relay = fiatjaf, filter = Filter()) + + assertTrue((result?.count ?: 0) > 1) + + client.disconnect() + appScope.cancel() + } + + @Test + fun testQueryCountSuspendMultipleRelays() = + runBlocking { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val result = + client.queryCountSuspend( + filters = + mapOf( + fiatjaf to listOf(metadata, outboxRelays), + utxo to listOf(metadata, outboxRelays), + ), + ) + + result.forEach { url, result -> + println("${url.url}: ${result.count}") + assertTrue(result.count > 1) + } + + client.disconnect() + appScope.cancel() + } +} From 1cdd061b06006452b137ffcb3f522371b77c2ca5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 20:03:39 -0400 Subject: [PATCH 069/130] removing warnings --- .../quartz/nip47WalletConnect/ResponseTest.kt | 82 ++++++++----------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt index c26e05bf0..ff7ad12a7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/ResponseTest.kt @@ -94,13 +94,13 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result) - assertEquals("incoming", response.result?.type) - assertEquals("lnbc50n1...", response.result?.invoice) - assertEquals("test", response.result?.description) - assertEquals("abc", response.result?.payment_hash) - assertEquals(5000L, response.result?.amount) - assertEquals(1693876497L, response.result?.created_at) - assertEquals(1694876497L, response.result?.expires_at) + assertEquals("incoming", response.result.type) + assertEquals("lnbc50n1...", response.result.invoice) + assertEquals("test", response.result.description) + assertEquals("abc", response.result.payment_hash) + assertEquals(5000L, response.result.amount) + assertEquals(1693876497L, response.result.created_at) + assertEquals(1694876497L, response.result.expires_at) } // --- LookupInvoice Success --- @@ -111,11 +111,11 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result) - assertEquals("incoming", response.result?.type) - assertEquals("settled", response.result?.state) - assertEquals("hash123", response.result?.payment_hash) - assertEquals(1000L, response.result?.amount) - assertEquals(1694876497L, response.result?.settled_at) + assertEquals("incoming", response.result.type) + assertEquals("settled", response.result.state) + assertEquals("hash123", response.result.payment_hash) + assertEquals(1000L, response.result.amount) + assertEquals(1694876497L, response.result.settled_at) } // --- ListTransactions Success --- @@ -127,34 +127,22 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result?.transactions) - assertEquals(2, response.result?.transactions?.size) + assertEquals(2, response.result.transactions.size) assertEquals( "incoming", - response.result - ?.transactions - ?.get(0) - ?.type, + response.result.transactions[0].type, ) assertEquals( 100L, - response.result - ?.transactions - ?.get(0) - ?.amount, + response.result.transactions[0].amount, ) assertEquals( "outgoing", - response.result - ?.transactions - ?.get(1) - ?.type, + response.result.transactions[1].type, ) assertEquals( 200L, - response.result - ?.transactions - ?.get(1) - ?.amount, + response.result.transactions[1].amount, ) } @@ -164,7 +152,7 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result?.transactions) - assertEquals(0, response.result?.transactions?.size) + assertEquals(0, response.result.transactions.size) } // --- GetBalance Success --- @@ -194,14 +182,14 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result) - assertEquals("MyNode", response.result?.alias) - assertEquals("#ff9900", response.result?.color) - assertEquals("abc123", response.result?.pubkey) - assertEquals("mainnet", response.result?.network) - assertEquals(800000L, response.result?.block_height) - assertEquals("hash", response.result?.block_hash) - assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods) - assertEquals(listOf("payment_received"), response.result?.notifications) + assertEquals("MyNode", response.result.alias) + assertEquals("#ff9900", response.result.color) + assertEquals("abc123", response.result.pubkey) + assertEquals("mainnet", response.result.network) + assertEquals(800000L, response.result.block_height) + assertEquals("hash", response.result.block_hash) + assertEquals(listOf("pay_invoice", "get_balance"), response.result.methods) + assertEquals(listOf("payment_received"), response.result.notifications) } // --- MakeHoldInvoice Success --- @@ -212,8 +200,8 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result) - assertEquals("lnbc...", response.result?.invoice) - assertEquals("hash", response.result?.payment_hash) + assertEquals("lnbc...", response.result.invoice) + assertEquals("hash", response.result.payment_hash) } // --- CancelHoldInvoice Success --- @@ -287,10 +275,10 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result) - assertEquals(50000L, response.result?.used_budget) - assertEquals(100000L, response.result?.total_budget) - assertEquals(1700000000L, response.result?.renews_at) - assertEquals("monthly", response.result?.renewal_period) + assertEquals(50000L, response.result.used_budget) + assertEquals(100000L, response.result.total_budget) + assertEquals(1700000000L, response.result.renews_at) + assertEquals("monthly", response.result.renewal_period) } @Test @@ -312,8 +300,8 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result) - assertEquals("Hello Nostr", response.result?.message) - assertEquals("sig123abc", response.result?.signature) + assertEquals("Hello Nostr", response.result.message) + assertEquals("sig123abc", response.result.signature) } // --- CreateConnection Success --- @@ -324,7 +312,7 @@ class ResponseTest { val response = OptimizedJsonMapper.fromJsonTo(json) assertIs(response) assertNotNull(response.result) - assertEquals("walletpub123", response.result?.wallet_pubkey) + assertEquals("walletpub123", response.result.wallet_pubkey) } // --- GetInfo with extended fields --- From 19ca7afcb8dd3ab65b50310d4df8a8d9db299226 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 20:06:20 -0400 Subject: [PATCH 070/130] fixes warnings --- .../quartz/nip47WalletConnect/AlbyInteropTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt index f6221cdc6..4b70331eb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/AlbyInteropTest.kt @@ -581,8 +581,8 @@ class AlbyInteropTest { assertIs(response) assertEquals(13, response.result?.methods?.size) assertEquals(3, response.result?.notifications?.size) - assertTrue(response.result?.methods?.contains(NwcMethod.GET_BUDGET) == true) - assertTrue(response.result?.methods?.contains(NwcMethod.SIGN_MESSAGE) == true) - assertTrue(response.result?.methods?.contains(NwcMethod.CREATE_CONNECTION) == true) + assertEquals(response.result?.methods?.contains(NwcMethod.GET_BUDGET), true) + assertEquals(response.result?.methods?.contains(NwcMethod.SIGN_MESSAGE), true) + assertEquals(response.result?.methods?.contains(NwcMethod.CREATE_CONNECTION), true) } } From 49f5d9518ef8cd2dbec2705116c3c7981a0647d8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 20:26:31 -0400 Subject: [PATCH 071/130] Fixes warnings --- .../quartz/nip49PrivKeyEnc/NIP49Test.kt | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt index 5dfcfb8ba..3eeae8055 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt @@ -29,15 +29,15 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -public class NIP49Test { +class NIP49Test { companion object { - val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + const val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" - val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" - val TEST_CASE_PASSWORD = "nostr" + const val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" + const val TEST_CASE_PASSWORD = "nostr" val MAIN_TEST_CASES = - listOf( + listOf( Nip49TestCase(".ksjabdk.aselqwe", "14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a", 1, 0x00), Nip49TestCase("skjdaklrnçurbç l", "f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab", 2, 0x01), Nip49TestCase("777z7z7z7z7z7z7z", "11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944", 3, 0x02), @@ -55,12 +55,9 @@ public class NIP49Test { @Test fun decodeBech32() { - val data = - Nip49.EncryptedInfo.decodePayload( - TEST_CASE, - )!! + val data = Nip49.EncryptedInfo.decodePayload(TEST_CASE) - assertEquals(2.toByte(), data.version) + assertEquals(2.toByte(), data!!.version) assertEquals(16.toByte(), data.logn) assertEquals("52d7c3f8580e7b41953381e5bc49646b", data.salt.toHexKey()) assertEquals("c33f02a7dcaac8bdd8da23cd449783240b6ebc12edeea7bf", data.nonce.toHexKey()) @@ -77,7 +74,7 @@ public class NIP49Test { @Test fun encryptDecryptTestCase() { val encrypted = nip49.encrypt(TEST_CASE_EXPECTED, TEST_CASE_PASSWORD, 16, 0) - val decrypted = nip49.decrypt(encrypted!!, TEST_CASE_PASSWORD) + val decrypted = nip49.decrypt(encrypted, TEST_CASE_PASSWORD) assertEquals(TEST_CASE_EXPECTED, decrypted) } @@ -89,7 +86,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, it.password) + val decrypted = nip49.decrypt(encrypted, it.password) assertEquals(it.secretKey, decrypted) } @@ -108,7 +105,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, samePassword2) + val decrypted = nip49.decrypt(encrypted, samePassword2) assertEquals(TEST_CASE_EXPECTED, decrypted) } From e87ac7468d96e325caddb5667efe5b6a6de48c0e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 20:29:05 -0400 Subject: [PATCH 072/130] Removing Locale deprecations --- .../com/vitorpamplona/amethyst/model/Account.kt | 3 +-- .../amethyst/model/AccountSettings.kt | 7 +++---- .../amethyst/model/AccountSyncedSettings.kt | 9 ++++----- .../ui/screen/loggedIn/AccountViewModel.kt | 3 +-- .../screen/loggedIn/settings/UserSettingsScreen.kt | 10 +++++----- .../ui/components/TranslatableRichTextViewer.kt | 14 +++++++------- 6 files changed, 21 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e7397ce9c..ca19be854 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -226,7 +226,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.math.BigDecimal -import java.util.Locale import kotlin.coroutines.cancellation.CancellationException @OptIn(DelicateCoroutinesApi::class) @@ -508,7 +507,7 @@ class Account( sendNewAppSpecificData() } - suspend fun updateTranslateTo(languageCode: Locale) { + suspend fun updateTranslateTo(languageCode: String) { if (settings.updateTranslateTo(languageCode)) { sendNewAppSpecificData() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 5e6ad853d..daa174de4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -63,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable -import java.util.Locale val DefaultChannels = listOf( @@ -332,11 +331,11 @@ class AccountSettings( saveAccountSettings() } - fun translateToContains(languageCode: Locale) = + fun translateToContains(languageCode: String) = syncedSettings.languages.translateTo.value - .contains(languageCode.language) + .contains(languageCode) - fun updateTranslateTo(languageCode: Locale): Boolean { + fun updateTranslateTo(languageCode: String): Boolean { if (syncedSettings.languages.updateTranslateTo(languageCode)) { saveAccountSettings() return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index 3789e1c98..a1ab6665b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -27,7 +27,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import java.util.Locale @Stable class AccountSyncedSettings( @@ -165,11 +164,11 @@ class AccountLanguagePreferences( dontTranslateFrom.update { it - languageCode } } - fun translateToContains(languageCode: Locale) = translateTo.value.contains(languageCode.language) + fun translateToContains(languageCode: String) = translateTo.value.contains(languageCode) - fun updateTranslateTo(languageCode: Locale): Boolean { - if (translateTo.value != languageCode.language) { - translateTo.tryEmit(languageCode.language) + fun updateTranslateTo(languageCode: String): Boolean { + if (translateTo.value != languageCode) { + translateTo.tryEmit(languageCode) return true } return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 09a901b7d..312e6b541 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -158,7 +158,6 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.Locale @Stable class AccountViewModel( @@ -1009,7 +1008,7 @@ class AccountViewModel( fun removeDontTranslateFrom(languageCode: String) = launchSigner { account.removeDontTranslateFrom(languageCode) } - fun updateTranslateTo(languageCode: Locale) = launchSigner { account.updateTranslateTo(languageCode) } + fun updateTranslateTo(languageCode: String) = launchSigner { account.updateTranslateTo(languageCode) } fun prefer( source: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt index ef2f01e2c..16af23f49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -213,7 +213,7 @@ fun TranslateToSetting(accountViewModel: AccountViewModel) { verticalAlignment = Alignment.CenterVertically, ) { Text( - text = JavaLocale(currentTranslateTo).displayName, + text = JavaLocale.forLanguageTag(currentTranslateTo).displayName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) @@ -227,7 +227,7 @@ fun TranslateToSetting(accountViewModel: AccountViewModel) { SearchableLanguageList( languages = allLanguages, onSelect = { locale -> - accountViewModel.updateTranslateTo(locale) + accountViewModel.updateTranslateTo(locale.language) showPicker = false }, ) @@ -265,7 +265,7 @@ fun DontTranslateFromSetting(accountViewModel: AccountViewModel) { InputChip( selected = true, onClick = { accountViewModel.removeDontTranslateFrom(languageCode) }, - label = { Text(JavaLocale(languageCode).displayName) }, + label = { Text(JavaLocale.forLanguageTag(languageCode).displayName) }, trailingIcon = { Icon( imageVector = Icons.Default.Close, @@ -367,8 +367,8 @@ private fun LanguagePreferenceCard( preference: String, accountViewModel: AccountViewModel, ) { - val sourceName = JavaLocale(source).displayName - val targetName = JavaLocale(target).displayName + val sourceName = JavaLocale.forLanguageTag(source).displayName + val targetName = JavaLocale.forLanguageTag(target).displayName OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(12.dp)) { diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index a48f700ac..697583552 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -178,9 +178,9 @@ private fun TranslationMessage( buildAnnotatedString { appendLink(stringRes(R.string.translations_auto), textColor) { langSettingsPopupExpanded = !langSettingsPopupExpanded } append(" ${stringRes(R.string.translations_translated_from)} ") - appendLink(Locale(source).displayName, textColor) { onChangeWhatToShow(true) } + appendLink(Locale.forLanguageTag(source).displayName, textColor) { onChangeWhatToShow(true) } append(" ${stringRes(R.string.translations_to)} ") - appendLink(Locale(target).displayName, textColor) { onChangeWhatToShow(false) } + appendLink(Locale.forLanguageTag(target).displayName, textColor) { onChangeWhatToShow(false) } }, style = LocalTextStyle.current.copy( @@ -213,7 +213,7 @@ private fun TranslationMessage( Text( stringRes( R.string.translations_never_translate_from_lang, - Locale(source).displayName, + Locale.forLanguageTag(source).displayName, ), ) } @@ -242,7 +242,7 @@ private fun TranslationMessage( Text( stringRes( R.string.translations_show_in_lang_first, - Locale(source).displayName, + Locale.forLanguageTag(source).displayName, ), ) } @@ -272,7 +272,7 @@ private fun TranslationMessage( Text( stringRes( R.string.translations_show_in_lang_first, - Locale(target).displayName, + Locale.forLanguageTag(target).displayName, ), ) } @@ -292,7 +292,7 @@ private fun TranslationMessage( DropdownMenuItem( text = { Row(verticalAlignment = Alignment.CenterVertically) { - if (accountViewModel.account.settings.translateToContains(lang)) { + if (accountViewModel.account.settings.translateToContains(lang.language)) { Icon( imageVector = Icons.Default.Check, contentDescription = null, @@ -314,7 +314,7 @@ private fun TranslationMessage( }, onClick = { langSettingsPopupExpanded = false - accountViewModel.updateTranslateTo(lang) + accountViewModel.updateTranslateTo(lang.language) }, ) } From bda7e48a841fb462939beda24739f17cde2d43ed Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 20:33:31 -0400 Subject: [PATCH 073/130] removes more warnings --- .../quartz/nip01Core/tags/aTag/TagArrayExt.kt | 2 +- .../quartz/nip64Chess/ChessEngine.jvmAndroid.kt | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt index d79c73717..f3e9df472 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayExt.kt @@ -39,7 +39,7 @@ fun TagArray.isTaggedAddressableKind(kindStr: String) = this.any(ATag::isTaggedW fun TagArray.getTagOfAddressableKind(kind: Int) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kind.toString()) -fun TagArray.getTagOfAddressableKind(kindStr: String) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kindStr.toString()) +fun TagArray.getTagOfAddressableKind(kindStr: String) = this.fastFirstNotNullOfOrNull(ATag::parseIfOfKind, kindStr) fun TagArray.taggedATags() = this.mapNotNull(ATag::parse) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt index 42f5dd891..d9b192d9d 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt @@ -119,7 +119,6 @@ actual class ChessEngine { val sq = Square.fromValue(square.uppercase()) return board .legalMoves() - .filterNotNull() .filter { it.from == sq } .map { it.to.toString().lowercase() } } @@ -132,7 +131,7 @@ actual class ChessEngine { } else { false } - } catch (e: Exception) { + } catch (_: Exception) { false } @@ -156,7 +155,7 @@ actual class ChessEngine { val move = Move(fromSquare, toSquare, promotionPiece) board.legalMoves().contains(move) - } catch (e: Exception) { + } catch (_: Exception) { false } @@ -192,7 +191,7 @@ actual class ChessEngine { val toSquare = move.to val piece = board.getPiece(fromSquare) val pt = piece.pieceType ?: return move.toString() - val promotionPiece = move.promotion ?: Piece.NONE + val promotionPiece = move.promotion // Castling if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) { @@ -213,7 +212,7 @@ actual class ChessEngine { board.getPiece(toSquare) != Piece.NONE || ( pt == com.github.bhlangonijr.chesslib.PieceType.PAWN && - epTarget != null && epTarget != Square.NONE && toSquare == epTarget + epTarget != Square.NONE && toSquare == epTarget ) if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) { @@ -221,7 +220,7 @@ actual class ChessEngine { // Disambiguation: check if other pieces of same type can reach the same square val ambiguous = - board.legalMoves().filterNotNull().filter { + board.legalMoves().filter { it.to == toSquare && board.getPiece(it.from).pieceType == pt && it.from != fromSquare @@ -342,7 +341,7 @@ actual class ChessEngine { blackKingSide = board.castleRight.toString().contains("k"), blackQueenSide = board.castleRight.toString().contains("q"), ), - enPassantSquare = board.enPassantTarget?.let { it.toString().lowercase() }, + enPassantSquare = board.enPassantTarget.toString().lowercase(), halfMoveClock = board.halfMoveCounter, ) } From 8b4a45cb5b980d23e7b553be97b5d90da71bf246 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 20:52:01 -0400 Subject: [PATCH 074/130] Removing deprecated library addresses and deprecated apis --- commons/build.gradle.kts | 16 ++++++++-------- desktopApp/build.gradle.kts | 6 +++--- gradle/libs.versions.toml | 11 +++++++++++ quartz/build.gradle.kts | 25 ++++++++++++++----------- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 1d6ce95e1..c55f9e409 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -49,12 +49,12 @@ kotlin { implementation(project(":quartz")) // Compose Multiplatform - implementation(compose.ui) - implementation(compose.foundation) - implementation(compose.runtime) - implementation(compose.material3) - implementation(compose.materialIconsExtended) - implementation(compose.components.uiToolingPreview) + implementation(libs.jetbrains.compose.ui) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.material.icons.extended) + implementation(libs.jetbrains.compose.ui.tooling.preview) // Lifecycle ViewModel (KMP since 2.8.0) implementation(libs.androidx.lifecycle.viewmodel.compose) @@ -71,7 +71,7 @@ kotlin { api(libs.kotlinx.collections.immutable) // Compose Multiplatform Resources - implementation(compose.components.resources) + implementation(libs.jetbrains.compose.components.resources) } } @@ -94,7 +94,7 @@ kotlin { dependencies { // Desktop-specific Compose implementation(compose.desktop.currentOs) - implementation(compose.uiTooling) + implementation(libs.jetbrains.compose.ui.tooling) // Secure key storage via OS keychain (macOS/Windows/Linux) implementation(libs.java.keyring) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 85c1c5607..1a728e171 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -23,9 +23,9 @@ kotlin { dependencies { implementation(compose.desktop.currentOs) - implementation(compose.material3) - implementation(compose.materialIconsExtended) - implementation(compose.components.resources) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.material.icons.extended) + implementation(libs.jetbrains.compose.components.resources) // Quartz Nostr library (will use JVM target) implementation(project(":quartz")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95a41277f..998b19aa0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,8 @@ lazysodiumJava = "5.2.0" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" +material3 = "1.9.0" +materialIconsExtended = "1.7.3" media3 = "1.9.2" mockk = "1.14.9" kotlinx-coroutines-test = "1.10.2" @@ -53,6 +55,7 @@ spotless = "8.3.0" tarsosdsp = "2.5" torAndroid = "0.4.9.5" translate = "17.0.3" +jetbrainsCompose = "1.10.2" unifiedpush = "3.0.10" vico-charts = "2.4.3" zelory = "3.0.1" @@ -122,6 +125,14 @@ dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.crypto drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } +jetbrains-compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jetbrainsCompose" } +jetbrains-compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "jetbrainsCompose" } +jetbrains-compose-material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } +jetbrains-compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } +jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "jetbrainsCompose" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 488f904f1..a2dc608f3 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -1,11 +1,13 @@ @file:OptIn(ExperimentalSpmForKmpFeature::class) import com.vanniktech.maven.publish.KotlinMultiplatform +import com.vanniktech.maven.publish.SourcesJar import io.github.frankois944.spmForKmp.swiftPackageConfig import io.github.frankois944.spmForKmp.utils.ExperimentalSpmForKmpFeature import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest + plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) @@ -66,17 +68,18 @@ kotlin { val libsodiumDefFile = project.file("src/nativeInterop/cinterop/Clibsodium.def") // This generates the Libsodium definition file, necessary for creating native bindings(a Kotlin API) for libsodium(for iOS). - val libsodiumDefFileGeneration = tasks.register("GenerateSodiumCinteropFile") { - outputs.file(libsodiumDefFile) - doLast { - if (!libsodiumDefFile.exists()) { - libsodiumDefFile.parentFile.mkdirs() - libsodiumDefFile.writeText("package = Clibsodium\n") - libsodiumDefFile.appendText("staticLibraries = libsodium.a libsodium-simulator.a\n") - libsodiumDefFile.appendText("libraryPaths = ${libsodiumPath.absolutePath}/ios/lib ${libsodiumPath.absolutePath}/ios-simulators/lib\n") + val libsodiumDefFileGeneration = + tasks.register("GenerateSodiumCinteropFile") { + outputs.file(libsodiumDefFile) + doLast { + if (!libsodiumDefFile.exists()) { + libsodiumDefFile.parentFile.mkdirs() + libsodiumDefFile.writeText("package = Clibsodium\n") + libsodiumDefFile.appendText("staticLibraries = libsodium.a libsodium-simulator.a\n") + libsodiumDefFile.appendText("libraryPaths = ${libsodiumPath.absolutePath}/ios/lib ${libsodiumPath.absolutePath}/ios-simulators/lib\n") + } } } - } listOf( iosArm64(), @@ -92,7 +95,7 @@ kotlin { headers( "$libsodiumHeaderFilesPath/crypto_aead_xchacha20poly1305.h", "$libsodiumHeaderFilesPath/crypto_core_hchacha20.h", - "$libsodiumHeaderFilesPath/crypto_stream_chacha20.h" + "$libsodiumHeaderFilesPath/crypto_stream_chacha20.h", ) } @@ -315,7 +318,7 @@ mavenPublishing { configure( KotlinMultiplatform( // whether to publish a sources jar - sourcesJar = true, + sourcesJar = SourcesJar.Sources(), ), ) From d10b43c098c2a1db6c09d832aec1b2008b21efe4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 20:55:55 -0400 Subject: [PATCH 075/130] Migrates old Preview annotation --- .../com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt | 2 +- .../com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt | 2 +- .../com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt | 2 +- .../com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt | 2 +- .../com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt | 2 +- .../com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt | 2 +- .../com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt | 2 +- .../com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt | 2 +- .../com/vitorpamplona/amethyst/commons/icons/Following.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt | 2 +- .../kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt | 2 +- .../amethyst/commons/robohash/RobohashAssembler.kt | 2 +- .../amethyst/commons/robohash/parts/Accessory0Seven.kt | 2 +- .../amethyst/commons/robohash/parts/Accessory1Nose.kt | 2 +- .../amethyst/commons/robohash/parts/Accessory2HornRed.kt | 2 +- .../amethyst/commons/robohash/parts/Accessory3Button.kt | 2 +- .../amethyst/commons/robohash/parts/Accessory4Satellite.kt | 2 +- .../amethyst/commons/robohash/parts/Accessory5Mustache.kt | 2 +- .../amethyst/commons/robohash/parts/Body0Trooper.kt | 2 +- .../vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt | 2 +- .../amethyst/commons/robohash/parts/Body2Thinnest.kt | 2 +- .../vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt | 2 +- .../vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt | 2 +- .../vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt | 2 +- .../amethyst/commons/robohash/parts/Body6Ironman.kt | 2 +- .../amethyst/commons/robohash/parts/Body7Neckthinner.kt | 2 +- .../vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt | 2 +- .../vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt | 2 +- 44 files changed, 44 insertions(+), 44 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt index ea46a6db8..e640624d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt @@ -32,8 +32,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt index e4e0aa72d..be8d13a79 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt @@ -33,8 +33,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt index 1a831f0b3..e1de172b1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt index 5daa578c5..53c4ec730 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt index d290cee85..9e0295efe 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt index 7ea0d4104..2630d212e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt index 7362b1ac2..d28818a2d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt @@ -28,8 +28,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt index 41166aadb..bec0a2ff6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt index 242adac53..7b9611921 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt index 50b96eee9..8f8174d98 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt index ee6b91f12..511507d62 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt index dab15b1f6..f4f989545 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt index ccbd67b1e..3c47a957d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt index ddb1fe555..a8aa8bd35 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt index 3cff5f596..338eef364 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt index 7b3631fc7..3924aaa01 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt index 89684d1c9..1a3259529 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Bookmark.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.graphics.StrokeCap.Companion.Butt import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt index 583ebfd72..326474d8a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt index 1bcf1a6b7..52a15572b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt index fd4eb5fb0..d41cc91b3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt index 533fc64f7..b3775729f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt index 32d726838..3d2025d7e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt index f3e0726d6..5d99988b4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt index bab4cda6f..34c17c3b9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt index 40156d93c..345dd88af 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt index a56999f41..73ee2a4d2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path -import org.jetbrains.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt index ba3d83567..252b34265 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index a5c197bb6..6a493dd04 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.robohash.parts.accessory0Seven import com.vitorpamplona.amethyst.commons.robohash.parts.accessory1Nose @@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.commons.robohash.parts.mouth9Closed import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 -import org.jetbrains.compose.ui.tooling.preview.Preview val Black = SolidColor(Color.Black) val Gray = SolidColor(Color(0xFF6d6e70)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt index f2261f6fd..e98d7c67d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt @@ -29,10 +29,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt index 23c05187a..349a44766 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt @@ -27,9 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt index c02c969f4..0faf7f839 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightBrown @@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.robohash.LightGray import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.MediumGray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt index 230f701c2..ae1054843 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt index 0dbbc39b0..cb093dfbb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt index fcff3c8ba..d37661830 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt @@ -27,9 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt index 4adcd72bc..f07e5875d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt index 1b50a772f..992e24088 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt index c5c13ff25..ad57bc5ec 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt index c403b9054..52597a40d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt index 6f3335c7c..dadf2805f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt index 3d2219eab..515a8c5ca 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt index 635c438e5..dc56fa1ef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt @@ -27,11 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt index 0200c6737..20f50c631 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt index 046f6c26c..e0218819e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt index 2c58e6d83..3742241bf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -import org.jetbrains.compose.ui.tooling.preview.Preview @Preview @Composable From 04b4429809b1a51be79fafe92a374d3a200eaa70 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 20:57:32 -0400 Subject: [PATCH 076/130] removes another deprecation --- .../commons/ui/components/BunkerHeartbeatIndicator.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt index 7c295ef91..9a3f43f22 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.rememberTooltipState @@ -69,14 +70,10 @@ fun BunkerHeartbeatIndicator( is SignerConnectionState.Disconnected -> { "Bunker disconnected" } - - else -> { - "" - } } TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), tooltip = { PlainTooltip { Text(tooltipText) } }, state = tooltipState, modifier = modifier, @@ -110,8 +107,6 @@ fun BunkerHeartbeatIndicator( modifier = Modifier.size(20.dp), ) } - - else -> {} } } } From d426dbd4ffff1dd1a28ea2554ff453623bb24f54 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 21:10:59 -0400 Subject: [PATCH 077/130] adds icons for desktop releases --- desktopApp/src/jvmMain/resources/icon.icns | Bin 0 -> 104193 bytes desktopApp/src/jvmMain/resources/icon.ico | Bin 0 -> 4286 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 desktopApp/src/jvmMain/resources/icon.icns create mode 100644 desktopApp/src/jvmMain/resources/icon.ico diff --git a/desktopApp/src/jvmMain/resources/icon.icns b/desktopApp/src/jvmMain/resources/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..c46a41b0b08515483e3c5e9323a9ccfccd2385e3 GIT binary patch literal 104193 zcmeFaRa9KTwl3Tajk~)AcXxM};O_43?(Tu$5P~EG3+{~vw*+@5xI=(&JA3c5&pH2p z?!$e!W88;yQ>&_0%~`cp*BYzpo3p;c&cfLP0L%v3S-5fm04PRrYAUiQh=hm$NJd^x zN*xlT{|WHWkRu&4ng##>478J!RI{_NfSgDRN)wjr7b6}t(U7LciNVEogI6EyWntLX ztx}6bEYr6A(Yrg#YCD_yv6T<81_n+Pw+fYIvR$ARF?(b<>uwvo{ z&EdvK#ucI!gp`TjY9?JpeHlYMV4k!u8`8huWkud^dS_KLM=S%Cpeup+#`3$G3=Hmv zM*ENN<>rD{`$cyjG{*N3}DTlM#v z^6yeJd-KZ2-+h|ob*>NjH1quOd$9GpEJIkRZ{5+)tNowXwi$zL-p!}ikH24j$Jl+8 zp%3e9`F?(JU)8rx>C#X%u(>+3ZNL_xFP^f!d9u25?pr=Bl`{B!cvYJ|*h1;u#l@X} ze5ZhE1&>pmU~mU@NO#xrNuXiAGJEvGkA1iBRz8z*QO`!cz;>#TZmTczkgMydo06gp z$w=(z7+B*qDiif+;0-CFy(yK{Dad2bWhzO;S5adf@*^JeMi&H4RP zMeD-d{nPRJ?akevr=&rL=oJzHJ=Ik-0Al-#)_*eQin3CGSQrvHNCv@GPR|1XK<)bz zfH-stp^%esp7P4la6h5ZfrwO9`AaqsonC)UT~Bp0A97cB7i&95D{@aiS1a;=yc+nD z$}|j;i7Aku53u>eFB|w#q1V*ULc;Koua}qP@^mF)Ihx1H<$4Shd!j#?g`4S$=cRj7 znii&d3n!t#Ea7A9roO=eo(QcP0lz-JLA&ELpZ#KK3Dy$}C3=o9GWKE{B|40JnLLTf z3ij<`tird$9&I&^-c1TC88rRM0{1CL6`sd98O$lT(LY>&(Jm9l{K$8J-#NuIiupPCj$A(=L9o*28Xx1P@P@vkb~yu7

!q@x=mToj1?5{xh;Lk4NH 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 E2RWzav;Y7A literal 0 HcmV?d00001 diff --git a/desktopApp/src/jvmMain/resources/icon.ico b/desktopApp/src/jvmMain/resources/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..61b727bfe589246364734e4732a334c138f6f99f GIT binary patch literal 4286 zcmeI0`BPI@6vxwlpwplFvp;n@ooZWf!?4LJq5^|PkX6N2EoxQV#|?3b8U$Gifq)B8 zG!e><1p%XB5n{rsB1?z}3K~g}L=wWc_a!v;H9;ge(_egm&RdKFc|oJ&3OhUbXrVk$LAc!+5p2n zkDwYFu-1J}e)P-uGT4Zx}ox<3#wzBAY?m0#9^X7-VTk4i_mg)1;&Ou zX)!Dby-n@7(G$Hr(uBIxycl(I8%Zb=Y0jzPFY_?bc5@T!l# zzOu&q$c^yEjR;p5`A&pyPxy9(zl87?621-L|6t%ZCfGpHRrOz-nLI3ejzpimIJY#| z$ai9)>}))F?g^7E^^77e2ic)+xUt1;M!x9$cWBEEflf1GiZR^}(X~>XUK9?tU5J4n z=Mn&EK{?HQboeFm_ijdZm^;aTW`6C(`50)*qQ$TzynUkrFYFXzY(v3bX5?S?3`S3b zm}ahgF@RgU!jKi>I`jOWnU5x}H`EIH`q2VT2~R{VIAgcfnEw<%VMQop;%1sTwHB>K zH&J}#XR`l%!QbkPykG56@Y^z!>|2WRLyJ&(%((t{3BT*FbC{f*q{Xl#^mp`v1Nsm=a}<*15rK-I|wXuYwE^ev5<-E@lf z$_HRGBTV&2@dZ&QFs@cl=VH>24n0FNpN%`G+{yggqUQ8s47QY*?tjdWw+baP!cUm? zKh=NI*Kfg4ckc(?$KNVZm$C=NQAjS2mLJFyq8&E%AeYQ37#yn z2hH=4OC^xiQ+rbnMc-qTk^Wn<&)9z|NdDb5X_mszsC(HZN0QG@Ty)p~ihtS79|cFE z&DNm)dN%5~N1)ef2>%gEDL(0cRQ(Gttb}Ut>5ME(1{0*Vi(^u7fl2y5mA}V2q^%8x zTq32JYe`MKBlAvOgKf2h?K;I@h*rT#(ld25``HMcA~E7UHcs(bNb>SS#YGN=ySvdX zs)1Zw2gS1@lAL(E?e@aOGHE_2>s~I(mIa@{J%@R}o$=?1*_3=fz z|7u(b@W%CEFXR~d|G&<^-Q@f`z=W(;Fk6&G``7a4;BAOBoc~6?A9((2-t)sf=;t5l zzcuUtjJ^6a@8Z-sTM^48^}iM$%Wp48u$YUrN?B7gdH)JQlBXY1R{J2`Zxu3GUbwcw zgPi}a$lbIY{9hc&`NKrnVK4ID*J>@wqTl$K8cqBH+~a0I#LGb46&~uZrQtyq7fm^- ic$k|42|pQ9K_a?p1fI(((-a^M8Q<>-%q9UK#)Z literal 0 HcmV?d00001 From 78ffd5ec6bdfa988b2d95a4fa085295d20c61ab4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 15 Mar 2026 21:32:02 -0400 Subject: [PATCH 078/130] Moves nip06 tests to the right package. --- .../nip06KeyDerivation}/Bip32SeedDerivationCommonTest.kt | 5 +---- .../quartz/nip06KeyDerivation}/Nip06CommonTest.kt | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) rename quartz/src/commonTest/kotlin/{nip06KeyDerivationCommon => com/vitorpamplona/quartz/nip06KeyDerivation}/Bip32SeedDerivationCommonTest.kt (93%) rename quartz/src/commonTest/kotlin/{nip06KeyDerivationCommon => com/vitorpamplona/quartz/nip06KeyDerivation}/Nip06CommonTest.kt (97%) diff --git a/quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Bip32SeedDerivationCommonTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationCommonTest.kt similarity index 93% rename from quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Bip32SeedDerivationCommonTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationCommonTest.kt index 4a6ccb8f9..c9f3040b0 100644 --- a/quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Bip32SeedDerivationCommonTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivationCommonTest.kt @@ -18,12 +18,9 @@ * 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 nip06KeyDerivationCommon +package com.vitorpamplona.quartz.nip06KeyDerivation import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip06KeyDerivation.Bip32SeedDerivation -import com.vitorpamplona.quartz.nip06KeyDerivation.Bip39Mnemonics -import com.vitorpamplona.quartz.nip06KeyDerivation.KeyPath import kotlin.test.Test import kotlin.test.assertEquals diff --git a/quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Nip06CommonTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt similarity index 97% rename from quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Nip06CommonTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt index 5feaa1621..eec79ca77 100644 --- a/quartz/src/commonTest/kotlin/nip06KeyDerivationCommon/Nip06CommonTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Nip06CommonTest.kt @@ -18,10 +18,9 @@ * 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 nip06KeyDerivationCommon +package com.vitorpamplona.quartz.nip06KeyDerivation import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertEquals From e4b4606ab90033ed9aa36f89469177acd860d512 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 16 Mar 2026 07:35:09 +0200 Subject: [PATCH 079/130] 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)) From c56665d4ddcaadc539d68e14dbd21354c6d63ac4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 16 Mar 2026 10:51:24 +0100 Subject: [PATCH 080/130] spotlessApply --- .../loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 26c3d530c..44d7f8c75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -510,6 +510,7 @@ class ChatNewMessageViewModel : onceUploaded?.invoke() } } + RetryMode.SEND -> { if (room == null) return@launchSigner ChatFileUploader(account).justUploadNIP17Unencrypted( From 1c663cb26bde356dbfa11c039e483c48673a1af3 Mon Sep 17 00:00:00 2001 From: Nathan Day Date: Mon, 16 Mar 2026 10:32:18 +0000 Subject: [PATCH 081/130] Fix swipe-back triggering unwanted draft deletion signing When swiping back on a DM screen with an empty message, the BackHandler was calling sendDraftSync() which checks if the message is blank and then calls deleteDraftIgnoreErrors(). This creates and signs a NIP-09 DeletionEvent, prompting the external signer to sign a deletion event. The fix adds a check to only call sendDraftSync() when there's actual content in the message, avoiding the unnecessary signing prompt. --- .../chats/privateDM/send/PrivateMessageEditFieldRow.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 2615ac2ec..18df26d77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -88,10 +88,12 @@ fun PrivateMessageEditFieldRow( nav: INav, ) { BackHandler { - accountViewModel.launchSigner { - channelScreenModel.sendDraftSync() - channelScreenModel.cancel() + if (channelScreenModel.message.text.isNotBlank()) { + accountViewModel.launchSigner { + channelScreenModel.sendDraftSync() + } } + channelScreenModel.cancel() nav.popBack() } From be09535cd6ff447d610d0156fd224588ea13b13c Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 16 Mar 2026 12:17:53 +0000 Subject: [PATCH 082/130] New Crowdin translations by GitHub Action --- .../src/main/res/values-zh-rCN/strings.xml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 6d7eafd38..95b1bad1c 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -673,6 +673,7 @@ 写入到继电器 向该中继发送事件和过滤请求所使用的数据量 从该中继接收事件和过滤响应所使用的数据量 + 已保存的事件 尝试从 %1$s 获取中继器信息时出错 机主 使用者 @@ -1052,6 +1053,29 @@ 全球 短篇 国际象棋 + 钱包 + 余额 + 发送 + 接收 + 交易 + 未连接钱包 + 在打闪设置中设置Nostr Wallet Connect (NWC) 连接以使用钱包。 + 设置钱包 + + 粘贴 BOLT-11 发票 + 付款 + 支付成功 + 正在发送付款… + 聪金额 + 描述(可选) + 创建发票 + 正在创建发票… + 复制发票 + 尚无交易 + 正在加载… + 已收到 + 已发送 + 刷新 安全滤镜 导入关注 新帖子 @@ -1099,6 +1123,13 @@ 取消打闪拆分 添加内容警告 移除内容警告 + 添加过期日期 + 删除过期日期 + 过期日期 + 帖子将在此日期后被客户端隐藏 (NIP-40) + 选择到期日期和时间 + 在 %1$s 过期 + 到期时间 将 npub 显示为二维码 以二维码显示 nprofile 地址无效 @@ -1168,6 +1199,9 @@ 中继黑名单 中继黑名单 应用永远不会连接的中继 + 导出中继设置 + 导出为文本 + 导出为 ZIP (JSON) 打闪开发人员! 你的捐赠帮助我们做出不同的贡献。每个聪都很重要! 立即捐款 @@ -1295,6 +1329,7 @@ 找到了崩溃报告 要用私信将最近的崩溃报告发送给 Amethyst 吗?不会分享个人信息 发送它 + 此消息将在 %1$s 后消失 此消息将在 %1$d 天内消失 选择签名者 已经在列表中 @@ -1523,4 +1558,9 @@ 全选 %1$d%% 运行时间 Namecoin 设置 + 比特币资源管理器 (OTS) + 事件 + 私信 + 个人资料 + 中继设置 From 714afdc2a8c04092e4be60af26b88000134045dc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 09:35:41 -0400 Subject: [PATCH 083/130] Update dependencies --- gradle/libs.versions.toml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 998b19aa0..e232d95dd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] accompanistAdaptive = "0.37.3" cachemapVersion = "0.2.4" -composeMultiplatform = "1.10.1" -activityCompose = "1.12.4" +composeMultiplatform = "1.10.2" +activityCompose = "1.13.0" agp = "9.1.0" android-compileSdk = "36" android-minSdk = "26" @@ -11,13 +11,13 @@ androidKotlinGeohash = "b481c6a64e" androidxJunit = "1.3.0" appcompat = "1.7.1" audiowaveform = "1.1.2" -benchmark = "1.5.0-alpha03" +benchmark = "1.5.0-alpha04" biometricKtx = "1.2.0-alpha05" coil = "3.4.0" -composeBom = "2026.02.01" -composeRuntimeAnnotation = "1.10.4" -coreKtx = "1.17.0" -datastore = "1.2.0" +composeBom = "2026.03.00" +composeRuntimeAnnotation = "1.10.5" +coreKtx = "1.18.0" +datastore = "1.2.1" devWhyolegCryptography = "0.5.0" espressoCore = "3.7.0" firebaseBom = "34.10.0" @@ -53,7 +53,7 @@ secp256k1KmpJniAndroid = "0.22.0" securityCryptoKtx = "1.1.0" spotless = "8.3.0" tarsosdsp = "2.5" -torAndroid = "0.4.9.5" +torAndroid = "0.4.9.5.1" translate = "17.0.3" jetbrainsCompose = "1.10.2" unifiedpush = "3.0.10" @@ -68,7 +68,7 @@ androidxCollection = "1.5.0" kotlinTest = "2.3.0" core = "1.7.0" mavenPublish = "0.36.0" -spmForKmpVersion = "1.4.9" +spmForKmpVersion = "1.4.10" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } From 2a9c9e4510d807d8528abfa64054010f0417d77a Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 15 Mar 2026 23:25:04 +0100 Subject: [PATCH 084/130] fix: allow swipe-to-close when drawer is open on pager screens Keep drawer gestures enabled when the drawer is open so users can swipe left to close it. Only disable gestures when the drawer is closed (to let the pager and ZonedSwipeModifier handle gestures). --- .../ui/components/ZonedSwipeModifier.kt | 90 +++++++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 11 ++- .../AccountSwitcherAndLeftDrawerLayout.kt | 2 + .../chats/rooms/feed/ChatroomListTabs.kt | 8 +- .../ui/screen/loggedIn/home/HomeScreen.kt | 8 +- 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt new file mode 100644 index 000000000..440a8d935 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt @@ -0,0 +1,90 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp + +private val EDGE_ZONE_WIDTH = 48.dp + +fun Modifier.zonedDrawerSwipe( + pagerState: PagerState, + openDrawer: () -> Unit, +): Modifier = + composed { + val edgeZonePx = with(LocalDensity.current) { EDGE_ZONE_WIDTH.toPx() } + + var gestureStartX by remember { mutableFloatStateOf(0f) } + var gestureStartPage by remember { mutableIntStateOf(0) } + var drawerOpened by remember { mutableStateOf(false) } + + val connection = + remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + if (drawerOpened) return Offset(available.x, 0f) + + // available.x > 0 means user is swiping right + if (available.x > 0f) { + val wasOnFirstPage = gestureStartPage == 0 + val isInEdgeZone = gestureStartX < edgeZonePx + + if (wasOnFirstPage || !isInEdgeZone) { + drawerOpened = true + openDrawer() + return Offset(available.x, 0f) + } + } + return Offset.Zero + } + } + } + + this + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + gestureStartX = down.position.x + gestureStartPage = pagerState.currentPage + drawerOpened = false + } + }.nestedScroll(connection) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f4b276b43..d91803756 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -37,8 +37,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri import androidx.core.util.Consumer +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages @@ -142,7 +144,14 @@ fun AppNavigation( ) { val nav = rememberNav() - AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val isTabPagerRoute = + navBackStackEntry?.destination?.let { dest -> + dest.hasRoute() || dest.hasRoute() + } ?: false + val drawerGesturesEnabled = !isTabPagerRoute || nav.drawerState.isOpen + + AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) { NavHost( navController = nav.controller, startDestination = Route.Home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 84ec07af7..8dc7d01f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -49,6 +49,7 @@ fun AccountSwitcherAndLeftDrawerLayout( accountViewModel: AccountViewModel, accountSessionManager: AccountSessionManager, nav: INav, + gesturesEnabled: Boolean = true, content: @Composable () -> Unit, ) { val scope = rememberCoroutineScope() @@ -83,6 +84,7 @@ fun AccountSwitcherAndLeftDrawerLayout( ModalNavigationDrawer( drawerState = nav.drawerState, + gesturesEnabled = gesturesEnabled, drawerContent = { DrawerContent(nav, openSheetFunction, accountViewModel) BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 9f48375a4..5bf51f508 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -124,7 +125,12 @@ fun MessagesPager( HorizontalPager( contentPadding = paddingValues, state = pagerState, - userScrollEnabled = false, + userScrollEnabled = true, + modifier = + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ), ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 86891c7fe..feaeb1543 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState import com.vitorpamplona.amethyst.ui.feeds.PagerStateKeys @@ -211,7 +212,12 @@ private fun HomePages( HorizontalPager( contentPadding = it, state = pagerState, - userScrollEnabled = false, + userScrollEnabled = true, + modifier = + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ), ) { page -> HomeFeeds( feedState = tabs[page].feedState, From 844847d87194148ee14c175ae3942d8a715b9b57 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 16 Mar 2026 14:45:10 +0100 Subject: [PATCH 085/130] use 50/50 split for pager vs dock zones --- .../amethyst/ui/components/ZonedSwipeModifier.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt index 440a8d935..257925144 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt @@ -36,18 +36,16 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp +import androidx.compose.ui.layout.onSizeChanged -private val EDGE_ZONE_WIDTH = 48.dp +private const val PAGER_ZONE_FRACTION = 0.5f fun Modifier.zonedDrawerSwipe( pagerState: PagerState, openDrawer: () -> Unit, ): Modifier = composed { - val edgeZonePx = with(LocalDensity.current) { EDGE_ZONE_WIDTH.toPx() } - + var widthPx by remember { mutableFloatStateOf(1f) } var gestureStartX by remember { mutableFloatStateOf(0f) } var gestureStartPage by remember { mutableIntStateOf(0) } var drawerOpened by remember { mutableStateOf(false) } @@ -65,9 +63,9 @@ fun Modifier.zonedDrawerSwipe( // available.x > 0 means user is swiping right if (available.x > 0f) { val wasOnFirstPage = gestureStartPage == 0 - val isInEdgeZone = gestureStartX < edgeZonePx + val isInPagerZone = gestureStartX < widthPx * PAGER_ZONE_FRACTION - if (wasOnFirstPage || !isInEdgeZone) { + if (wasOnFirstPage || !isInPagerZone) { drawerOpened = true openDrawer() return Offset(available.x, 0f) @@ -79,6 +77,7 @@ fun Modifier.zonedDrawerSwipe( } this + .onSizeChanged { widthPx = it.width.toFloat() } .pointerInput(Unit) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) From 588cfe4c96465865e27973b5c633738a64d6978c Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 16 Mar 2026 16:23:08 +0100 Subject: [PATCH 086/130] =?UTF-8?q?Unnecessary=20=3F.=20/=20=3F:=20/=20!!?= =?UTF-8?q?=20on=20non-null=20Missing=20@OptIn=20annotations=20Parameter?= =?UTF-8?q?=20name=20mismatches=20Icons.Filled.*=20=20/=20Icons.AutoMirror?= =?UTF-8?q?ed.*=20No=20cast=20needed=20/=20remove=20inline=20LocalLifecycl?= =?UTF-8?q?eOwner=20import=20fix=20NIP-51=20name()=20=E2=86=92=20title()?= =?UTF-8?q?=20Chess=20gameId=20=E2=86=92=20startEventId=20Nullable=20Decim?= =?UTF-8?q?alFormat=20safe=20calls=20DelicateCoroutinesApi=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vitorpamplona/amethyst/model/Account.kt | 1 + .../amethyst/model/LocalCache.kt | 19 ++++++++++--------- .../nip47WalletConnect/NwcSignerState.kt | 2 +- .../LabeledBookmarkListsState.kt | 4 ++-- .../nip51Lists/peopleList/PeopleListsState.kt | 4 ++-- .../serverList/MergedFollowListsState.kt | 1 + .../amethyst/service/cashu/v4/V4Models.kt | 4 ++++ .../watchers/EventWatcherSubAssembler.kt | 6 +++--- .../mediaServers/BlossomServersViewModel.kt | 2 +- .../amethyst/ui/note/BadgeCompose.kt | 2 +- .../amethyst/ui/note/UsernameDisplay.kt | 2 +- .../ui/note/ZapFormatterNoDecimals.kt | 8 ++++---- .../amethyst/ui/note/ZapPollNoteViewModel.kt | 4 ++-- .../amethyst/ui/note/types/Chess.kt | 2 +- .../amethyst/ui/note/types/FileStorage.kt | 4 ++-- .../ui/note/types/TextModification.kt | 2 +- .../metadata/ChannelMetadataViewModel.kt | 4 ++-- .../nip99Classifieds/NewProductViewModel.kt | 17 +++++++---------- .../NewPublicMessageViewModel.kt | 6 +++--- .../UserProfileFollowersUserFeedViewModel.kt | 1 + .../UserProfileFollowsUserFeedViewModel.kt | 1 + .../loggedIn/profile/header/apps/WatchApp.kt | 2 +- .../zaps/dal/UserProfileZapsViewModel.kt | 1 + .../loggedIn/relays/RelayInformationScreen.kt | 4 ++-- .../loggedIn/search/SearchBarViewModel.kt | 1 + .../settings/SecurityFiltersScreen.kt | 2 +- .../screen/loggedIn/wallet/WalletViewModel.kt | 2 +- .../amethyst/commons/chess/LiveChessGame.kt | 4 ++-- .../amethyst/commons/chess/MoveNavigator.kt | 8 ++++---- .../amethyst/commons/model/Note.kt | 2 +- 30 files changed, 65 insertions(+), 57 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ca19be854..0c5f5c084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2016,6 +2016,7 @@ class Account( } scope.launch(Dispatchers.IO) { + @OptIn(kotlinx.coroutines.FlowPreview::class) settings.saveable.debounce(1000).collect { if (it.accountSettings != null) { LocalPreferences.saveToEncryptedStorage(it.accountSettings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 750d10ae9..923e7a91b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -358,19 +358,19 @@ object LocalCache : ILocalCache, ICacheProvider { fun load(keys: Set): Set = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) - override fun getOrCreateUser(key: HexKey): User { - require(isValidHex(key = key)) { "$key is not a valid hex" } + override fun getOrCreateUser(pubkey: HexKey): User { + require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" } - return users.getOrCreate(key) { - val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key)) - val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key)) + return users.getOrCreate(pubkey) { + val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(pubkey)) + val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(pubkey)) User(it, nip65RelayListNote, dmRelayListNote) } } - override fun getUserIfExists(key: String): User? { - if (key.isEmpty()) return null - return users.get(key) + override fun getUserIfExists(pubkey: String): User? { + if (pubkey.isEmpty()) return null + return users.get(pubkey) } override fun countUsers(predicate: (String, User) -> Boolean): Int { @@ -394,7 +394,7 @@ object LocalCache : ILocalCache, ICacheProvider { fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address) - override fun getNoteIfExists(key: String): Note? = if (key.length == 64) notes.get(key) else Address.parse(key)?.let { addressables.get(it) } + override fun getNoteIfExists(hexKey: String): Note? = if (hexKey.length == 64) notes.get(hexKey) else Address.parse(hexKey)?.let { addressables.get(it) } fun getNoteIfExists(key: ETag): Note? = notes.get(key.eventId) @@ -2250,6 +2250,7 @@ object LocalCache : ILocalCache, ICacheProvider { requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } + @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { responseCallback(event) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index 206f9bbfd..aa499a6b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -114,7 +114,7 @@ class NwcSignerState( fun hasWalletConnectSetup(): Boolean = nip47Setup.value != null - override fun isNIP47Author(pubkey: HexKey?): Boolean = nip47Signer.value.pubKey == pubkey + override fun isNIP47Author(pubKey: HexKey?): Boolean = nip47Signer.value.pubKey == pubKey /** * Decrypts a NIP-47 payment request using the current signer. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt index 91f5fc1ac..54c03aec6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image -import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.title import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -181,7 +181,7 @@ class LabeledBookmarkListsState( val template = listEvent.update { - if (listName != null) name(listName) + if (listName != null) title(listName) if (listDescription != null) description(listDescription) if (listImage != null) image(listImage) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt index 3787caac9..d279ec20e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.description import com.vitorpamplona.quartz.nip51Lists.peopleList.image -import com.vitorpamplona.quartz.nip51Lists.peopleList.name +import com.vitorpamplona.quartz.nip51Lists.peopleList.title import com.vitorpamplona.quartz.utils.flattenToSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -226,7 +226,7 @@ class PeopleListsState( val template = listEvent.update { - if (listName != null) name(listName) + if (listName != null) title(listName) if (listDescription != null) description(listDescription) if (listImage != null) image(listImage) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt index f3d7ae385..dfb319822 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -79,6 +79,7 @@ class MergedFollowListsState( communities = community.mapTo(mutableSetOf()) { it.address.toValue() }, ) + @OptIn(kotlinx.coroutines.FlowPreview::class) val flow: StateFlow = combine( listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt index 5f2b17690..efd6e12fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/v4/V4Models.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.cashu.v4 +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.cbor.ByteString @@ -34,6 +35,7 @@ class V4Token( val t: Array?, ) +@OptIn(ExperimentalSerializationApi::class) @Serializable class V4T( // identifier @@ -42,6 +44,7 @@ class V4T( val p: Array, ) +@OptIn(ExperimentalSerializationApi::class) @Serializable class V4Proof( // amount @@ -57,6 +60,7 @@ class V4Proof( val w: String? = null, ) +@OptIn(ExperimentalSerializationApi::class) @Serializable class V4DleqProof( @ByteString diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index be3e7e252..91358cdfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -51,14 +51,14 @@ class EventWatcherSubAssembler( } override fun updateFilter( - key: List, + keys: List, since: SincePerRelayMap?, ): List? { - if (key.isEmpty()) { + if (keys.isEmpty()) { return null } - lastNotesOnFilter = key.map { it.note } + lastNotesOnFilter = keys.map { it.note } return groupByRelayPresence(lastNotesOnFilter, latestEOSEs) .map { group -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index 84142984b..23a5e561d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -53,7 +53,7 @@ class BlossomServersViewModel : ViewModel() { fun refresh() { isModified = false _fileServers.update { - val obtainedFileServers = obtainFileServers() ?: emptyList() + val obtainedFileServers = obtainFileServers() obtainedFileServers.mapNotNull { serverUrl -> try { ServerName( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index ac0e28646..321187de8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -62,7 +62,7 @@ fun BadgeCompose( nav: INav, ) { val noteState by observeNote(likeSetCard.note, accountViewModel) - val note = noteState?.note + val note = noteState.note val context = LocalContext.current.applicationContext diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 2e1de3e28..14c5ed899 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -30,10 +30,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt index 2856360ee..ab8aa2015 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapFormatterNoDecimals.kt @@ -49,10 +49,10 @@ fun showAmountInteger(amount: BigDecimal?): String { if (amount.abs() < BigDecimal(0.01)) return "" return when { - amount >= OneGiga -> dfG.get().format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfM.get().format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.get().format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.get().format(amount) + amount >= OneGiga -> dfG.get()?.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) ?: "" + amount >= OneMega -> dfM.get()?.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) ?: "" + amount >= TenKilo -> dfK.get()?.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) ?: "" + else -> dfN.get()?.format(amount) ?: "" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 9d4b31d4a..62d8be2b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt @@ -118,13 +118,13 @@ class PollNoteViewModel : ViewModel() { it.zappedValue.value = zappedValue it.tally.value = tallyValue.toFloat() it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!! - it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false + it.zappedByLoggedIn.value = account.userProfile().let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } } } } fun checkIfCanZap(): Boolean { - val account = account ?: return false + val account = account val note = pollNote ?: return false return account.userProfile() != note.author && !wasZappedByLoggedInAccount } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index 4840d06a1..066de85c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -96,7 +96,7 @@ fun RenderLiveChessChallenge( nav: INav, ) { val event = (note.event as? LiveChessGameChallengeEvent) ?: return - val gameId = event.gameId() ?: return + val gameId = event.gameId() val chessViewModel: ChessViewModelNew = viewModel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt index 6cdcec848..29cb0937a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileStorage.kt @@ -72,9 +72,9 @@ private fun ObserverAndRenderNIP95( val content by remember(noteState) { // Creates a new object when the event arrives to force an update of the image. - val note = noteState?.note + val note = noteState.note val uri = header.toNostrUri() - val localDir = note?.idHex?.let { File(Amethyst.instance.nip95cache, it) } + val localDir = note.idHex.let { File(Amethyst.instance.nip95cache, it) } val blurHash = eventHeader.blurhash() val dimensions = eventHeader.dimensions() val description = eventHeader.alt() ?: eventHeader.content diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt index d69e4a5a0..e7772e9c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -163,7 +163,7 @@ fun RenderTextModificationEvent( } LaunchedEffect(key1 = noteState) { - val newAuthor = accountViewModel.isLoggedUser(noteState?.note?.author) + val newAuthor = accountViewModel.isLoggedUser(noteState.note.author) if (isAuthorTheLoggedUser.value != newAuthor) { isAuthorTheLoggedUser.value = newAuthor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 310d5044a..b1c626720 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -104,7 +104,7 @@ class ChannelMetadataViewModel : ViewModel() { fun createOrUpdate(onDone: (PublicChatChannel) -> Unit) { viewModelScope.launch(Dispatchers.IO) { - account?.let { account -> + account.let { account -> val channel = originalChannel if (channel == null) { val template = @@ -204,7 +204,7 @@ class ChannelMetadataViewModel : ViewModel() { onUploaded: (String) -> Unit, onError: (String, String) -> Unit, ) { - val account = account ?: return + val account = account onUploading(true) val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index c3d359d08..66b46d0a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -220,7 +220,7 @@ open class NewProductViewModel : } open fun quote(quote: Note) { - val accountViewModel = accountViewModel ?: return + val accountViewModel = accountViewModel message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") @@ -307,7 +307,6 @@ open class NewProductViewModel : } suspend fun sendPostSync() { - val accountViewModel = accountViewModel ?: return val template = createTemplate() ?: return val version = draftTag.current @@ -320,8 +319,6 @@ open class NewProductViewModel : } suspend fun sendDraftSync() { - val accountViewModel = accountViewModel ?: return - if (message.text.isBlank()) { accountViewModel.account.deleteDraftIgnoreErrors(draftTag.current) } else { @@ -331,7 +328,7 @@ open class NewProductViewModel : } private suspend fun createTemplate(): EventTemplate? { - val accountViewModel = accountViewModel ?: return null + val accountViewModel = accountViewModel val tagger = NewMessageTagger( @@ -340,7 +337,7 @@ open class NewProductViewModel : ) tagger.run() - val emojis = findEmoji(tagger.message, account?.emoji?.myEmojis?.value) + val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) val urls = findURLs(tagger.message) val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } @@ -399,7 +396,7 @@ open class NewProductViewModel : context: Context, ) { viewModelScope.launch(Dispatchers.IO) { - val myAccount = account ?: return@launch + val myAccount = account val myMultiOrchestrator = multiOrchestrator ?: return@launch mediaUploadTracker.startUpload(myMultiOrchestrator.hasNonMedia()) @@ -501,8 +498,8 @@ open class NewProductViewModel : this.multiOrchestrator?.remove(selected) } - override fun updateMessage(it: TextFieldValue) { - message = it + override fun updateMessage(newMessage: TextFieldValue) { + message = newMessage urlPreviews.update(message) if (message.selection.collapsed) { @@ -616,7 +613,7 @@ open class NewProductViewModel : override fun updateZapFromText() { viewModelScope.launch(Dispatchers.IO) { - val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel!!) + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), accountViewModel) tagger.run() tagger.pTags?.forEach { taggedUser -> if (!forwardZapTo.value.items.any { it.key == taggedUser }) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index fbda70c24..414c808c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -322,7 +322,7 @@ class NewPublicMessageViewModel : } suspend fun sendPostSync() { - val template = createTemplate() ?: return + val template = createTemplate() val extraNotesToBroadcast = mutableListOf() if (nip95attachments.isNotEmpty()) { @@ -354,7 +354,7 @@ class NewPublicMessageViewModel : broadcast.add(it.second) } - val template = createTemplate() ?: return + val template = createTemplate() accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag.current, template, broadcast) } } @@ -459,7 +459,7 @@ class NewPublicMessageViewModel : if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason) nip95attachments = nip95attachments + nip95 - val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) } note?.let { message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt index a614105d4..d931e2cdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt @@ -63,6 +63,7 @@ class UserProfileFollowersUserFeedViewModel( } } + @OptIn(kotlinx.coroutines.FlowPreview::class) val followersFlow: StateFlow> = account.cache .observeEvents(followerFilter) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt index dab557484..0667d781c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt @@ -55,6 +55,7 @@ class UserProfileFollowsUserFeedViewModel( return LocalCache.load(nonHiddenFollows).sortedWith(sortingModel) } + @OptIn(kotlinx.coroutines.FlowPreview::class) val followsFlow: StateFlow> = contactList .flow() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt index 8514dde91..c55f6e580 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/apps/WatchApp.kt @@ -56,7 +56,7 @@ fun WatchApp( LaunchedEffect(key1 = appState) { withContext(Dispatchers.IO) { - (appState?.note?.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData -> + (appState.note.event as? AppDefinitionEvent)?.appMetaData()?.let { metaData -> metaData.picture?.ifBlank { null }?.let { newLogo -> if (newLogo != appLogo) appLogo = newLogo } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 1c95843c2..35fd0bd23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -112,6 +112,7 @@ class UserProfileZapsViewModel( return results.map { (user, amount) -> ZapAmount(user, amount) }.sortedWith(sortingModel) } + @OptIn(kotlinx.coroutines.FlowPreview::class) val receivedZapAmountsByUser: StateFlow> = account.cache .observeEvents(zapsToUser) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 64ebae295..91d26f937 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -47,6 +47,7 @@ import androidx.compose.material.icons.automirrored.filled.Feed import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AttachMoney import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Code @@ -61,7 +62,6 @@ import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Payment import androidx.compose.material.icons.filled.PrivacyTip -import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Topic @@ -931,7 +931,7 @@ private fun OutboxEventsCard(eventIds: Set) { horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( - imageVector = Icons.Default.Send, + imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(12.dp), tint = MaterialTheme.colorScheme.onTertiaryContainer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 028b9eec3..d29bb7037 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -90,6 +90,7 @@ class SearchBarViewModel( val listState: LazyListState = LazyListState(0, 0) + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val directNip05Resolver: Flow = searchTerm .debounce(400) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 21760c45e..1f2473402 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -55,7 +55,6 @@ 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.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -63,6 +62,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index ff670665d..0e7934930 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -99,7 +99,7 @@ class WalletViewModel : ViewModel() { fun init(account: Account) { this.account = account - _hasWalletSetup.value = account.nip47SignerState?.hasWalletConnectSetup() == true + _hasWalletSetup.value = account.nip47SignerState.hasWalletConnectSetup() } fun refreshWalletSetup() { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index 2dba6f760..c04a06f36 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -237,7 +237,7 @@ fun LiveChessGameScreen( ) { // Game info - use currentPosition.activeColor for turn display GameInfoHeader( - gameId = gameState.gameId, + gameId = gameState.startEventId, opponentName = opponentName, playerColor = gameState.playerColor, currentTurn = currentPosition.activeColor, @@ -463,7 +463,7 @@ private fun GameInfoHeader( // Show turn or game result when (gameStatus) { is GameStatus.Finished -> { - val result = (gameStatus as GameStatus.Finished).result + val result = gameStatus.result val resultText = when { result == GameResult.DRAW -> "Draw" diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt index f9a1b51c3..0f5cb9693 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt @@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material3.Icon @@ -82,7 +82,7 @@ fun MoveNavigator( enabled = currentMove > 0, ) { Icon( - Icons.Default.KeyboardArrowLeft, + Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = "Previous move", tint = if (currentMove > 0) { @@ -106,7 +106,7 @@ fun MoveNavigator( enabled = currentMove < totalMoves, ) { Icon( - Icons.Default.KeyboardArrowRight, + Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Next move", tint = if (currentMove < totalMoves) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index a66d32bab..f9c3c3f37 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -1027,7 +1027,7 @@ public inline fun Iterable.filterEvents(predicate: (T) -> Boolean): Li return dest } -public inline fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { +public fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { if (this is Collection && isEmpty()) return emptyList() val dest = ArrayList() From 81ecaa82ffdd20b6a92e6757cfdbe689a7eb6592 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 12:53:54 -0400 Subject: [PATCH 087/130] Migrates to vico charts 3.0 --- amethyst/build.gradle | 2 - .../notifications/NotificationSummaryState.kt | 8 ++-- .../chart/AmountValueFormatter.kt | 6 +-- .../chart/CountAxisValueFormatter.kt | 6 +-- .../chart/LastWeekLabelFormatter.kt | 6 +-- .../loggedIn/notifications/chart/ShowChart.kt | 45 ++++++++++--------- gradle/libs.versions.toml | 9 ++-- 7 files changed, 41 insertions(+), 41 deletions(-) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index fe6c881a8..b6b3c6f2a 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -336,9 +336,7 @@ dependencies { fdroidImplementation libs.unifiedpush // Charts - implementation libs.vico.charts.core implementation libs.vico.charts.compose - implementation libs.vico.charts.views implementation libs.vico.charts.m3 // GeoHash diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt index 105d71a93..f112a4173 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSummaryState.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel -import com.patrykandpatrick.vico.core.cartesian.data.LineCartesianLayerModel -import com.patrykandpatrick.vico.core.common.data.ExtraStore -import com.patrykandpatrick.vico.core.common.data.MutableExtraStore +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.compose.cartesian.data.LineCartesianLayerModel +import com.patrykandpatrick.vico.compose.common.data.ExtraStore +import com.patrykandpatrick.vico.compose.common.data.MutableExtraStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt index 34675af1a..70a2444ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/AmountValueFormatter.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext -import com.patrykandpatrick.vico.core.cartesian.axis.Axis -import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import com.vitorpamplona.amethyst.ui.note.showAmountIntegerWithZero import com.vitorpamplona.amethyst.ui.note.showAmountWithZero import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.ShowDecimals diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt index 7cc79fa6f..50223df25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/CountAxisValueFormatter.kt @@ -21,9 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext -import com.patrykandpatrick.vico.core.cartesian.axis.Axis -import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import kotlin.math.roundToInt @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt index f438df0fd..df1f61ef8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/LastWeekLabelFormatter.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart import android.util.LruCache import androidx.compose.runtime.Stable -import com.patrykandpatrick.vico.core.cartesian.CartesianMeasuringContext -import com.patrykandpatrick.vico.core.cartesian.axis.Axis -import com.patrykandpatrick.vico.core.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import java.time.LocalDateTime import java.time.format.DateTimeFormatter import kotlin.math.roundToInt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt index 961895f1c..8c568b7af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/chart/ShowChart.kt @@ -21,34 +21,34 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.rememberAxisLabelComponent -import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottom -import com.patrykandpatrick.vico.compose.cartesian.axis.rememberEnd -import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStart +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer.AreaFill.Companion.single +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer.Line import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart -import com.patrykandpatrick.vico.compose.common.fill -import com.patrykandpatrick.vico.core.cartesian.axis.Axis -import com.patrykandpatrick.vico.core.cartesian.axis.HorizontalAxis -import com.patrykandpatrick.vico.core.cartesian.axis.VerticalAxis -import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModel -import com.patrykandpatrick.vico.core.cartesian.layer.LineCartesianLayer -import com.patrykandpatrick.vico.core.common.shader.ShaderProvider +import com.patrykandpatrick.vico.compose.common.Fill import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.RoyalBlue -fun makeLine(color: Color): LineCartesianLayer.Line = - LineCartesianLayer.Line( - fill = LineCartesianLayer.LineFill.single(fill(color)), +fun makeLine(color: Color): Line = + Line( + fill = LineCartesianLayer.LineFill.single(Fill(color)), areaFill = - LineCartesianLayer.AreaFill.single( - fill( - ShaderProvider.verticalGradient( - color.copy(alpha = 0.4f).toArgb(), - Color.Transparent.toArgb(), - ), + single( + Fill( + brush = + Brush.verticalGradient( + colors = listOf(color.copy(alpha = 0.4f), Color.Transparent), + ), ), ), pointConnector = LineCartesianLayer.PointConnector.cubic(), @@ -84,7 +84,10 @@ fun ShowChart(model: CartesianChartModel) { ), endAxis = VerticalAxis.rememberEnd( - label = rememberAxisLabelComponent(color = BitcoinOrange), + label = + rememberAxisLabelComponent( + style = TextStyle(color = BitcoinOrange, fontSize = 12.sp), + ), valueFormatter = AmountValueFormatter(), itemPlacer = VerticalAxis.ItemPlacer.count({ 7 }), ), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e232d95dd..a9963706c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,7 +57,7 @@ torAndroid = "0.4.9.5.1" translate = "17.0.3" jetbrainsCompose = "1.10.2" unifiedpush = "3.0.10" -vico-charts = "2.4.3" +vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" zxing = "3.5.4" @@ -164,10 +164,9 @@ secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jn tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } -vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts" } -vico-charts-core = { group = "com.patrykandpatrick.vico", name = "core", version.ref = "vico-charts" } -vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts" } -vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" } +vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } +vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" } +vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts-compose" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } From 1af1383428f5444a66aeb4c9a84a327a7f4e5648 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 16 Mar 2026 17:09:41 +0000 Subject: [PATCH 088/130] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 5 +++++ amethyst/src/main/res/values-de-rDE/strings.xml | 5 +++++ amethyst/src/main/res/values-hu-rHU/strings.xml | 5 +++++ amethyst/src/main/res/values-pt-rBR/strings.xml | 5 +++++ amethyst/src/main/res/values-sv-rSE/strings.xml | 5 +++++ 5 files changed, 25 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index fa6683bf4..56b1c6a49 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -669,6 +669,7 @@ Zapisovat do Relay Množství bajtů, které bylo odesláno na toto relé, včetně filtrů a událostí Množství bajtů, které bylo přijato z tohoto relé, včetně filtrů a událostí + Uložené události Při pokusu o získání informací z Relay se vyskytla chyba z %1$s Vlastník Používáno @@ -1553,4 +1554,8 @@ %1$d%% dostupnost Nastavení Namecoin Průzkumník Bitcoin (OTS) + události + DMs + profily + nastavení relé diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index cfe786197..13ba58cc3 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -674,6 +674,7 @@ anz der Bedingungen ist erforderlich In Relay schreiben Die Menge in Bytes, die an dieses Relais gesendet wurde, einschließlich Filter und Ereignisse Die Menge in Bytes, die von diesem Relais empfangen wurde, einschließlich Filter und Ereignisse + Ereignisse gespeichert Ein Fehler ist beim Abrufen von Relay-Informationen von %1$s aufgetreten Inhaber Verwendet von @@ -1558,4 +1559,8 @@ anz der Bedingungen ist erforderlich %1$d%% Verfügbarkeit Namecoin-Einstellungen Bitcoin Explorer (OTS) + ereignisse + DMs + profile + relaiseinstellungen diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 8615b4bfb..c1b1eed46 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -673,6 +673,7 @@ Írás az átjátszóra Az átjátszónak küldött bájt-mennyiség, beleértve a szűrőket és eseményeket is Az átjátszótól kapott bájt-mennyiség, beleértve a szűrőket és eseményeket is + Tárolt események Hiba lépett fel, amikor megpróbálta lekérni az átjátszó-információt innen: %1$s Tulajdonos Használat a következővel: @@ -1558,4 +1559,8 @@ Üzemidő: %1$d%% Namecoin-beállítások Bitcoin felfedező (OTS) + események + Közvetlen üzenetek + profilok + átjátszóbeállítások diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 3816f9dfb..003f4da75 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -669,6 +669,7 @@ Enviar para o Relay A quantidade em bytes que foi enviada para este relé, incluindo filtros e eventos A quantidade em bytes que foi recebida deste relé, incluindo filtros e eventos + Eventos armazenados Ocorreu um erro ao tentar obter informações do relay de %1$s Proprietário Usado por @@ -1553,4 +1554,8 @@ %1$d%% de disponibilidade Configurações do Namecoin Explorador Bitcoin (OTS) + eventos + DMs + perfils + configurações de Relay diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index d48eddc07..ad0ee1a94 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -668,6 +668,7 @@ Skriv till Relay Mängden data i byte som skickades till detta relä, inklusive filter och händelser Mängden data i byte som mottogs från detta relä, inklusive filter och händelser + Lagrade händelser Ett fel inträffade vid försök att hämta information från Relay %1$s Ägare Används av @@ -1552,4 +1553,8 @@ %1$d%% drifttid Namecoin-inställningar Bitcoin Explorer (OTS) + händelser + DMs + profiler + relä inställningar From 7c22c4e6c5c6ff73cccc6a394a513f33fb022ed3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 13:45:01 -0400 Subject: [PATCH 089/130] Migrates to collections 1.6, which deprecates iOS Intel targets. --- .claude/core-skills-plan.md | 2 +- .../references/dependency-graph.md | 3 +-- .claude/skills/kotlin-multiplatform/SKILL.md | 6 +++--- .../references/source-set-hierarchy.md | 6 ++---- .../references/target-compatibility.md | 5 ++--- gradle/libs.versions.toml | 3 +-- quartz/build.gradle.kts | 19 ++----------------- 7 files changed, 12 insertions(+), 32 deletions(-) diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 6b51cdab0..f85c0d9a1 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -119,7 +119,7 @@ Create 8 hybrid domain skills combining general expertise with AmethystMultiplat **Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation **SKILL.md sections:** -- iOS source sets: iosMain, iosX64Main, iosArm64Main +- iOS source sets: iosMain, iosArm64Main - Swift interop: type mapping, nullability - expect/actual iOS: 10+ examples from quartz/iosMain - XCFramework setup: baseName = "quartz-kmpKit" diff --git a/.claude/skills/gradle-expert/references/dependency-graph.md b/.claude/skills/gradle-expert/references/dependency-graph.md index 3f3820398..9417851c4 100644 --- a/.claude/skills/gradle-expert/references/dependency-graph.md +++ b/.claude/skills/gradle-expert/references/dependency-graph.md @@ -47,7 +47,7 @@ ### :quartz (KMP Nostr Library) **Type:** Kotlin Multiplatform Library -**Targets:** JVM, Android, iOS (iosX64, iosArm64, iosSimulatorArm64) +**Targets:** JVM, Android, iOS (iosArm64, iosSimulatorArm64) **Dependencies:** - External: secp256k1, jackson, okhttp, kotlinx.coroutines, kotlinx.collections.immutable - Source sets: commonMain → jvmAndroid → {androidMain, jvmMain}, iosMain @@ -127,7 +127,6 @@ commonMain (base) │ ├─ androidMain (Android platform) │ └─ jvmMain (Desktop platform) └─ iosMain (iOS platform) - ├─ iosX64Main ├─ iosArm64Main └─ iosSimulatorArm64Main ``` diff --git a/.claude/skills/kotlin-multiplatform/SKILL.md b/.claude/skills/kotlin-multiplatform/SKILL.md index b0aa2f8d6..6f8e43f42 100644 --- a/.claude/skills/kotlin-multiplatform/SKILL.md +++ b/.claude/skills/kotlin-multiplatform/SKILL.md @@ -110,8 +110,8 @@ Think of source sets as a dependency graph, not folders. │ - Jackson │ │ │ │ - OkHttp │ └────┬─────────────┘ └───┬───────────┬───┘ │ - │ │ ├─→ iosX64Main - ▼ ▼ ├─→ iosArm64Main + │ │ │ + ▼ ▼ ├─→ iosArm64Main ┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main │android │ │jvmMain │ │Main │ │(Desktop) │ @@ -252,7 +252,7 @@ expect fun currentTimeSeconds(): Long **iOS (iosMain):** - Active development, framework configured -- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main +- Architecture targets: macosArm64Main, iosArm64Main, iosSimulatorArm64Main - Platform APIs via platform.posix, Security framework ### Web, wasm - Future Targets diff --git a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md index ec09e0033..b950c57d5 100644 --- a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md +++ b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md @@ -29,7 +29,7 @@ Visual guide to source set organization with concrete examples from the codebase │ - Jackson │ │ - Platform libs │ │ - OkHttp │ └───────┬───────────┘ └────┬─────────┬───┘ │ - │ │ ├─→ iosX64Main (simulator Intel) + │ │ │ │ │ ├─→ iosArm64Main (device ARM64) │ │ └─→ iosSimulatorArm64Main (Apple Silicon) ▼ ▼ @@ -238,7 +238,6 @@ iosMain { } } -val iosX64Main by getting { dependsOn(iosMain.get()) } val iosArm64Main by getting { dependsOn(iosMain.get()) } val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) } ``` @@ -249,7 +248,6 @@ val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) } - Different from Android/Desktop **Architecture targets:** -- iosX64Main: Intel simulator - iosArm64Main: Device (iPhone, iPad) - iosSimulatorArm64Main: Apple Silicon simulator @@ -326,7 +324,7 @@ commonMain | androidMain | jvmAndroid | Android framework | Activity, ViewModel | | jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar | | iosMain | commonMain | iOS platform | Security framework | -| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific | +| iosMain | Simulator (Intel) | Architecture-specific | | iosArm64Main | iosMain | Device (ARM64) | Architecture-specific | | jsMain | commonMain | JS/DOM | Web (future) | | wasmMain | commonMain | wasm APIs | WebAssembly (future) | diff --git a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md index 7ccf720fb..c2a4d7129 100644 --- a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md +++ b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md @@ -76,7 +76,6 @@ fun main() = application { **Source sets:** - iosMain (common iOS code) -- iosX64Main (Intel simulator) - iosArm64Main (device - iPhone/iPad) - iosSimulatorArm64Main (Apple Silicon simulator) @@ -110,7 +109,7 @@ actual object Secp256k1Instance { ```kotlin // quartz/build.gradle.kts kotlin { - listOf(iosX64(), iosArm64(), iosSimulatorArm64()) + listOf(macosArm64(), iosArm64(), iosSimulatorArm64()) .forEach { target -> target.binaries.framework { baseName = "quartz-kmpKit" @@ -310,7 +309,7 @@ fun parseJson(json: String): Event { - Manual desktop app testing **iOS:** -- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.) +- Unit tests: iosTest (iosArm64Test, etc.) - Simulator/device testing **Web (future):** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9963706c..9fda673e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,7 +64,7 @@ zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" androidxCamera = "1.5.3" -androidxCollection = "1.5.0" +androidxCollection = "1.6.0" kotlinTest = "2.3.0" core = "1.7.0" mavenPublish = "0.36.0" @@ -103,7 +103,6 @@ androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3- androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } -androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } androidx-media3-ui-compose-material3 = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index a2dc608f3..7912bcf70 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -83,12 +83,11 @@ kotlin { listOf( iosArm64(), - iosX64(), iosSimulatorArm64(), ).forEach { target -> target.compilations.getByName("main") { - val Clibsodium by cinterops.creating { + val clibsodium by cinterops.creating { definitionFile = libsodiumDefFile packageName = "Clibsodium" @@ -99,7 +98,7 @@ kotlin { ) } - tasks.named(cinterops.getByName("Clibsodium").interopProcessingTaskName).configure { + tasks.named(cinterops.getByName("clibsodium").interopProcessingTaskName).configure { dependsOn(libsodiumDefFileGeneration) } } @@ -120,12 +119,6 @@ kotlin { } } - iosX64 { - binaries.framework { - baseName = xcfName - } - } - iosArm64 { binaries.framework { baseName = xcfName @@ -281,10 +274,6 @@ kotlin { } } - val iosX64Main by getting { - dependsOn(iosMain.get()) - } - val iosArm64Main by getting { dependsOn(iosMain.get()) } @@ -299,10 +288,6 @@ kotlin { } } - val iosX64Test by getting { - dependsOn(iosTest.get()) - } - val iosArm64Test by getting { dependsOn(iosTest.get()) } From 21b6f9c67e3b95da8898cd4dfdeaa945150afeeb Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 16 Mar 2026 17:50:19 +0000 Subject: [PATCH 090/130] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index a10363d70..dceddba9f 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -670,6 +670,7 @@ Zapisz do Transmitera Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia Ilość w bajtach, która została otrzymana z tego transmitera, w tym filtry i wydarzenia + Zapisane zdarzenia Wystąpił błąd podczas próby uzyskania informacji o transmiterze z %1$s Operator Używany przez @@ -925,6 +926,8 @@ Upewnij się, że aplikacja logującego autoryzuje tę operację Nie znaleziono portfeli do zapłacenia faktury z Lightning (Error: %1$s). Proszę zainstalować Lightning wallet, aby używać zapów Nie znaleziono portfeli do zapłacenia faktury z Lightning. Proszę zainstalować Lightning wallet, aby używać zapów + Nie można otworzyć linków Blossom + Nie znaleziono aplikacji Blossom. Zainstaluj lokalną aplikację Blossom, aby wyświetlić ten plik Ukryte słowa Ukryj nowe słowo lub wyrażenie Zdjęcie profilowe @@ -1047,6 +1050,7 @@ Wszystkie Filmiki Szachy + Portfel Filtry bezpieczeństwa Importuj Obserwujących Nowy post From bf6ee6dc8708c67cfdab8567677d7db38e6995fb Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 16 Mar 2026 19:01:51 +0100 Subject: [PATCH 091/130] Split the logic between onPreScroll and onPostScroll --- .../ui/components/ZonedSwipeModifier.kt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt index 257925144..b72c34315 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZonedSwipeModifier.kt @@ -60,12 +60,13 @@ fun Modifier.zonedDrawerSwipe( if (source != NestedScrollSource.UserInput) return Offset.Zero if (drawerOpened) return Offset(available.x, 0f) - // available.x > 0 means user is swiping right + // Non-first pages in the drawer zone: intercept before the + // pager consumes the delta to page backwards. if (available.x > 0f) { val wasOnFirstPage = gestureStartPage == 0 val isInPagerZone = gestureStartX < widthPx * PAGER_ZONE_FRACTION - if (wasOnFirstPage || !isInPagerZone) { + if (!wasOnFirstPage && !isInPagerZone) { drawerOpened = true openDrawer() return Offset(available.x, 0f) @@ -73,6 +74,24 @@ fun Modifier.zonedDrawerSwipe( } return Offset.Zero } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + if (drawerOpened) return Offset(available.x, 0f) + + // First page: open drawer only with unconsumed right-swipe + // so child LazyRows can scroll first. + if (available.x > 0f && gestureStartPage == 0) { + drawerOpened = true + openDrawer() + return Offset(available.x, 0f) + } + return Offset.Zero + } } } From 9d95817b4d0cfd589fa7c2d7b4dada7ebeec5fad Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 16 Mar 2026 19:04:01 +0100 Subject: [PATCH 092/130] drawerGesturesEnabled is true whenever the drawer is in transition (target != current) --- .../vitorpamplona/amethyst/ui/navigation/AppNavigation.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d91803756..a613eac3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -149,7 +149,10 @@ fun AppNavigation( navBackStackEntry?.destination?.let { dest -> dest.hasRoute() || dest.hasRoute() } ?: false - val drawerGesturesEnabled = !isTabPagerRoute || nav.drawerState.isOpen + val drawerGesturesEnabled = + !isTabPagerRoute || + nav.drawerState.isOpen || + nav.drawerState.targetValue != nav.drawerState.currentValue AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav, drawerGesturesEnabled) { NavHost( From 1a671fd0b480038b5d4a7b4143be3956d252b22f Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 16 Mar 2026 18:27:11 +0000 Subject: [PATCH 093/130] New Crowdin translations by GitHub Action --- .../src/main/res/values-pl-rPL/strings.xml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index dceddba9f..3e737905b 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1051,6 +1051,28 @@ Filmiki Szachy Portfel + Saldo + Wyślij + Odbierz + Transakcje + Nie podłączono portfela + Aby korzystać z portfela, skonfiguruj połączenie Nostr Wallet Connect (NWC) w ustawieniach zap. + Skonfiguruj portfel + satosze + Wstaw fakturę BOLT-11 + Zapłać + Płatność udana + Wysyłanie zapłaty… + Kwota (satoszy) + Opis (opcjonalnie) + Utwórz fakturę + Tworzenie faktury… + Kopiuj fakturę + Brak dostępnych transakcji + Wczytywanie… + Otrzymano + Wysłano + Odśwież Filtry bezpieczeństwa Importuj Obserwujących Nowy post @@ -1286,6 +1308,7 @@ Wybierz język, na który chcesz przetłumaczyć treść. Ustawienia językowe Dla każdej pary językowej wybierz, który język ma być wyświetlany jako pierwszy. + %1$s - %2$s Wyszukiwanie Języków Dodaj język Dodaj pary językowe @@ -1303,6 +1326,7 @@ Znaleziono raport o błędzie Czy chcesz wysłać ostatni raport o awarii do Amethyst w DM? Żadne dane osobowe nie będą udostępnione Prześlij + Ta wiadomość zniknie za %1$s Ta wiadomość zniknie za %1$d dni Wybierz Sygnatariusza Już jest na liście From abf2151bac43e9aaf5860e8c2340b7b19da050d3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 16:25:16 -0400 Subject: [PATCH 094/130] Damus' relay is way too restrictive --- .../quartz/nip01Core/relay/NostrClientManualSubTest.kt | 2 +- .../quartz/nip01Core/relay/NostrClientRepeatSubTest.kt | 6 +++--- .../nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt | 2 +- .../quartz/nip01Core/relay/NostrClientSubscriptionTest.kt | 2 +- .../relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt index 338a832e8..e974d964d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientManualSubTest.kt @@ -70,7 +70,7 @@ class NostrClientManualSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt index e583343c5..7334cb158 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientRepeatSubTest.kt @@ -83,7 +83,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filters = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(MetadataEvent.KIND), @@ -94,7 +94,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldIgnore = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), @@ -105,7 +105,7 @@ class NostrClientRepeatSubTest : BaseNostrClientTest() { val filtersShouldSendAfterEOSE = mapOf( - RelayUrlNormalizer.normalize("wss://relay.damus.io") to + RelayUrlNormalizer.normalize("wss://nos.lol") to listOf( Filter( kinds = listOf(AdvertisedRelayListEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt index e28b887f2..7afd9caad 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionAsFlowTest.kt @@ -93,7 +93,7 @@ class NostrClientSubscriptionAsFlowTest : BaseNostrClientTest() { val flow = client.reqAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt index 511a0d2a5..00c37f00e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionTest.kt @@ -48,7 +48,7 @@ class NostrClientSubscriptionTest : BaseNostrClientTest() { val sub = client.req( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt index 913bf5eff..de3623968 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -93,7 +93,7 @@ class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { val flow = client.reqUntilEoseAsFlow( - relay = "wss://relay.damus.io", + relay = "wss://nos.lol", filter = Filter( kinds = listOf(MetadataEvent.KIND), From b916817247c433b83cc3adc331306412d36badda Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 16:35:05 -0400 Subject: [PATCH 095/130] Finishes serialization features for NIP-47 --- quartz/build.gradle.kts | 5 + .../KotlinSerializationMapper.kt | 12 ++ .../nip47WalletConnect/NwcTransaction.kt | 2 +- .../quartz/nip47WalletConnect/Request.kt | 8 +- .../quartz/nip47WalletConnect/Response.kt | 2 +- .../kotlinSerialization/JsonExt.kt | 48 +++++ .../Nip47NotificationKSerializer.kt | 42 ++++- .../Nip47RequestKSerializer.kt | 165 +++++++++++++++- .../Nip47ResponseKSerializer.kt | 178 +++++++++++++++++- .../quartz/nip01Core/jackson/JacksonMapper.kt | 6 + .../jackson/NotificationSerializer.kt | 60 ++++++ .../jackson/RequestSerializer.kt | 111 +++++++++++ .../jackson/ResponseSerializer.kt | 146 ++++++++++++++ 13 files changed, 776 insertions(+), 9 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 7912bcf70..b9ffc2add 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -247,9 +247,14 @@ kotlin { } getByName("androidHostTest") { + dependsOn(jvmAndroidTest) dependencies { // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) + + // LibSodium for ChaCha encryption (NIP-44) - Needed for host tests + implementation(libs.lazysodium.java) + implementation(libs.jna) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt index d5b79dbf4..d0f81a3bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -107,6 +107,18 @@ class KotlinSerializationMapper { json.encodeToString(BunkerMessageKSerializer, value) } + is Request -> { + json.encodeToString(Nip47RequestKSerializer, value) + } + + is Response -> { + json.encodeToString(Nip47ResponseKSerializer, value) + } + + is Notification -> { + json.encodeToString(Nip47NotificationKSerializer, value) + } + else -> { throw IllegalArgumentException("Unsupported type: ${value::class}") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt index adff64b56..c0db67e71 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcTransaction.kt @@ -62,7 +62,7 @@ class NwcTransaction( var expires_at: Long? = null, var settled_at: Long? = null, var settle_deadline: Long? = null, - var metadata: Any? = null, + var metadata: Map? = null, ) { fun parsedMetadata(): NwcTransactionMetadata? = NwcTransactionMetadata.parse(metadata) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index 26e5711a3..fe00ca44c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -31,7 +31,7 @@ abstract class Request( class PayInvoiceParams( var invoice: String? = null, var amount: Long? = null, - var metadata: Any? = null, + var metadata: Map? = null, ) class PayInvoiceMethod( @@ -74,7 +74,7 @@ class MakeInvoiceParams( var description: String? = null, var description_hash: String? = null, var expiry: Long? = null, - var metadata: Any? = null, + var metadata: Map? = null, ) class MakeInvoiceMethod( @@ -233,7 +233,7 @@ class CreateConnectionParams( var budget_renewal: String? = null, var expires_at: Long? = null, var isolated: Boolean? = null, - var metadata: Any? = null, + var metadata: Map? = null, ) class CreateConnectionMethod( @@ -249,7 +249,7 @@ class CreateConnectionMethod( budgetRenewal: String? = null, expiresAt: Long? = null, isolated: Boolean? = null, - metadata: Any? = null, + metadata: Map? = null, ): CreateConnectionMethod = CreateConnectionMethod( CreateConnectionParams(pubkey, name, requestMethods, notificationTypes, maxAmount, budgetRenewal, expiresAt, isolated, metadata), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt index 623f0cee6..6547443ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Response.kt @@ -105,7 +105,7 @@ class GetInfoSuccessResponse( val block_hash: String? = null, val methods: List? = null, val notifications: List? = null, - val metadata: Any? = null, + val metadata: Map? = null, val lud16: String? = null, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt new file mode 100644 index 000000000..472972cb6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt @@ -0,0 +1,48 @@ +/* + * 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.quartz.nip47WalletConnect.kotlinSerialization + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +// Helper function to convert JsonElement to standard Kotlin types recursively +fun JsonElement.toAnyValue(): Any = + when (this) { + is JsonPrimitive -> { + if (isString) { + content + } else { + content.toBooleanStrictOrNull() ?: content.toDoubleOrNull() ?: content.toLongOrNull() ?: content + } + } + + is JsonObject -> { + toAnyMap() + } + + is JsonArray -> { + map { it.toAnyValue() } + } + } + +fun JsonObject.toAnyMap(): Map = entries.associate { it.key to it.value.toAnyValue() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt index 811e46fe4..715bd6bee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -32,10 +32,13 @@ import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object Nip47NotificationKSerializer : KSerializer { override val descriptor: SerialDescriptor = @@ -44,7 +47,44 @@ object Nip47NotificationKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Notification, - ): Unit = throw UnsupportedOperationException("NIP-47 Notification serialization not supported") + ) { + val jsonEncoder = encoder as JsonEncoder + val jsonObject = + buildJsonObject { + put("notification_type", value.notification_type) + when (value) { + is PaymentReceivedNotification -> { + Nip47ResponseKSerializer.serializeTransaction(value.notification)?.let { + put("notification", it) + } + } + + is PaymentSentNotification -> { + Nip47ResponseKSerializer.serializeTransaction(value.notification)?.let { + put("notification", it) + } + } + + is HoldInvoiceAcceptedNotification -> { + value.notification?.let { data -> + put( + "notification", + buildJsonObject { + data.type?.let { put("type", it) } + data.invoice?.let { put("invoice", it) } + data.payment_hash?.let { put("payment_hash", it) } + data.amount?.let { put("amount", it) } + data.created_at?.let { put("created_at", it) } + data.expires_at?.let { put("expires_at", it) } + data.settle_deadline?.let { put("settle_deadline", it) } + }, + ) + } + } + } + } + jsonEncoder.encodeJsonElement(jsonObject) + } override fun deserialize(decoder: Decoder): Notification { val jsonDecoder = decoder as JsonDecoder diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index 691627409..87e7e0a0f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -51,15 +51,23 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonNull.content import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object Nip47RequestKSerializer : KSerializer { override val descriptor: SerialDescriptor = @@ -68,7 +76,159 @@ object Nip47RequestKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Request, - ): Unit = throw UnsupportedOperationException("NIP-47 Request serialization not supported") + ) { + val jsonEncoder = encoder as JsonEncoder + val jsonObject = + buildJsonObject { + put("method", value.method) + when (value) { + is PayInvoiceMethod -> { + value.params?.let { put("params", serializePayInvoiceParams(it)) } + } + + is PayKeysendMethod -> { + value.params?.let { put("params", serializePayKeysendParams(it)) } + } + + is MakeInvoiceMethod -> { + value.params?.let { put("params", serializeMakeInvoiceParams(it)) } + } + + is LookupInvoiceMethod -> { + value.params?.let { put("params", serializeLookupInvoiceParams(it)) } + } + + is ListTransactionsMethod -> { + value.params?.let { put("params", serializeListTransactionsParams(it)) } + } + + is GetBalanceMethod -> {} + + is GetInfoMethod -> {} + + is GetBudgetMethod -> {} + + is SignMessageMethod -> { + value.params?.let { put("params", serializeSignMessageParams(it)) } + } + + is CreateConnectionMethod -> { + value.params?.let { put("params", serializeCreateConnectionParams(it)) } + } + + is MakeHoldInvoiceMethod -> { + value.params?.let { put("params", serializeMakeHoldInvoiceParams(it)) } + } + + is CancelHoldInvoiceMethod -> { + value.params?.let { put("params", serializeCancelHoldInvoiceParams(it)) } + } + + is SettleHoldInvoiceMethod -> { + value.params?.let { put("params", serializeSettleHoldInvoiceParams(it)) } + } + } + } + jsonEncoder.encodeJsonElement(jsonObject) + } + + private fun serializePayInvoiceParams(params: PayInvoiceParams): JsonObject = + buildJsonObject { + params.invoice?.let { put("invoice", it) } + params.amount?.let { put("amount", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + + private fun serializePayKeysendParams(params: PayKeysendParams): JsonObject = + buildJsonObject { + params.amount?.let { put("amount", it) } + params.pubkey?.let { put("pubkey", it) } + params.preimage?.let { put("preimage", it) } + params.tlv_records?.let { records -> + put( + "tlv_records", + buildJsonArray { + records.forEach { record -> + add( + buildJsonObject { + record.type?.let { put("type", it) } + record.value?.let { put("value", it) } + }, + ) + } + }, + ) + } + } + + private fun serializeMakeInvoiceParams(params: MakeInvoiceParams): JsonObject = + buildJsonObject { + params.amount?.let { put("amount", it) } + params.description?.let { put("description", it) } + params.description_hash?.let { put("description_hash", it) } + params.expiry?.let { put("expiry", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + + private fun serializeLookupInvoiceParams(params: LookupInvoiceParams): JsonObject = + buildJsonObject { + params.payment_hash?.let { put("payment_hash", it) } + params.invoice?.let { put("invoice", it) } + } + + private fun serializeListTransactionsParams(params: ListTransactionsParams): JsonObject = + buildJsonObject { + params.from?.let { put("from", it) } + params.until?.let { put("until", it) } + params.limit?.let { put("limit", it) } + params.offset?.let { put("offset", it) } + params.unpaid?.let { put("unpaid", it) } + params.unpaid_outgoing?.let { put("unpaid_outgoing", it) } + params.unpaid_incoming?.let { put("unpaid_incoming", it) } + params.type?.let { put("type", it) } + } + + private fun serializeSignMessageParams(params: SignMessageParams): JsonObject = + buildJsonObject { + params.message?.let { put("message", it) } + } + + private fun serializeCreateConnectionParams(params: CreateConnectionParams): JsonObject = + buildJsonObject { + params.pubkey?.let { put("pubkey", it) } + params.name?.let { put("name", it) } + params.request_methods?.let { methods -> + put("request_methods", buildJsonArray { methods.forEach { add(it) } }) + } + params.notification_types?.let { types -> + put("notification_types", buildJsonArray { types.forEach { add(it) } }) + } + params.max_amount?.let { put("max_amount", it) } + params.budget_renewal?.let { put("budget_renewal", it) } + params.expires_at?.let { put("expires_at", it) } + params.isolated?.let { put("isolated", it) } + params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + + private fun serializeMakeHoldInvoiceParams(params: MakeHoldInvoiceParams): JsonObject = + buildJsonObject { + params.amount?.let { put("amount", it) } + params.description?.let { put("description", it) } + params.description_hash?.let { put("description_hash", it) } + params.expiry?.let { put("expiry", it) } + params.payment_hash?.let { put("payment_hash", it) } + params.min_cltv_expiry_delta?.let { put("min_cltv_expiry_delta", it) } + } + + private fun serializeCancelHoldInvoiceParams(params: CancelHoldInvoiceParams): JsonObject = + buildJsonObject { + params.payment_hash?.let { put("payment_hash", it) } + } + + private fun serializeSettleHoldInvoiceParams(params: SettleHoldInvoiceParams): JsonObject = + buildJsonObject { + params.preimage?.let { put("preimage", it) } + } override fun deserialize(decoder: Decoder): Request { val jsonDecoder = decoder as JsonDecoder @@ -100,6 +260,7 @@ object Nip47RequestKSerializer : KSerializer { PayInvoiceParams( invoice = it["invoice"]?.jsonPrimitive?.content, amount = it["amount"]?.jsonPrimitive?.longOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), ) }, ) @@ -135,6 +296,7 @@ object Nip47RequestKSerializer : KSerializer { description = it["description"]?.jsonPrimitive?.content, description_hash = it["description_hash"]?.jsonPrimitive?.content, expiry = it["expiry"]?.jsonPrimitive?.longOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), ) }, ) @@ -194,6 +356,7 @@ object Nip47RequestKSerializer : KSerializer { budget_renewal = it["budget_renewal"]?.jsonPrimitive?.content, expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull, isolated = it["isolated"]?.jsonPrimitive?.booleanOrNull, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index 7c124de3c..e7b9bed88 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -45,13 +45,20 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put object Nip47ResponseKSerializer : KSerializer { override val descriptor: SerialDescriptor = @@ -60,7 +67,154 @@ object Nip47ResponseKSerializer : KSerializer { override fun serialize( encoder: Encoder, value: Response, - ): Unit = throw UnsupportedOperationException("NIP-47 Response serialization not supported") + ) { + val jsonEncoder = encoder as JsonEncoder + val jsonObject = + buildJsonObject { + put("result_type", value.resultType) + when (value) { + is NwcErrorResponse -> { + value.error?.let { put("error", serializeNwcError(it)) } + } + + is PayInvoiceSuccessResponse -> { + value.result?.let { put("result", serializePayInvoiceResult(it)) } + } + + is PayInvoiceErrorResponse -> { + value.error?.let { put("error", serializePayInvoiceErrorParams(it)) } + } + + is PayKeysendSuccessResponse -> { + value.result?.let { put("result", serializePayKeysendResult(it)) } + } + + is MakeInvoiceSuccessResponse -> { + serializeTransaction(value.result)?.let { put("result", it) } + } + + is LookupInvoiceSuccessResponse -> { + serializeTransaction(value.result)?.let { put("result", it) } + } + + is ListTransactionsSuccessResponse -> { + value.result?.let { put("result", serializeListTransactionsResult(it)) } + } + + is GetBalanceSuccessResponse -> { + value.result?.let { put("result", serializeGetBalanceResult(it)) } + } + + is GetInfoSuccessResponse -> { + value.result?.let { put("result", serializeGetInfoResult(it)) } + } + + is GetBudgetSuccessResponse -> { + value.result?.let { put("result", serializeGetBudgetResult(it)) } + } + + is SignMessageSuccessResponse -> { + value.result?.let { put("result", serializeSignMessageResult(it)) } + } + + is CreateConnectionSuccessResponse -> { + value.result?.let { put("result", serializeCreateConnectionResult(it)) } + } + + is MakeHoldInvoiceSuccessResponse -> { + serializeTransaction(value.result)?.let { put("result", it) } + } + + is CancelHoldInvoiceSuccessResponse -> { + put("result", buildJsonObject {}) + } + + is SettleHoldInvoiceSuccessResponse -> { + put("result", buildJsonObject {}) + } + } + } + jsonEncoder.encodeJsonElement(jsonObject) + } + + private fun serializeNwcError(error: NwcError): JsonObject = + buildJsonObject { + error.code?.let { put("code", it.name) } + error.message?.let { put("message", it) } + } + + private fun serializePayInvoiceResult(result: PayInvoiceSuccessResponse.PayInvoiceResultParams): JsonObject = + buildJsonObject { + result.preimage?.let { put("preimage", it) } + result.fees_paid?.let { put("fees_paid", it) } + } + + private fun serializePayInvoiceErrorParams(error: PayInvoiceErrorResponse.PayInvoiceErrorParams): JsonObject = + buildJsonObject { + error.code?.let { put("code", it.name) } + error.message?.let { put("message", it) } + } + + private fun serializePayKeysendResult(result: PayKeysendSuccessResponse.PayKeysendResult): JsonObject = + buildJsonObject { + result.preimage?.let { put("preimage", it) } + result.fees_paid?.let { put("fees_paid", it) } + } + + private fun serializeListTransactionsResult(result: ListTransactionsSuccessResponse.ListTransactionsResult): JsonObject = + buildJsonObject { + result.transactions?.let { transactions -> + put( + "transactions", + buildJsonArray { + transactions.forEach { serializeTransaction(it)?.let { t -> add(t) } } + }, + ) + } + result.total_count?.let { put("total_count", it) } + } + + private fun serializeGetBalanceResult(result: GetBalanceSuccessResponse.GetBalanceResult): JsonObject = + buildJsonObject { + result.balance?.let { put("balance", it) } + } + + private fun serializeGetInfoResult(result: GetInfoSuccessResponse.GetInfoResult): JsonObject = + buildJsonObject { + result.alias?.let { put("alias", it) } + result.color?.let { put("color", it) } + result.pubkey?.let { put("pubkey", it) } + result.network?.let { put("network", it) } + result.block_height?.let { put("block_height", it) } + result.block_hash?.let { put("block_hash", it) } + result.methods?.let { methods -> + put("methods", buildJsonArray { methods.forEach { add(it) } }) + } + result.notifications?.let { notifications -> + put("notifications", buildJsonArray { notifications.forEach { add(it) } }) + } + result.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + result.lud16?.let { put("lud16", it) } + } + + private fun serializeGetBudgetResult(result: GetBudgetSuccessResponse.GetBudgetResult): JsonObject = + buildJsonObject { + result.used_budget?.let { put("used_budget", it) } + result.total_budget?.let { put("total_budget", it) } + result.renews_at?.let { put("renews_at", it) } + result.renewal_period?.let { put("renewal_period", it) } + } + + private fun serializeSignMessageResult(result: SignMessageSuccessResponse.SignMessageResult): JsonObject = + buildJsonObject { + result.message?.let { put("message", it) } + result.signature?.let { put("signature", it) } + } + + private fun serializeCreateConnectionResult(result: CreateConnectionSuccessResponse.CreateConnectionResult): JsonObject = + buildJsonObject { + result.wallet_pubkey?.let { put("wallet_pubkey", it) } + } override fun deserialize(decoder: Decoder): Response { val jsonDecoder = decoder as JsonDecoder @@ -162,6 +316,26 @@ object Nip47ResponseKSerializer : KSerializer { return NwcError(code, obj["message"]?.jsonPrimitive?.content) } + fun serializeTransaction(transaction: NwcTransaction?): JsonObject? { + if (transaction == null) return null + return buildJsonObject { + transaction.type?.let { put("type", it) } + transaction.state?.let { put("state", it) } + transaction.invoice?.let { put("invoice", it) } + transaction.description?.let { put("description", it) } + transaction.description_hash?.let { put("description_hash", it) } + transaction.preimage?.let { put("preimage", it) } + transaction.payment_hash?.let { put("payment_hash", it) } + transaction.amount?.let { put("amount", it) } + transaction.fees_paid?.let { put("fees_paid", it) } + transaction.created_at?.let { put("created_at", it) } + transaction.expires_at?.let { put("expires_at", it) } + transaction.settled_at?.let { put("settled_at", it) } + transaction.settle_deadline?.let { put("settle_deadline", it) } + transaction.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) } + } + } + fun parseTransaction(obj: JsonObject?): NwcTransaction? { if (obj == null) return null return NwcTransaction( @@ -178,6 +352,7 @@ object Nip47ResponseKSerializer : KSerializer { expires_at = obj["expires_at"]?.jsonPrimitive?.longOrNull, settled_at = obj["settled_at"]?.jsonPrimitive?.longOrNull, settle_deadline = obj["settle_deadline"]?.jsonPrimitive?.longOrNull, + metadata = obj["metadata"]?.jsonObject?.toAnyMap(), ) } @@ -260,6 +435,7 @@ object Nip47ResponseKSerializer : KSerializer { block_hash = it["block_hash"]?.jsonPrimitive?.content, methods = it["methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content }, notifications = it["notifications"]?.jsonArray?.map { n -> n.jsonPrimitive.content }, + metadata = it["metadata"]?.jsonObject?.toAnyMap(), lud16 = it["lud16"]?.jsonPrimitive?.content, ) }, diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index 7729b4dd8..34757f17c 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -56,8 +56,11 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Notification import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationSerializer import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestSerializer import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer +import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseSerializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer @@ -93,8 +96,11 @@ class JacksonMapper { .addSerializer(Rumor::class.java, RumorSerializer()) .addDeserializer(Rumor::class.java, RumorDeserializer()) // nip 47 + .addSerializer(Response::class.java, ResponseSerializer()) .addDeserializer(Response::class.java, ResponseDeserializer()) + .addSerializer(Request::class.java, RequestSerializer()) .addDeserializer(Request::class.java, RequestDeserializer()) + .addSerializer(Notification::class.java, NotificationSerializer()) .addDeserializer(Notification::class.java, NotificationDeserializer()) // nip 46 .addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer()) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt new file mode 100644 index 000000000..591197df4 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.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.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification + +class NotificationSerializer : StdSerializer(Notification::class.java) { + override fun serialize( + value: Notification, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + gen.writeStringField("notification_type", value.notification_type) + when (value) { + is PaymentReceivedNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + + is PaymentSentNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + + is HoldInvoiceAcceptedNotification -> { + if (value.notification != null) { + gen.writeObjectField("notification", value.notification) + } + } + } + gen.writeEndObject() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt new file mode 100644 index 000000000..ac34c82a7 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt @@ -0,0 +1,111 @@ +/* + * 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.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod + +class RequestSerializer : StdSerializer(Request::class.java) { + override fun serialize( + value: Request, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + if (value.method != null) { + gen.writeStringField("method", value.method) + } + when (value) { + is PayInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is PayKeysendMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is MakeInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is LookupInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is ListTransactionsMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is MakeHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is CancelHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is SettleHoldInvoiceMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is SignMessageMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + + is CreateConnectionMethod -> { + if (value.params != null) { + gen.writeObjectField("params", value.params) + } + } + } + gen.writeEndObject() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt new file mode 100644 index 000000000..933025b63 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt @@ -0,0 +1,146 @@ +/* + * 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.quartz.nip47WalletConnect.jackson + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.ser.std.StdSerializer +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse + +class ResponseSerializer : StdSerializer(Response::class.java) { + override fun serialize( + value: Response, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeStartObject() + if (value.resultType.isNotEmpty()) { + gen.writeStringField("result_type", value.resultType) + } + when (value) { + is NwcErrorResponse -> { + if (value.error != null) { + gen.writeObjectField("error", value.error) + } + } + + is PayInvoiceErrorResponse -> { + if (value.error != null) { + gen.writeObjectField("error", value.error) + } + } + + is PayInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is PayKeysendSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is MakeInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is LookupInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is ListTransactionsSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetBalanceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetInfoSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is MakeHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is CancelHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is SettleHoldInvoiceSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is GetBudgetSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is SignMessageSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + + is CreateConnectionSuccessResponse -> { + if (value.result != null) { + gen.writeObjectField("result", value.result) + } + } + } + gen.writeEndObject() + } +} From b96c1c2c3a90b5febf749db58b2775dcc6efc8c0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 16:38:31 -0400 Subject: [PATCH 096/130] Fixes iosGzip --- .../com/vitorpamplona/quartz/utils/GZip.ios.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt index 24676f812..708f6f815 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt @@ -73,9 +73,14 @@ actual object GZip { val written = input.usePinned { pinIn -> output.usePinned { pinOut -> - stream.next_in = pinIn.addressOf(0).reinterpret() + if (input.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } stream.avail_in = input.size.toUInt() - stream.next_out = pinOut.addressOf(0).reinterpret() + + if (output.isNotEmpty()) { + stream.next_out = pinOut.addressOf(0).reinterpret() + } stream.avail_out = maxSize.toUInt() deflate(stream.ptr, Z_FINISH) @@ -97,6 +102,8 @@ actual object GZip { * Output is collected in fixed-size chunks to handle arbitrary output size. */ actual fun decompress(content: ByteArray): String { + if (content.isEmpty()) return "" + val chunks = ArrayList() val chunkSize = maxOf(content.size * 4, 4096) @@ -108,7 +115,9 @@ actual object GZip { .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } content.usePinned { pinIn -> - stream.next_in = pinIn.addressOf(0).reinterpret() + if (content.isNotEmpty()) { + stream.next_in = pinIn.addressOf(0).reinterpret() + } stream.avail_in = content.size.toUInt() var status: Int = Z_OK From 95c7adc90b75907b481d0d6d8333150ef46a02bd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 16:53:17 -0400 Subject: [PATCH 097/130] Less warnings --- .../amethyst/commons/search/QueryParserTest.kt | 2 +- .../com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt | 3 ++- .../vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt | 6 +++--- .../vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt | 4 ++-- .../vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt | 2 +- .../com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt | 2 +- .../amethyst/desktop/ui/auth/NewKeyWarningCard.kt | 2 +- .../amethyst/desktop/ui/search/SearchResultsList.kt | 3 ++- 8 files changed, 13 insertions(+), 11 deletions(-) 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 index 4cae7f2c3..df5c3af77 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/QueryParserTest.kt @@ -128,7 +128,7 @@ class QueryParserTest { fun untilDate() { val q = QueryParser.parse("until:2025-12-31") assertNull(q.since) - assertTrue(q.until != null && q.until!! > 0) + assertTrue(q.until != null && q.until > 0) } @Test diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index daf10b553..efc578373 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add @@ -183,7 +184,7 @@ fun ChessScreen( Row(verticalAlignment = Alignment.CenterVertically) { if (selectedGameId != null) { IconButton(onClick = { viewModel.selectGame(null) }) { - Icon(Icons.Default.ArrowBack, "Back to list") + Icon(Icons.AutoMirrored.Default.ArrowBack, "Back to list") } Spacer(Modifier.width(8.dp)) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index e0f5086d1..ae3725798 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -86,9 +86,9 @@ class DesktopIAccount( override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = object : IPrivateZapsDecryptionCache { - override fun cachedPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + override fun cachedPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null - override suspend fun decryptPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + override suspend fun decryptPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null } override fun userProfile(): User = localCache.getOrCreateUser(pubKey) @@ -108,7 +108,7 @@ class DesktopIAccount( override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { if (!isWriteable()) return - val signedEvent = signer.sign(eventTemplate) + val signedEvent = signer.sign(eventTemplate) val recipient = signedEvent.verifiedRecipientPubKey() // Optimistic local add so the message appears immediately diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index eed9c760f..75769e59f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -214,7 +214,7 @@ fun UserProfileScreen( latestMetadataEvent = event } } - } catch (e: Exception) { + } catch (_: Exception) { // Ignore parse errors } } @@ -332,7 +332,7 @@ fun UserProfileScreen( } // Edit button for own profile - if (isOwnProfile && account?.isReadOnly == false) { + if (isOwnProfile && account.isReadOnly == false) { OutlinedButton( onClick = { editingDisplayName = displayName ?: "" diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt index d8c7c1632..bae3732b1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/KeyInputField.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -41,6 +40,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.login_hide_key diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index 67d1bcdf7..ea6c00113 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -54,6 +53,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt index f4a3c432c..573d03586 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyWarningCard.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -35,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res 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 bb43400f9..e02889f84 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 @@ -35,6 +35,7 @@ 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.automirrored.filled.Article import androidx.compose.material.icons.filled.Article import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.ExpandMore @@ -185,7 +186,7 @@ fun SearchResultsList( SortableHeader( title = "Articles", count = articles.size, - icon = Icons.Default.Article, + icon = Icons.AutoMirrored.Default.Article, options = SearchSortOrder.EVENT_OPTIONS, selected = eventSortOrder, onSelect = { state.updateEventSortOrder(it) }, From 874d750bf03649a706ace8287730b507c5451c31 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 17:01:59 -0400 Subject: [PATCH 098/130] removing unused import --- .../amethyst/desktop/ui/search/SearchResultsList.kt | 1 - 1 file changed, 1 deletion(-) 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 e02889f84..2a8a36085 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 @@ -36,7 +36,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article -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 From 7e2912ffd8f995024c1f5b1126e7d3d0275c7c7b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 17:15:33 -0400 Subject: [PATCH 099/130] removes more warnings --- .../quartz/nip49PrivKeyEnc/NIP49Test.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt index c011e785e..c0cee1c9d 100644 --- a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/NIP49Test.kt @@ -26,15 +26,15 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.fail -public class NIP49Test { +class NIP49Test { companion object { - val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + const val TEST_CASE = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" - val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" - val TEST_CASE_PASSWORD = "nostr" + const val TEST_CASE_EXPECTED = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" + const val TEST_CASE_PASSWORD = "nostr" val MAIN_TEST_CASES = - listOf( + listOf( Nip49TestCase(".ksjabdk.aselqwe", "14c226dbdd865d5e1645e72c7470fd0a17feb42cc87b750bab6538171b3a3f8a", 1, 0x00), Nip49TestCase("skjdaklrnçurbç l", "f7f2f77f98890885462764afb15b68eb5f69979c8046ecb08cad7c4ae6b221ab", 2, 0x01), Nip49TestCase("777z7z7z7z7z7z7z", "11b25a101667dd9208db93c0827c6bdad66729a5b521156a7e9d3b22b3ae8944", 3, 0x02), @@ -74,7 +74,7 @@ public class NIP49Test { @Test fun encryptDecryptTestCase() { val encrypted = nip49.encrypt(TEST_CASE_EXPECTED, TEST_CASE_PASSWORD, 16, 0) - val decrypted = nip49.decrypt(encrypted!!, TEST_CASE_PASSWORD) + val decrypted = nip49.decrypt(encrypted, TEST_CASE_PASSWORD) assertEquals(TEST_CASE_EXPECTED, decrypted) } @@ -86,7 +86,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, it.password) + val decrypted = nip49.decrypt(encrypted, it.password) assertEquals(it.secretKey, decrypted) } @@ -105,7 +105,7 @@ public class NIP49Test { assertNotNull(encrypted) - val decrypted = nip49.decrypt(encrypted!!, samePassword2) + val decrypted = nip49.decrypt(encrypted, samePassword2) assertEquals(TEST_CASE_EXPECTED, decrypted) } From 12c27af8139ff147cf48c33f07275b090ed83430 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 17:16:05 -0400 Subject: [PATCH 100/130] cancel other running builds in github actions when a new one comes up --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9bb6fd295..e5985a719 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,10 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint: runs-on: ubuntu-latest From e7760dc7c728b83b226976697a9a595d2b9780ba Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 17:16:18 -0400 Subject: [PATCH 101/130] adds bundleId to the binary file --- quartz/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index b9ffc2add..ade53340e 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -122,12 +122,14 @@ kotlin { iosArm64 { binaries.framework { baseName = xcfName + binaryOption("bundleId", "com.vitorpamplona.quartz") } } iosSimulatorArm64 { binaries.framework { baseName = xcfName + binaryOption("bundleId", "com.vitorpamplona.quartz") } } From 7f61b4819b79e3fc84f4ee009c4bf132d83c22bf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 17:45:05 -0400 Subject: [PATCH 102/130] new log fuctions mocked --- amethyst/src/test/java/android/util/Log.java | 4 ++++ quartz/src/commonTest/kotlin/android/util/Log.kt | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/amethyst/src/test/java/android/util/Log.java b/amethyst/src/test/java/android/util/Log.java index af85d0b47..245a440a1 100644 --- a/amethyst/src/test/java/android/util/Log.java +++ b/amethyst/src/test/java/android/util/Log.java @@ -1,6 +1,10 @@ package android.util; public class Log { + public static Boolean isLoggable(String tag, Integer msg) { + return true; + } + public static int d(String tag, String msg) { System.out.println("DEBUG: " + tag + ": " + msg); return 0; diff --git a/quartz/src/commonTest/kotlin/android/util/Log.kt b/quartz/src/commonTest/kotlin/android/util/Log.kt index ba75a2cab..eb9574b08 100644 --- a/quartz/src/commonTest/kotlin/android/util/Log.kt +++ b/quartz/src/commonTest/kotlin/android/util/Log.kt @@ -24,6 +24,12 @@ import kotlin.jvm.JvmStatic class Log { companion object { + @JvmStatic + fun isLoggable( + tag: String?, + msg: Int?, + ): Boolean = true + @JvmStatic fun d( tag: String?, From f708f6c2653e39827a11d2cb4a3d62a0c82d168b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 17:46:20 -0400 Subject: [PATCH 103/130] Specify which .def file is used between simulators and ios Devices --- quartz/build.gradle.kts | 43 +++++++++++++++---- .../src/nativeInterop/cinterop/Clibsodium.def | 3 -- 2 files changed, 34 insertions(+), 12 deletions(-) delete mode 100644 quartz/src/nativeInterop/cinterop/Clibsodium.def diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index ade53340e..da783590c 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -65,19 +65,36 @@ kotlin { val xcfName = "quartz-kmpKit" val libsodiumPath = project.file("src/nativeInterop/libsodium") val libsodiumHeaderFilesPath = project.file("$libsodiumPath/include/sodium") - val libsodiumDefFile = project.file("src/nativeInterop/cinterop/Clibsodium.def") + + // Generate target-specific Libsodium definition files for creating native bindings. + // Device (iosArm64) uses libsodium.a, simulator targets use libsodium-simulator.a. + val libsodiumDeviceDefFile = + project.layout.buildDirectory + .file("cinterop/Clibsodium-device.def") + .get() + .asFile + val libsodiumSimulatorDefFile = + project.layout.buildDirectory + .file("cinterop/Clibsodium-simulator.def") + .get() + .asFile // This generates the Libsodium definition file, necessary for creating native bindings(a Kotlin API) for libsodium(for iOS). val libsodiumDefFileGeneration = tasks.register("GenerateSodiumCinteropFile") { - outputs.file(libsodiumDefFile) + outputs.files(libsodiumDeviceDefFile, libsodiumSimulatorDefFile) doLast { - if (!libsodiumDefFile.exists()) { - libsodiumDefFile.parentFile.mkdirs() - libsodiumDefFile.writeText("package = Clibsodium\n") - libsodiumDefFile.appendText("staticLibraries = libsodium.a libsodium-simulator.a\n") - libsodiumDefFile.appendText("libraryPaths = ${libsodiumPath.absolutePath}/ios/lib ${libsodiumPath.absolutePath}/ios-simulators/lib\n") - } + libsodiumDeviceDefFile.parentFile.mkdirs() + libsodiumDeviceDefFile.writeText( + "package = Clibsodium\n" + + "staticLibraries = libsodium.a\n" + + "libraryPaths = ${libsodiumPath.absolutePath}/ios/lib\n", + ) + libsodiumSimulatorDefFile.writeText( + "package = Clibsodium\n" + + "staticLibraries = libsodium-simulator.a\n" + + "libraryPaths = ${libsodiumPath.absolutePath}/ios-simulators/lib\n", + ) } } @@ -85,10 +102,12 @@ kotlin { iosArm64(), iosSimulatorArm64(), ).forEach { target -> + val isSimulator = target.name != "iosArm64" + val defFile = if (isSimulator) libsodiumSimulatorDefFile else libsodiumDeviceDefFile target.compilations.getByName("main") { val clibsodium by cinterops.creating { - definitionFile = libsodiumDefFile + definitionFile = defFile packageName = "Clibsodium" headers( @@ -120,6 +139,9 @@ kotlin { } iosArm64 { + binaries.all { + linkerOpts("-L${libsodiumPath.absolutePath}/ios/lib", "-lsodium") + } binaries.framework { baseName = xcfName binaryOption("bundleId", "com.vitorpamplona.quartz") @@ -127,6 +149,9 @@ kotlin { } iosSimulatorArm64 { + binaries.all { + linkerOpts("-L${libsodiumPath.absolutePath}/ios-simulators/lib", "-lsodium-simulator") + } binaries.framework { baseName = xcfName binaryOption("bundleId", "com.vitorpamplona.quartz") diff --git a/quartz/src/nativeInterop/cinterop/Clibsodium.def b/quartz/src/nativeInterop/cinterop/Clibsodium.def deleted file mode 100644 index a60f3fda6..000000000 --- a/quartz/src/nativeInterop/cinterop/Clibsodium.def +++ /dev/null @@ -1,3 +0,0 @@ -package = Clibsodium -staticLibraries = libsodium.a libsodium-simulator.a -libraryPaths = src/nativeInterop/libsodium/ios/lib src/nativeInterop/libsodium/ios-simulators/lib From 790fd45470bdb49958143ce266c058a0d07f0a5a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 18:10:55 -0400 Subject: [PATCH 104/130] Removes warnings --- .../vitorpamplona/quartz/utils/UriParser.ios.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt index 05c06c547..ba0890c38 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.ios.kt @@ -26,32 +26,32 @@ import platform.Foundation.NSURLQueryItem actual class UriParser actual constructor( uri: String, ) { - private val nsUrlComponents: NSURLComponents? = NSURLComponents(string = uri) + private val nsUrlComponents: NSURLComponents = NSURLComponents(string = uri) - actual fun scheme(): String? = nsUrlComponents?.scheme + actual fun scheme(): String? = nsUrlComponents.scheme - actual fun host(): String? = nsUrlComponents?.host + actual fun host(): String? = nsUrlComponents.host actual fun port(): Int? { // The NSNumber?.intValue is a way to handle a nullable port and convert it // from a platform-specific number type to a Kotlin Int. - return nsUrlComponents?.port?.intValue + return nsUrlComponents.port?.intValue } - actual fun path(): String? = nsUrlComponents?.path + actual fun path(): String? = nsUrlComponents.path actual fun queryParameterNames(): Set { - val queryItems = nsUrlComponents?.queryItems ?: return emptySet() + val queryItems = nsUrlComponents.queryItems ?: return emptySet() return queryItems.mapNotNull { (it as? NSURLQueryItem)?.name }.toSet() } actual fun getQueryParameter(param: String): String? { - val queryItems = nsUrlComponents?.queryItems ?: return null + val queryItems = nsUrlComponents.queryItems ?: return null return (queryItems.firstOrNull { (it as? NSURLQueryItem)?.name == param } as? NSURLQueryItem)?.value } val fragments: Map by lazy { - nsUrlComponents?.fragment()?.ifBlank { null }?.let { keyValuePair -> + nsUrlComponents.fragment()?.ifBlank { null }?.let { keyValuePair -> keyValuePair.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { From 6ffc902598f1fe3b2b794fdee68e00f10372796c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 18:15:43 -0400 Subject: [PATCH 105/130] Unifies UriParser --- .../quartz/utils/UriParser.android.kt | 61 ------------------- .../quartz/utils/UriParser.jvmAndroid.kt} | 16 ++--- 2 files changed, 8 insertions(+), 69 deletions(-) delete mode 100644 quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt rename quartz/src/{jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt => jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt} (86%) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt deleted file mode 100644 index 7d157db80..000000000 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.android.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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.quartz.utils - -import androidx.core.net.toUri -import java.net.URLDecoder - -actual class UriParser actual constructor( - uri: String, -) { - val myUri = uri.toUri() - - val fragments: Map by lazy { - myUri.fragment?.ifBlank { null }?.let { keyValuePair -> - keyValuePair.split('&').associate { paramValue -> - val parts = paramValue.split("=", limit = 2) - if (parts.size == 2) { - parts[0] to URLDecoder.decode(parts[1], "UTF-8") - } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" - } - } - } ?: emptyMap() - } - - actual fun scheme(): String? = myUri.scheme - - actual fun host(): String? = myUri.host - - actual fun port(): Int? { - // android.net.Uri.getPort() returns -1 if the port is not set, so we handle that case. - val port = myUri.port - return if (port == -1) null else port - } - - actual fun path(): String? = myUri.path - - actual fun queryParameterNames() = myUri.queryParameterNames - - actual fun getQueryParameter(param: String) = myUri.getQueryParameter(param) - - actual fun fragments(): Map = fragments -} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt similarity index 86% rename from quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt rename to quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt index ebdf48970..3e75d0dc8 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvm.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/UriParser.jvmAndroid.kt @@ -26,28 +26,29 @@ import java.net.URLDecoder actual class UriParser actual constructor( uri: String, ) { - val myUri = URI.create(uri) - val queryParameters: Map by lazy { + private val myUri = URI.create(uri) + + private val queryParameters: Map by lazy { myUri.query?.ifBlank { null }?.let { query -> query.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { - parts[0] to parts[1] + parts[0] to URLDecoder.decode(parts[1], "UTF-8") } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" + parts[0] to "" // Handle parameters without a value } } } ?: emptyMap() } - val fragments: Map by lazy { + private val fragments: Map by lazy { myUri.rawFragment?.ifBlank { null }?.let { keyValuePair -> keyValuePair.split('&').associate { paramValue -> val parts = paramValue.split("=", limit = 2) if (parts.size == 2) { parts[0] to URLDecoder.decode(parts[1], "UTF-8") } else { - parts[0] to "" // Handle parameters without a value, e.g., "param&other=value" + parts[0] to "" // Handle parameters without a value } } } ?: emptyMap() @@ -58,7 +59,6 @@ actual class UriParser actual constructor( actual fun host(): String? = myUri.host actual fun port(): Int? { - // java.net.URI.getPort() returns -1 if the port is not set, so we handle that case. val port = myUri.port return if (port == -1) null else port } @@ -69,5 +69,5 @@ actual class UriParser actual constructor( actual fun getQueryParameter(param: String): String? = queryParameters[param] - actual fun fragments() = fragments + actual fun fragments(): Map = fragments } From b0c714176a9327ee31010522380145b9d4a965a7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 18:46:39 -0400 Subject: [PATCH 106/130] Updates versions for github actions --- .github/workflows/build.yml | 38 ++++++++++++++-------------- .github/workflows/create-release.yml | 12 ++++----- .github/workflows/crowdin.yml | 2 +- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5985a719..0377b3735 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,16 +16,16 @@ jobs: timeout-minutes: 15 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 - name: Cache gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches @@ -50,16 +50,16 @@ jobs: shell: bash steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 - name: Cache gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches @@ -76,7 +76,7 @@ jobs: if: ${{ always() && matrix.os == 'ubuntu-latest' }} - name: Upload Test Results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 if: ${{ always() && matrix.os == 'ubuntu-latest' }} with: name: Test Reports @@ -88,16 +88,16 @@ jobs: timeout-minutes: 30 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 - name: Cache gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches @@ -110,13 +110,13 @@ jobs: run: ./gradlew assembleDebug - name: Upload Play APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Play Debug APK path: amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk - name: Upload FDroid APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: FDroid Debug APK path: amethyst/build/outputs/apk/fdroid/debug/amethyst-fdroid-universal-debug.apk @@ -125,19 +125,19 @@ jobs: run: ./gradlew assembleBenchmark - name: Upload Play APK Benchmark - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Play Benchmark APK path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk - name: Upload FDroid APK Benchmark - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: FDroid Benchmark APK path: amethyst/build/outputs/apk/fdroid/benchmark/amethyst-fdroid-universal-benchmark.apk - name: Upload Compose Reports - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Compose Reports path: amethyst/build/compose_compiler @@ -167,16 +167,16 @@ jobs: shell: bash steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 - name: Cache gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches @@ -189,7 +189,7 @@ jobs: run: ./gradlew :desktopApp:${{ matrix.task }} - name: Upload Desktop Distribution - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ matrix.artifact-name }} path: ${{ matrix.artifact-path }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 70f337ecd..beb08874b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -27,16 +27,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 - name: Cache gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches @@ -263,16 +263,16 @@ jobs: shell: bash steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: 21 - name: Cache gradle - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.gradle/caches diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index d2707654d..923e7fa49 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: crowdin action uses: crowdin/github-action@v2 From 962da3280bd421630188a07d198bb01b957a2446 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 18:57:00 -0400 Subject: [PATCH 107/130] Moves the N44 test to Jvmtest --- .../com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename quartz/src/{commonTest/kotlin => jvmTest/java}/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt (100%) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt similarity index 100% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt rename to quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt From 56cce46858f1e16a158a9e47325e49f7d39d86b0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 19:22:28 -0400 Subject: [PATCH 108/130] Solving all problems with Quartz tests --- quartz/build.gradle.kts | 13 ++- .../LnZapPaymentRequestNip44EventTest.kt | 84 +++++++++++++++++++ .../LnZapPaymentRequestEventTest.kt | 49 ----------- .../LnZapPaymentRequestNip44EventTest.kt | 81 ++++++++++++++++++ .../LnZapPaymentRequestNip44EventTest.kt | 81 ++++++++++++++++++ 5 files changed, 258 insertions(+), 50 deletions(-) create mode 100644 quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt create mode 100644 quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt create mode 100644 quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index da783590c..f0d4f6815 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -274,8 +274,10 @@ kotlin { } getByName("androidHostTest") { - dependsOn(jvmAndroidTest) dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + // Bitcoin secp256k1 bindings implementation(libs.secp256k1.kmp.jni.jvm) @@ -291,7 +293,16 @@ kotlin { implementation(libs.androidx.core) implementation(libs.androidx.junit) implementation(libs.androidx.espresso.core) + + implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) + + // Bitcoin secp256k1 bindings to Android + api(libs.secp256k1.kmp.jni.android) + + // LibSodium for ChaCha encryption (NIP-44) + implementation("com.goterl:lazysodium-android:5.2.0@aar") + implementation("net.java.dev.jna:jna:5.18.1@aar") } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 000000000..9175d0818 --- /dev/null +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,84 @@ +/* + * 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.quartz.nip47WalletConnect + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@RunWith(AndroidJUnit4::class) +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt index 271c2783b..18a1f577c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestEventTest.kt @@ -86,29 +86,6 @@ class LnZapPaymentRequestEventTest { assertNull(event.encryptionScheme()) } - @Test - fun testCreateRequestWithNip44() = - runTest { - val clientKeyPair = KeyPair() - val walletKeyPair = KeyPair() - val clientSigner = NostrSignerInternal(clientKeyPair) - val walletServicePubkey: HexKey = - walletKeyPair.pubKey.toHexKey() - - val request = GetBalanceMethod.create() - val event = - LnZapPaymentRequestEvent.createRequest( - request = request, - walletServicePubkey = walletServicePubkey, - signer = clientSigner, - createdAt = 1000L, - useNip44 = true, - ) - - assertEquals(23194, event.kind) - assertEquals("nip44_v2", event.encryptionScheme()) - } - @Test fun testDecryptPayInvoiceRequest() = runTest { @@ -156,32 +133,6 @@ class LnZapPaymentRequestEventTest { assertEquals("test payment", decrypted.params?.description) } - @Test - fun testDecryptNip44Request() = - runTest { - val clientKeyPair = KeyPair() - val walletKeyPair = KeyPair() - val clientSigner = NostrSignerInternal(clientKeyPair) - val walletSigner = NostrSignerInternal(walletKeyPair) - val walletServicePubkey: HexKey = - walletKeyPair.pubKey.toHexKey() - - val request = GetInfoMethod.create() - val event = - LnZapPaymentRequestEvent.createRequest( - request = request, - walletServicePubkey = walletServicePubkey, - signer = clientSigner, - useNip44 = true, - ) - - assertEquals("nip44_v2", event.encryptionScheme()) - - // Wallet service should be able to decrypt NIP-44 encrypted request - val decrypted = event.decryptRequest(walletSigner) - assertIs(decrypted) - } - @Test fun testCanDecrypt() = runTest { diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 000000000..fa07bc58e --- /dev/null +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,81 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} diff --git a/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt new file mode 100644 index 000000000..fa07bc58e --- /dev/null +++ b/quartz/src/jvmTest/java/com/vitorpamplona/quartz/nip47WalletConnect/LnZapPaymentRequestNip44EventTest.kt @@ -0,0 +1,81 @@ +/* + * 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.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class LnZapPaymentRequestNip44EventTest { + @Test + fun testCreateRequestWithNip44() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetBalanceMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + createdAt = 1000L, + useNip44 = true, + ) + + assertEquals(23194, event.kind) + assertEquals("nip44_v2", event.encryptionScheme()) + } + + @Test + fun testDecryptNip44Request() = + runTest { + val clientKeyPair = KeyPair() + val walletKeyPair = KeyPair() + val clientSigner = NostrSignerInternal(clientKeyPair) + val walletSigner = NostrSignerInternal(walletKeyPair) + val walletServicePubkey: HexKey = + walletKeyPair.pubKey.toHexKey() + + val request = GetInfoMethod.create() + val event = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletServicePubkey, + signer = clientSigner, + useNip44 = true, + ) + + assertEquals("nip44_v2", event.encryptionScheme()) + + // Wallet service should be able to decrypt NIP-44 encrypted request + val decrypted = event.decryptRequest(walletSigner) + assertIs(decrypted) + } +} From d0297b7e343c1ee46e0d3dc8081c410f954d4ea1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 16 Mar 2026 19:44:51 -0400 Subject: [PATCH 109/130] Fixes stability plugin for AGP9 --- amethyst/build.gradle | 11 +++++++++++ gradle/libs.versions.toml | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index b6b3c6f2a..70b037908 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -36,6 +36,17 @@ def generateVersionName(String baseVersion) { } } +// Workaround: stability.analyzer plugin doesn't declare task dependencies properly for Gradle 9.x +afterEvaluate { + def stabilityNames = tasks.names.findAll { it.contains("StabilityCheck") } + def compileNames = tasks.names.findAll { it.matches("compile.*UnitTestKotlin") } + stabilityNames.each { scName -> + compileNames.each { ctName -> + tasks.named(scName).configure { mustRunAfter(tasks.named(ctName)) } + } + } +} + android { namespace = 'com.vitorpamplona.amethyst' compileSdk = libs.versions.android.compileSdk.get().toInteger() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9fda673e2..d6a717466 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,7 @@ kotlinTest = "2.3.0" core = "1.7.0" mavenPublish = "0.36.0" spmForKmpVersion = "1.4.10" +stabilityAnalyser = "0.7.0" [libraries] abedElazizShe-video-compressor-fork = { group = "com.github.davotoula", name = "LightCompressor-enhanced", version.ref = "lightcompressor-enhanced" } @@ -187,6 +188,6 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.7.0" } +stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version.ref = "stabilityAnalyser" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } frankois944-spmForKmp = { id = "io.github.frankois944.spmForKmp", version.ref = "spmForKmpVersion" } From 4a430e6663350c5e3f8666c66f2aca39304ba86c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 02:09:44 +0000 Subject: [PATCH 110/130] feat: auto-load wallet transactions on scroll Add infinite scroll pagination to the wallet transactions screen. When the user scrolls within 5 items of the bottom, the next page of transactions is automatically fetched and appended. Uses the existing NWC list_transactions limit/offset support and total_count to know when all transactions have been loaded. https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj --- .../wallet/WalletTransactionsScreen.kt | 34 +++++++++ .../screen/loggedIn/wallet/WalletViewModel.kt | 70 +++++++++++++++++-- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index 0f1818809..3dede2f18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -32,6 +32,7 @@ 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.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDownward @@ -49,6 +50,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -86,6 +88,24 @@ fun WalletTransactionsScreen( val transactions by walletViewModel.transactions.collectAsState() val isLoading by walletViewModel.isLoading.collectAsState() + val isLoadingMore by walletViewModel.isLoadingMore.collectAsState() + val hasMore by walletViewModel.hasMoreTransactions.collectAsState() + + val listState = rememberLazyListState() + + val shouldLoadMore by remember { + derivedStateOf { + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItems = listState.layoutInfo.totalItemsCount + lastVisibleIndex >= totalItems - 5 && !isLoadingMore && hasMore && transactions.isNotEmpty() + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + walletViewModel.loadMoreTransactions() + } + } Scaffold( topBar = { @@ -144,11 +164,25 @@ fun WalletTransactionsScreen( } else { LazyColumn( modifier = Modifier.padding(padding), + state = listState, ) { items(transactions) { tx -> TransactionItem(tx, accountViewModel, nav) HorizontalDivider() } + if (isLoadingMore) { + item { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 0e7934930..48cc4bdd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -88,6 +88,14 @@ class WalletViewModel : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore = _isLoadingMore.asStateFlow() + + private val _hasMoreTransactions = MutableStateFlow(true) + val hasMoreTransactions = _hasMoreTransactions.asStateFlow() + + private val pageSize = 20 + private val _error = MutableStateFlow(null) val error = _error.asStateFlow() @@ -153,24 +161,30 @@ class WalletViewModel : ViewModel() { } } - fun fetchTransactions( - limit: Int = 20, - offset: Int = 0, - ) { + fun fetchTransactions() { val acc = account ?: return viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true + _hasMoreTransactions.value = true try { acc.sendNwcRequest( ListTransactionsMethod.create( - limit = limit, - offset = offset, + limit = pageSize, + offset = 0, unpaid = false, ), ) { response -> when (response) { is ListTransactionsSuccessResponse -> { - _transactions.value = response.result?.transactions ?: emptyList() + val txs = response.result?.transactions ?: emptyList() + _transactions.value = txs + val totalCount = response.result?.total_count + _hasMoreTransactions.value = + if (totalCount != null) { + txs.size < totalCount + } else { + txs.size >= pageSize + } } is NwcErrorResponse -> { @@ -188,6 +202,48 @@ class WalletViewModel : ViewModel() { } } + fun loadMoreTransactions() { + if (_isLoadingMore.value || !_hasMoreTransactions.value) return + val acc = account ?: return + val currentOffset = _transactions.value.size + viewModelScope.launch(Dispatchers.IO) { + _isLoadingMore.value = true + try { + acc.sendNwcRequest( + ListTransactionsMethod.create( + limit = pageSize, + offset = currentOffset, + unpaid = false, + ), + ) { response -> + when (response) { + is ListTransactionsSuccessResponse -> { + val newTxs = response.result?.transactions ?: emptyList() + _transactions.value = _transactions.value + newTxs + val totalCount = response.result?.total_count + _hasMoreTransactions.value = + if (totalCount != null) { + _transactions.value.size < totalCount + } else { + newTxs.size >= pageSize + } + } + + is NwcErrorResponse -> { + _error.value = response.error?.message ?: "Failed to load more transactions" + } + + else -> {} + } + _isLoadingMore.value = false + } + } catch (e: Exception) { + _error.value = e.message + _isLoadingMore.value = false + } + } + } + fun sendPayment(bolt11: String) { val acc = account ?: return viewModelScope.launch(Dispatchers.IO) { From 146f3f91b51336add8f583a22569a7341fc011c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 02:20:47 +0000 Subject: [PATCH 111/130] feat: add GitHub Action to build benchmark APK on claude branches Adds a lightweight workflow that triggers on pushes to claude/* branches, building only the Play Benchmark APK for quick testing after Claude completes a task. https://claude.ai/code/session_013hcKzBgJgfsrArSgTTGNEk --- .github/workflows/build-benchmark-apk.yml | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/build-benchmark-apk.yml diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml new file mode 100644 index 000000000..cb1d8dd21 --- /dev/null +++ b/.github/workflows/build-benchmark-apk.yml @@ -0,0 +1,43 @@ +name: Build Benchmark APK + +on: + push: + branches: + - 'claude/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-benchmark: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Cache gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build Benchmark APK + run: ./gradlew assemblePlayBenchmark + + - name: Upload Play Benchmark APK + uses: actions/upload-artifact@v6 + with: + name: Play Benchmark APK + path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk From 61e85df8dbb07b60ee44dca0963fa34191dc1458 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 02:27:53 +0000 Subject: [PATCH 112/130] feat: post commit comment when benchmark APK is ready Adds a GitHub Script step that comments on the commit with a direct link to the workflow artifacts, making it visible in PRs and branch pages. https://claude.ai/code/session_013hcKzBgJgfsrArSgTTGNEk --- .github/workflows/build-benchmark-apk.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml index cb1d8dd21..a9e338b31 100644 --- a/.github/workflows/build-benchmark-apk.yml +++ b/.github/workflows/build-benchmark-apk.yml @@ -37,7 +37,20 @@ jobs: run: ./gradlew assemblePlayBenchmark - name: Upload Play Benchmark APK + id: upload uses: actions/upload-artifact@v6 with: name: Play Benchmark APK path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk + + - name: Comment on commit with APK link + uses: actions/github-script@v7 + with: + script: | + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.repos.createCommitComment({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + body: `📦 **Benchmark APK ready!**\n\nDownload it from the [workflow run artifacts](${runUrl}#artifacts).` + }); From c3f7e491a60de541c27c97a3a2ecfaec077a2d7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 02:34:15 +0000 Subject: [PATCH 113/130] feat: increase wallet transaction page size to 100 https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj --- .../amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 48cc4bdd9..0de7e1a58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -94,7 +94,7 @@ class WalletViewModel : ViewModel() { private val _hasMoreTransactions = MutableStateFlow(true) val hasMoreTransactions = _hasMoreTransactions.asStateFlow() - private val pageSize = 20 + private val pageSize = 100 private val _error = MutableStateFlow(null) val error = _error.asStateFlow() From 11950dc4f41d5a450b33a71dd7d37961ee238040 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 02:55:16 +0000 Subject: [PATCH 114/130] feat: add transaction filter for zaps vs non-zaps on wallet screen Adds filter chips (All / Zaps / Non-Zaps) to the transactions list. Zap transactions are identified by having nostr metadata. The filter is applied client-side via a combined Flow in WalletViewModel. https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj --- .../wallet/WalletTransactionsScreen.kt | 37 ++++++++++++++++++- .../screen/loggedIn/wallet/WalletViewModel.kt | 25 ++++++++++++- amethyst/src/main/res/values/strings.xml | 3 ++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index 3dede2f18..9a1407eb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -86,10 +87,11 @@ fun WalletTransactionsScreen( walletViewModel.fetchTransactions() } - val transactions by walletViewModel.transactions.collectAsState() + val transactions by walletViewModel.filteredTransactions.collectAsState(emptyList()) val isLoading by walletViewModel.isLoading.collectAsState() val isLoadingMore by walletViewModel.isLoadingMore.collectAsState() val hasMore by walletViewModel.hasMoreTransactions.collectAsState() + val currentFilter by walletViewModel.transactionFilter.collectAsState() val listState = rememberLazyListState() @@ -166,6 +168,9 @@ fun WalletTransactionsScreen( modifier = Modifier.padding(padding), state = listState, ) { + item { + TransactionFilterRow(currentFilter) { walletViewModel.setTransactionFilter(it) } + } items(transactions) { tx -> TransactionItem(tx, accountViewModel, nav) HorizontalDivider() @@ -188,6 +193,36 @@ fun WalletTransactionsScreen( } } +@Composable +private fun TransactionFilterRow( + currentFilter: TransactionFilter, + onFilterSelected: (TransactionFilter) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = currentFilter == TransactionFilter.ALL, + onClick = { onFilterSelected(TransactionFilter.ALL) }, + label = { Text(stringRes(R.string.wallet_filter_all)) }, + ) + FilterChip( + selected = currentFilter == TransactionFilter.ZAPS, + onClick = { onFilterSelected(TransactionFilter.ZAPS) }, + label = { Text(stringRes(R.string.wallet_filter_zaps)) }, + ) + FilterChip( + selected = currentFilter == TransactionFilter.NON_ZAPS, + onClick = { onFilterSelected(TransactionFilter.NON_ZAPS) }, + label = { Text(stringRes(R.string.wallet_filter_non_zaps)) }, + ) + } +} + @Composable private fun TransactionItem( tx: NwcTransaction, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 0de7e1a58..ceb1fbd77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch sealed class SendState { @@ -70,6 +72,12 @@ sealed class ReceiveState { ) : ReceiveState() } +enum class TransactionFilter { + ALL, + ZAPS, + NON_ZAPS, +} + class WalletViewModel : ViewModel() { private var account: Account? = null @@ -83,7 +91,18 @@ class WalletViewModel : ViewModel() { val walletAlias = _walletAlias.asStateFlow() private val _transactions = MutableStateFlow>(emptyList()) - val transactions = _transactions.asStateFlow() + + private val _transactionFilter = MutableStateFlow(TransactionFilter.ALL) + val transactionFilter = _transactionFilter.asStateFlow() + + val filteredTransactions = + combine(_transactions, _transactionFilter) { txs, filter -> + when (filter) { + TransactionFilter.ALL -> txs + TransactionFilter.ZAPS -> txs.filter { it.parsedMetadata()?.nostr != null } + TransactionFilter.NON_ZAPS -> txs.filter { it.parsedMetadata()?.nostr == null } + } + } private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() @@ -336,4 +355,8 @@ class WalletViewModel : ViewModel() { fun clearError() { _error.value = null } + + fun setTransactionFilter(filter: TransactionFilter) { + _transactionFilter.value = filter + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d2ae0d724..32a8e479e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1260,6 +1260,9 @@ Received Sent Refresh + All + Zaps + Non-Zaps Security Filters Import Follows From 866dfca710db2ffa22eaf6ada8dcd3ac9240505f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 03:07:46 +0000 Subject: [PATCH 115/130] fix: rename _transactions to allTransactions to fix ktlint backing-property-naming The backing property _transactions no longer has a matching public property since it was replaced by filteredTransactions. https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj --- .../ui/screen/loggedIn/wallet/WalletViewModel.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index ceb1fbd77..172d6835e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -90,13 +90,13 @@ class WalletViewModel : ViewModel() { private val _walletAlias = MutableStateFlow(null) val walletAlias = _walletAlias.asStateFlow() - private val _transactions = MutableStateFlow>(emptyList()) + private val allTransactions = MutableStateFlow>(emptyList()) private val _transactionFilter = MutableStateFlow(TransactionFilter.ALL) val transactionFilter = _transactionFilter.asStateFlow() val filteredTransactions = - combine(_transactions, _transactionFilter) { txs, filter -> + combine(allTransactions, _transactionFilter) { txs, filter -> when (filter) { TransactionFilter.ALL -> txs TransactionFilter.ZAPS -> txs.filter { it.parsedMetadata()?.nostr != null } @@ -196,7 +196,7 @@ class WalletViewModel : ViewModel() { when (response) { is ListTransactionsSuccessResponse -> { val txs = response.result?.transactions ?: emptyList() - _transactions.value = txs + allTransactions.value = txs val totalCount = response.result?.total_count _hasMoreTransactions.value = if (totalCount != null) { @@ -224,7 +224,7 @@ class WalletViewModel : ViewModel() { fun loadMoreTransactions() { if (_isLoadingMore.value || !_hasMoreTransactions.value) return val acc = account ?: return - val currentOffset = _transactions.value.size + val currentOffset = allTransactions.value.size viewModelScope.launch(Dispatchers.IO) { _isLoadingMore.value = true try { @@ -238,11 +238,11 @@ class WalletViewModel : ViewModel() { when (response) { is ListTransactionsSuccessResponse -> { val newTxs = response.result?.transactions ?: emptyList() - _transactions.value = _transactions.value + newTxs + allTransactions.value = allTransactions.value + newTxs val totalCount = response.result?.total_count _hasMoreTransactions.value = if (totalCount != null) { - _transactions.value.size < totalCount + allTransactions.value.size < totalCount } else { newTxs.size >= pageSize } From 39e15a89295ab2bf642181488c1f9868d0ede1b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 03:23:00 +0000 Subject: [PATCH 116/130] feat: comment benchmark APK link on open PRs Add PR commenting to the build-benchmark-apk workflow. When a build completes, it now finds any open PR for the branch and posts a comment with a direct download link to the benchmark APK artifact. https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf --- .github/workflows/build-benchmark-apk.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml index a9e338b31..e3efc3674 100644 --- a/.github/workflows/build-benchmark-apk.yml +++ b/.github/workflows/build-benchmark-apk.yml @@ -48,9 +48,30 @@ jobs: with: script: | const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const artifactUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${{ steps.upload.outputs.artifact-id }}`; + const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${artifactUrl})\n\nOr view all artifacts in the [workflow run](${runUrl}#artifacts).`; + await github.rest.repos.createCommitComment({ owner: context.repo.owner, repo: context.repo.repo, commit_sha: context.sha, - body: `📦 **Benchmark APK ready!**\n\nDownload it from the [workflow run artifacts](${runUrl}#artifacts).` + body }); + + // Find open PRs for this branch and comment on them + const branch = context.ref.replace('refs/heads/', ''); + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:${branch}`, + state: 'open' + }); + + for (const pr of prs) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body + }); + } From af12d13d67229650bcd97825b10f9225dbaa5201 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 03:34:53 +0000 Subject: [PATCH 117/130] fix: use artifact-url output for direct APK download link The manually constructed URL was invalid. Use the artifact-url output from upload-artifact which provides the correct download URL. https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf --- .github/workflows/build-benchmark-apk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml index e3efc3674..54db24f5e 100644 --- a/.github/workflows/build-benchmark-apk.yml +++ b/.github/workflows/build-benchmark-apk.yml @@ -48,7 +48,7 @@ jobs: with: script: | const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const artifactUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${{ steps.upload.outputs.artifact-id }}`; + const artifactUrl = `${{ steps.upload.outputs.artifact-url }}`; const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${artifactUrl})\n\nOr view all artifacts in the [workflow run](${runUrl}#artifacts).`; await github.rest.repos.createCommitComment({ From 1c4f9769b12c2e494ff8e5ef5c5ad633c4accb0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 03:43:10 +0000 Subject: [PATCH 118/130] fix: session-start hook fails silently when dl.google.com is blocked The Claude Code web egress proxy does not include dl.google.com in its allowlist, causing the session-start hook to abort entirely (due to set -euo pipefail) when Android SDK downloads fail. This cascading failure also prevented local.properties creation and ANDROID_HOME export. Additionally, JAVA_TOOL_OPTIONS contains nonProxyHosts entries for *.google.com and *.googleapis.com, which causes the JVM to bypass the proxy for Google Maven, resulting in connection failures. Changes: - Remove set -euo pipefail; use per-section error handling instead - Fix JAVA_TOOL_OPTIONS: strip *.google.com from nonProxyHosts - Fix no_proxy: remove *.google.com and *.googleapis.com entries - Move local.properties and env exports before SDK download section - Make SDK downloads non-fatal with clear warning messages - Install standalone ktlint as fallback for spotlessApply - Create spotless-apply wrapper script for proxy-blocked environments - Update Stop hook to fall back to spotless-apply when Gradle fails https://claude.ai/code/session_01HeWiRdKDTnMGfEBUegk3d1 --- .claude/hooks/session-start.sh | 232 ++++++++++++++++++++++++--------- .claude/settings.json | 2 +- 2 files changed, 170 insertions(+), 64 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 35cddfb3b..b17f6e97b 100644 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,12 +1,59 @@ #!/bin/bash # Session start hook: Configure proxy auth, SSL trust, and Android SDK for Claude Code on the web -set -euo pipefail # Only run in remote (web) environments if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then exit 0 fi +# --- Fix no_proxy: remove *.google.com and *.googleapis.com so traffic routes through proxy --- +# The default no_proxy in Claude Code web includes *.google.com and *.googleapis.com, +# which causes JVM/curl to bypass the proxy for Google Maven (dl.google.com). +# These domains MUST go through the proxy for authenticated egress to work. +fix_no_proxy() { + local var="$1" + local val="${!var:-}" + if [ -n "$val" ]; then + # Remove *.google.com, *.googleapis.com entries from the comma-separated list + local fixed + fixed=$(echo "$val" | tr ',' '\n' | grep -v -E '^\*\.google\.com$|^\*\.googleapis\.com$' | tr '\n' ',' | sed 's/,$//') + export "$var=$fixed" + fi +} + +fix_no_proxy no_proxy +fix_no_proxy NO_PROXY + +# --- Fix JAVA_TOOL_OPTIONS: remove nonProxyHosts that bypass Google domains --- +# JAVA_TOOL_OPTIONS may contain -Dhttp.nonProxyHosts=...|*.google.com|*.googleapis.com +# which prevents the JVM from routing Google Maven traffic through the proxy. +if [ -n "${JAVA_TOOL_OPTIONS:-}" ]; then + # Remove the nonProxyHosts entries for google.com and googleapis.com from the JVM options + # Strategy: rewrite the -Dhttp.nonProxyHosts value to exclude google patterns + JAVA_TOOL_OPTIONS=$(echo "$JAVA_TOOL_OPTIONS" | sed -E ' + s/-Dhttp\.nonProxyHosts=[^ ]*/\n&\n/g + ' | while IFS= read -r line; do + if [[ "$line" == -Dhttp.nonProxyHosts=* ]]; then + # Extract the value, remove google patterns, reconstruct + val="${line#-Dhttp.nonProxyHosts=}" + fixed=$(echo "$val" | tr '|' '\n' | grep -v -E '^\*\.google\.com$|^\*\.googleapis\.com$' | tr '\n' '|' | sed 's/|$//') + echo "-Dhttp.nonProxyHosts=$fixed" + else + echo "$line" + fi + done | tr '\n' ' ' | sed 's/ */ /g; s/^ //; s/ $//') + export JAVA_TOOL_OPTIONS +fi + +# Export fixed env vars for the session +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + echo "export no_proxy='${no_proxy:-}'" >> "$CLAUDE_ENV_FILE" + echo "export NO_PROXY='${NO_PROXY:-}'" >> "$CLAUDE_ENV_FILE" + echo "export JAVA_TOOL_OPTIONS='$JAVA_TOOL_OPTIONS'" >> "$CLAUDE_ENV_FILE" +fi + +echo "Fixed proxy bypass settings (no_proxy, JAVA_TOOL_OPTIONS)" >&2 + # --- Proxy credentials: configure Maven/Gradle if authenticated proxy is set --- proxy="${https_proxy:-${HTTPS_PROXY:-}}" if [ -n "$proxy" ] && echo "$proxy" | grep -q '@'; then @@ -99,68 +146,11 @@ if [ -n "$ANTHROPIC_CA_PEM" ]; then rm -f "$TMPCA" fi +# --- Project setup: local.properties and env vars (before SDK download so these always run) --- ANDROID_SDK_DIR="/root/android-sdk" -SDK_REPO_BASE="https://dl.google.com/android/repository" - -# Install Android SDK packages by downloading directly with curl -# (sdkmanager cannot reach the SDK repository through the proxy) -install_sdk_package() { - local zip_url="$1" - local dest_dir="$2" - local inner_dir="$3" # top-level dir inside the zip - - if [ -d "$dest_dir" ]; then - return 0 - fi - - echo "Downloading $zip_url..." - local TMP_ZIP - TMP_ZIP=$(mktemp /tmp/sdk-pkg.XXXXXX.zip) - curl -fsSL "$zip_url" -o "$TMP_ZIP" - - local TMP_DIR - TMP_DIR=$(mktemp -d) - unzip -q "$TMP_ZIP" -d "$TMP_DIR" - rm -f "$TMP_ZIP" - - mkdir -p "$(dirname "$dest_dir")" - mv "$TMP_DIR/$inner_dir" "$dest_dir" - rm -rf "$TMP_DIR" - echo "Installed to $dest_dir" -} - -# Install Android platform 36 -install_sdk_package \ - "$SDK_REPO_BASE/platform-36_r02.zip" \ - "$ANDROID_SDK_DIR/platforms/android-36" \ - "android-36" - -# Install build-tools 36.0.0 (zip uses "android-16" as inner dir name) -install_sdk_package \ - "$SDK_REPO_BASE/build-tools_r36_linux.zip" \ - "$ANDROID_SDK_DIR/build-tools/36.0.0" \ - "android-16" - -# Install platform-tools -install_sdk_package \ - "$SDK_REPO_BASE/platform-tools_r37.0.0-linux.zip" \ - "$ANDROID_SDK_DIR/platform-tools" \ - "platform-tools" - -# Accept SDK licenses (create license files manually) -echo "Writing SDK license files..." -mkdir -p "$ANDROID_SDK_DIR/licenses" -# android-sdk-license -echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_DIR/licenses/android-sdk-license" -echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_SDK_DIR/licenses/android-sdk-license" -# android-sdk-preview-license -echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license" -echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license" -# intel-android-extra-license -echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license" +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/amethyst}")" # Create local.properties if missing -REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")" LOCAL_PROPS="$REPO_ROOT/local.properties" if [ ! -f "$LOCAL_PROPS" ]; then echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS" @@ -174,7 +164,123 @@ if [ -n "${CLAUDE_ENV_FILE:-}" ]; then echo "export PATH=\$PATH:$ANDROID_SDK_DIR/platform-tools" >> "$CLAUDE_ENV_FILE" fi -cd "$CLAUDE_PROJECT_DIR" -./gradlew --version > /dev/null 2>&1 +# --- Install standalone ktlint (fallback for spotlessApply when Gradle can't resolve AGP) --- +# The egress proxy may block dl.google.com (Google Maven), preventing Gradle from resolving +# the Android Gradle Plugin. Since spotlessApply depends on Gradle's plugin resolution, +# we install ktlint standalone as a reliable alternative. +KTLINT_VERSION="1.5.0" +KTLINT_BIN="/usr/local/bin/ktlint" +if [ ! -x "$KTLINT_BIN" ]; then + echo "Installing ktlint $KTLINT_VERSION..." >&2 + if curl -fsSL "https://github.com/pinterest/ktlint/releases/download/${KTLINT_VERSION}/ktlint" -o "$KTLINT_BIN" 2>/dev/null; then + chmod +x "$KTLINT_BIN" + echo "Installed ktlint $KTLINT_VERSION to $KTLINT_BIN" >&2 + else + echo "WARNING: Failed to install ktlint (GitHub may be unreachable)" >&2 + fi +fi -echo "Android SDK setup complete." \ No newline at end of file +# Create a spotless-apply wrapper that mimics the Gradle spotless configuration +cat > /usr/local/bin/spotless-apply << 'SPOTLESS_SCRIPT' +#!/bin/bash +# Standalone spotless-apply: runs ktlint with the same settings as the Gradle spotless plugin. +# Use this when ./gradlew spotlessApply fails due to proxy/network issues. +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" + +if ! command -v ktlint &>/dev/null; then + echo "ERROR: ktlint not found. Install it first." >&2 + exit 1 +fi + +echo "Running ktlint format on Kotlin sources..." +# Find all Kotlin source files matching the spotless target pattern (src/**/*.kt) +# Exclude build directories +ktlint --format "**/*.kt" 2>&1 | grep -v "^$" || true + +echo "Done. Files formatted with ktlint." +SPOTLESS_SCRIPT +chmod +x /usr/local/bin/spotless-apply + +# --- Android SDK download (non-fatal: warns if dl.google.com is blocked) --- +SDK_REPO_BASE="https://dl.google.com/android/repository" + +install_sdk_package() { + local zip_url="$1" + local dest_dir="$2" + local inner_dir="$3" # top-level dir inside the zip + + if [ -d "$dest_dir" ]; then + return 0 + fi + + echo "Downloading $zip_url..." >&2 + local TMP_ZIP + TMP_ZIP=$(mktemp /tmp/sdk-pkg.XXXXXX.zip) + if ! curl -fsSL "$zip_url" -o "$TMP_ZIP" 2>/dev/null; then + rm -f "$TMP_ZIP" + echo "WARNING: Failed to download $zip_url (dl.google.com may be blocked by proxy)" >&2 + echo " -> Add dl.google.com to the egress proxy allowlist to fix this" >&2 + return 1 + fi + + local TMP_DIR + TMP_DIR=$(mktemp -d) + unzip -q "$TMP_ZIP" -d "$TMP_DIR" + rm -f "$TMP_ZIP" + + mkdir -p "$(dirname "$dest_dir")" + mv "$TMP_DIR/$inner_dir" "$dest_dir" + rm -rf "$TMP_DIR" + echo "Installed to $dest_dir" >&2 +} + +sdk_ok=true + +# Install Android platform 36 +if ! install_sdk_package \ + "$SDK_REPO_BASE/platform-36_r02.zip" \ + "$ANDROID_SDK_DIR/platforms/android-36" \ + "android-36"; then + sdk_ok=false +fi + +# Install build-tools 36.0.0 (zip uses "android-16" as inner dir name) +if ! install_sdk_package \ + "$SDK_REPO_BASE/build-tools_r36_linux.zip" \ + "$ANDROID_SDK_DIR/build-tools/36.0.0" \ + "android-16"; then + sdk_ok=false +fi + +# Install platform-tools +if ! install_sdk_package \ + "$SDK_REPO_BASE/platform-tools_r37.0.0-linux.zip" \ + "$ANDROID_SDK_DIR/platform-tools" \ + "platform-tools"; then + sdk_ok=false +fi + +if [ "$sdk_ok" = true ]; then + # Accept SDK licenses (create license files manually) + echo "Writing SDK license files..." >&2 + mkdir -p "$ANDROID_SDK_DIR/licenses" + echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_DIR/licenses/android-sdk-license" + echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_SDK_DIR/licenses/android-sdk-license" + echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license" + echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license" + echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license" + echo "Android SDK setup complete." >&2 +else + echo "WARNING: Android SDK download failed. dl.google.com is likely blocked by the egress proxy." >&2 + echo " -> Gradle builds requiring AGP will fail until dl.google.com is added to the proxy allowlist." >&2 + echo " -> Use 'spotless-apply' (standalone ktlint) instead of './gradlew spotlessApply'." >&2 +fi + +# Warm up Gradle wrapper (non-fatal) +cd "$REPO_ROOT" +./gradlew --version > /dev/null 2>&1 || true + +echo "Session start hook complete." >&2 diff --git a/.claude/settings.json b/.claude/settings.json index 80496cbba..4ab4d1726 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "./gradlew spotlessApply", + "command": "./gradlew spotlessApply 2>/dev/null || spotless-apply", "timeout": 120 } ] From 883a2f995e22dc35cb848c390065be1cf09cf2a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 03:43:19 +0000 Subject: [PATCH 119/130] style: apply ktlint formatting fixes https://claude.ai/code/session_01HeWiRdKDTnMGfEBUegk3d1 --- .../amethyst/ui/components/ShareHelper.kt | 11 ++++++----- .../ui/screen/loggedIn/chess/ChessLobbyScreen.kt | 6 ++++-- .../loggedIn/hashtag/dal/HashtagFeedFilter.kt | 3 ++- .../screen/loggedIn/home/ShortNotePostViewModel.kt | 3 ++- .../gallery/dal/UserProfileGalleryFeedFilter.kt | 13 +++++++------ .../amethyst/desktop/chess/ChessScreen.kt | 7 +++++-- .../quartz/nip01Core/store/sqlite/QueryBuilder.kt | 3 ++- .../namecoin/NamecoinNameResolver.kt | 3 ++- .../quartz/nip64Chess/ChessStateReconstructor.kt | 9 ++++++--- .../quartz/nip64Chess/ChessEngine.jvmAndroid.kt | 3 ++- .../nip05/namecoin/NamecoinNameResolverTest.kt | 3 ++- 11 files changed, 40 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 32aeaa679..90bad3c7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -119,11 +119,12 @@ object ShareHelper { bytesRead >= 12 && matchesMagicNumbers(header, 4, MOV_FTYP) -> detectMp4OrMov(header) // MP4/MOV alternative: moov, mdat, or free at offset 4 - bytesRead >= 8 && ( - matchesMagicNumbers(header, 4, MOV_MOOV) || - matchesMagicNumbers(header, 4, MOV_MDAT) || - matchesMagicNumbers(header, 4, MOV_FREE) - ) -> "mp4" + bytesRead >= 8 && + ( + matchesMagicNumbers(header, 4, MOV_MOOV) || + matchesMagicNumbers(header, 4, MOV_MDAT) || + matchesMagicNumbers(header, 4, MOV_FREE) + ) -> "mp4" else -> defaultExtension } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index ad9ad0e22..96a2502eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -297,8 +297,10 @@ fun ChessLobbyContent( val userPubkey = accountViewModel.account.userProfile().pubkeyHex val hasContent = - activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || - publicGames.isNotEmpty() || challenges.isNotEmpty() + activeGames.isNotEmpty() || + spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || + challenges.isNotEmpty() if (!hasContent) { // Empty state - use LazyColumn so pull-to-refresh works diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 9a95a0e46..e9c468e94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -83,7 +83,8 @@ class HashtagFeedFilter( event is PrivateDmEvent || event is PollNoteEvent || event is AudioHeaderEvent - ) && event.isTaggedHash(hashTag) + ) && + event.isTaggedHash(hashTag) fun acceptableViaScope( event: Event?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 4b63c9df4..87fd5939a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1047,7 +1047,8 @@ open class ShortNotePostViewModel : ( !wantsPoll || ( - pollOptions.isNotEmpty() && pollOptions.all { it.value.label.isNotEmpty() } && + pollOptions.isNotEmpty() && + pollOptions.all { it.value.label.isNotEmpty() } && closedAt > TimeUtils.oneMinuteFromNow() ) ) && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 0a8f6f31d..0e943641d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -77,12 +77,13 @@ class UserProfileGalleryFeedFilter( val noteEvent = it.event return ( ( - it.event?.pubKey == user.pubkeyHex && ( - noteEvent is PictureEvent || - noteEvent is RegularVideoEvent || - (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || - (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) - ) + it.event?.pubKey == user.pubkeyHex && + ( + noteEvent is PictureEvent || + noteEvent is RegularVideoEvent || + (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || + (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) + ) ) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) ) && params.match(noteEvent, it.relays) && diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index efc578373..2668756b8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -345,8 +345,11 @@ private fun ChessLobby( listState: LazyListState = rememberLazyListState(), ) { val hasContent = - activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || - publicGames.isNotEmpty() || challenges.isNotEmpty() || completedGames.isNotEmpty() + activeGames.isNotEmpty() || + spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || + challenges.isNotEmpty() || + completedGames.isNotEmpty() if (!hasContent) { // Empty state diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index c876c7b96..8ab91cfae 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -678,7 +678,8 @@ class QueryBuilder( val search: String? = null, ) { fun isSimpleSearch() = - search != null && search.isNotEmpty() && + search != null && + search.isNotEmpty() && (nonDTagsIn == null || nonDTagsIn.isEmpty()) && (nonDTagsAll == null || nonDTagsAll.isEmpty()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index 72abf7546..4d4a44aa8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -301,7 +301,8 @@ class NamecoinNameResolver( pubkey = rootMatch.content } - firstEntry != null && firstEntry.value is JsonPrimitive && + firstEntry != null && + firstEntry.value is JsonPrimitive && isValidPubkey((firstEntry.value as JsonPrimitive).content) -> { resolvedLocalPart = firstEntry.key pubkey = (firstEntry.value as JsonPrimitive).content diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt index 997e9921c..4dd69fbd4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -217,9 +217,12 @@ object ChessStateReconstructor { return fen1 == fen2 // Fallback to exact match } - return parts1[0] == parts2[0] && // Board position - parts1[1] == parts2[1] && // Active color - parts1[2] == parts2[2] && // Castling rights + return parts1[0] == parts2[0] && + // Board position + parts1[1] == parts2[1] && + // Active color + parts1[2] == parts2[2] && + // Castling rights parts1[3] == parts2[3] // En passant } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt index d9b192d9d..dbd0bbb27 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt @@ -212,7 +212,8 @@ actual class ChessEngine { board.getPiece(toSquare) != Piece.NONE || ( pt == com.github.bhlangonijr.chesslib.PieceType.PAWN && - epTarget != Square.NONE && toSquare == epTarget + epTarget != Square.NONE && + toSquare == epTarget ) if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) { diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt index 52822c49c..0c975e7c3 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt @@ -261,7 +261,8 @@ class NamecoinNameResolverTest { pubkey = rootMatch.content } - firstEntry != null && firstEntry.value is kotlinx.serialization.json.JsonPrimitive && + firstEntry != null && + firstEntry.value is kotlinx.serialization.json.JsonPrimitive && (firstEntry.value as kotlinx.serialization.json.JsonPrimitive) .content .matches(Regex("^[0-9a-fA-F]{64}$")) -> { From 6bc927147c5e592e7140e77bd127647341f477e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 03:47:47 +0000 Subject: [PATCH 120/130] fix: use nightly.link for direct APK download without auth GitHub artifact URLs require authentication and don't provide direct file downloads. nightly.link proxies artifact downloads, giving a direct download link that works for anyone without GitHub login. https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf --- .github/workflows/build-benchmark-apk.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml index 54db24f5e..beaddc41b 100644 --- a/.github/workflows/build-benchmark-apk.yml +++ b/.github/workflows/build-benchmark-apk.yml @@ -48,8 +48,9 @@ jobs: with: script: | const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const artifactUrl = `${{ steps.upload.outputs.artifact-url }}`; - const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${artifactUrl})\n\nOr view all artifacts in the [workflow run](${runUrl}#artifacts).`; + const artifactName = encodeURIComponent('Play Benchmark APK'); + const nightlyUrl = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/${artifactName}.zip`; + const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${nightlyUrl})\n\nOr view all artifacts in the [workflow run](${runUrl}#artifacts).`; await github.rest.repos.createCommitComment({ owner: context.repo.owner, From 61dc903e9a5cc21bb011dc573108301d47cea47a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 04:03:18 +0000 Subject: [PATCH 121/130] fix: use artifact ID for direct download URL instead of nightly.link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the artifact-id output from upload-artifact to construct a direct GitHub artifact download URL. No third-party service needed — the URL points to the artifact on GitHub which triggers a download when clicked. https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf --- .github/workflows/build-benchmark-apk.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml index beaddc41b..1f19c0e67 100644 --- a/.github/workflows/build-benchmark-apk.yml +++ b/.github/workflows/build-benchmark-apk.yml @@ -47,10 +47,10 @@ jobs: uses: actions/github-script@v7 with: script: | + const artifactId = `${{ steps.upload.outputs.artifact-id }}`; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const artifactName = encodeURIComponent('Play Benchmark APK'); - const nightlyUrl = `https://nightly.link/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/${artifactName}.zip`; - const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${nightlyUrl})\n\nOr view all artifacts in the [workflow run](${runUrl}#artifacts).`; + const downloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${artifactId}`; + const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${downloadUrl})\n\nOr view all artifacts in the [workflow run](${runUrl}#artifacts).`; await github.rest.repos.createCommitComment({ owner: context.repo.owner, From 706d282681198321ed817e117b53dcaab7dd54aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 04:05:33 +0000 Subject: [PATCH 122/130] fix: direct APK download via prerelease asset, PR comment only - Upload APK as a GitHub prerelease asset for direct .apk download (no zip wrapping like artifacts) - Remove commit comment, only comment on open PRs - Clean up existing release with same tag before creating https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf --- .github/workflows/build-benchmark-apk.yml | 69 ++++++++++++++++++----- 1 file changed, 54 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml index 1f19c0e67..ba9c7ee3c 100644 --- a/.github/workflows/build-benchmark-apk.yml +++ b/.github/workflows/build-benchmark-apk.yml @@ -36,30 +36,69 @@ jobs: - name: Build Benchmark APK run: ./gradlew assemblePlayBenchmark - - name: Upload Play Benchmark APK + - name: Upload APK to prerelease id: upload - uses: actions/upload-artifact@v6 - with: - name: Play Benchmark APK - path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk - - - name: Comment on commit with APK link uses: actions/github-script@v7 with: script: | - const artifactId = `${{ steps.upload.outputs.artifact-id }}`; - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const downloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${artifactId}`; - const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${downloadUrl})\n\nOr view all artifacts in the [workflow run](${runUrl}#artifacts).`; + const fs = require('fs'); + const apkPath = 'amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk'; + const sha = context.sha.substring(0, 7); + const tag = `benchmark-${sha}`; - await github.rest.repos.createCommitComment({ + // Delete existing release with this tag if it exists + try { + const { data: existing } = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag + }); + await github.rest.repos.deleteRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: existing.id + }); + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${tag}` + }); + } catch (e) { + // Release doesn't exist yet, that's fine + } + + const { data: release } = await github.rest.repos.createRelease({ owner: context.repo.owner, repo: context.repo.repo, - commit_sha: context.sha, - body + tag_name: tag, + target_commitish: context.sha, + name: `Benchmark APK (${sha})`, + body: `Auto-generated benchmark APK for commit ${context.sha}`, + prerelease: true }); - // Find open PRs for this branch and comment on them + const apkData = fs.readFileSync(apkPath); + const { data: asset } = await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + name: 'amethyst-play-universal-benchmark.apk', + data: apkData, + headers: { + 'content-type': 'application/vnd.android.package-archive', + 'content-length': apkData.length + } + }); + + core.setOutput('apk-url', asset.browser_download_url); + + - name: Comment on PR with APK link + uses: actions/github-script@v7 + with: + script: | + const apkUrl = `${{ steps.upload.outputs.apk-url }}`; + const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${apkUrl})`; + const branch = context.ref.replace('refs/heads/', ''); const { data: prs } = await github.rest.pulls.list({ owner: context.repo.owner, From dbf5d6954c73eafb61f847fe70a15a4c6ea41e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 04:08:30 +0000 Subject: [PATCH 123/130] fix: revert to artifact zip download, PR comment only Use upload-artifact with artifact-id for direct zip download URL. Remove prerelease approach and commit comments. https://claude.ai/code/session_0138kUcZaPQoQcAc7nA1KDbf --- .github/workflows/build-benchmark-apk.yml | 62 +++-------------------- 1 file changed, 7 insertions(+), 55 deletions(-) diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml index ba9c7ee3c..1326569d9 100644 --- a/.github/workflows/build-benchmark-apk.yml +++ b/.github/workflows/build-benchmark-apk.yml @@ -36,68 +36,20 @@ jobs: - name: Build Benchmark APK run: ./gradlew assemblePlayBenchmark - - name: Upload APK to prerelease + - name: Upload Play Benchmark APK id: upload - uses: actions/github-script@v7 + uses: actions/upload-artifact@v6 with: - script: | - const fs = require('fs'); - const apkPath = 'amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk'; - const sha = context.sha.substring(0, 7); - const tag = `benchmark-${sha}`; - - // Delete existing release with this tag if it exists - try { - const { data: existing } = await github.rest.repos.getReleaseByTag({ - owner: context.repo.owner, - repo: context.repo.repo, - tag - }); - await github.rest.repos.deleteRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: existing.id - }); - await github.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `tags/${tag}` - }); - } catch (e) { - // Release doesn't exist yet, that's fine - } - - const { data: release } = await github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: tag, - target_commitish: context.sha, - name: `Benchmark APK (${sha})`, - body: `Auto-generated benchmark APK for commit ${context.sha}`, - prerelease: true - }); - - const apkData = fs.readFileSync(apkPath); - const { data: asset } = await github.rest.repos.uploadReleaseAsset({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: release.id, - name: 'amethyst-play-universal-benchmark.apk', - data: apkData, - headers: { - 'content-type': 'application/vnd.android.package-archive', - 'content-length': apkData.length - } - }); - - core.setOutput('apk-url', asset.browser_download_url); + name: Play Benchmark APK + path: amethyst/build/outputs/apk/play/benchmark/amethyst-play-universal-benchmark.apk - name: Comment on PR with APK link uses: actions/github-script@v7 with: script: | - const apkUrl = `${{ steps.upload.outputs.apk-url }}`; - const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${apkUrl})`; + const artifactId = `${{ steps.upload.outputs.artifact-id }}`; + const downloadUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${artifactId}`; + const body = `📦 **Benchmark APK ready!**\n\nDownload: [Play Benchmark APK](${downloadUrl})`; const branch = context.ref.replace('refs/heads/', ''); const { data: prs } = await github.rest.pulls.list({ From f10387025d63e64562635ba0bce3f90a3a662353 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 17 Mar 2026 07:27:56 +0100 Subject: [PATCH 124/130] spotlessApply --- .../ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt | 7 +++++-- .../amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index 9a1407eb9..9f1dc252f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -97,7 +97,10 @@ fun WalletTransactionsScreen( val shouldLoadMore by remember { derivedStateOf { - val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val lastVisibleIndex = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index ?: 0 val totalItems = listState.layoutInfo.totalItemsCount lastVisibleIndex >= totalItems - 5 && !isLoadingMore && hasMore && transactions.isNotEmpty() } @@ -386,7 +389,7 @@ private fun TransactionUserName( ) } else { Text( - text = fallbackName ?: pubkeyHex.take(8) + "...", + text = fallbackName ?: (pubkeyHex.take(8) + "..."), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 172d6835e..7748b5eff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -40,7 +40,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch sealed class SendState { From 9bf3086230c25f5f5dc19a52e0311e849e31b3f6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 17 Mar 2026 09:25:33 -0400 Subject: [PATCH 125/130] Make the session-start executable by default --- .claude/hooks/session-start.sh | 0 .github/workflows/build-benchmark-apk.yml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 .claude/hooks/session-start.sh diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh old mode 100644 new mode 100755 diff --git a/.github/workflows/build-benchmark-apk.yml b/.github/workflows/build-benchmark-apk.yml index 1326569d9..0a65ca6c1 100644 --- a/.github/workflows/build-benchmark-apk.yml +++ b/.github/workflows/build-benchmark-apk.yml @@ -1,4 +1,4 @@ -name: Build Benchmark APK +name: Build APK For Claude on: push: From baa55ab7f9c271dead14c88597f13be37bdd64a2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 17 Mar 2026 09:55:56 -0400 Subject: [PATCH 126/130] reverts to amber's session start --- .claude/hooks/session-start.sh | 232 +++++++++------------------------ 1 file changed, 63 insertions(+), 169 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index b17f6e97b..35cddfb3b 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,59 +1,12 @@ #!/bin/bash # Session start hook: Configure proxy auth, SSL trust, and Android SDK for Claude Code on the web +set -euo pipefail # Only run in remote (web) environments if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then exit 0 fi -# --- Fix no_proxy: remove *.google.com and *.googleapis.com so traffic routes through proxy --- -# The default no_proxy in Claude Code web includes *.google.com and *.googleapis.com, -# which causes JVM/curl to bypass the proxy for Google Maven (dl.google.com). -# These domains MUST go through the proxy for authenticated egress to work. -fix_no_proxy() { - local var="$1" - local val="${!var:-}" - if [ -n "$val" ]; then - # Remove *.google.com, *.googleapis.com entries from the comma-separated list - local fixed - fixed=$(echo "$val" | tr ',' '\n' | grep -v -E '^\*\.google\.com$|^\*\.googleapis\.com$' | tr '\n' ',' | sed 's/,$//') - export "$var=$fixed" - fi -} - -fix_no_proxy no_proxy -fix_no_proxy NO_PROXY - -# --- Fix JAVA_TOOL_OPTIONS: remove nonProxyHosts that bypass Google domains --- -# JAVA_TOOL_OPTIONS may contain -Dhttp.nonProxyHosts=...|*.google.com|*.googleapis.com -# which prevents the JVM from routing Google Maven traffic through the proxy. -if [ -n "${JAVA_TOOL_OPTIONS:-}" ]; then - # Remove the nonProxyHosts entries for google.com and googleapis.com from the JVM options - # Strategy: rewrite the -Dhttp.nonProxyHosts value to exclude google patterns - JAVA_TOOL_OPTIONS=$(echo "$JAVA_TOOL_OPTIONS" | sed -E ' - s/-Dhttp\.nonProxyHosts=[^ ]*/\n&\n/g - ' | while IFS= read -r line; do - if [[ "$line" == -Dhttp.nonProxyHosts=* ]]; then - # Extract the value, remove google patterns, reconstruct - val="${line#-Dhttp.nonProxyHosts=}" - fixed=$(echo "$val" | tr '|' '\n' | grep -v -E '^\*\.google\.com$|^\*\.googleapis\.com$' | tr '\n' '|' | sed 's/|$//') - echo "-Dhttp.nonProxyHosts=$fixed" - else - echo "$line" - fi - done | tr '\n' ' ' | sed 's/ */ /g; s/^ //; s/ $//') - export JAVA_TOOL_OPTIONS -fi - -# Export fixed env vars for the session -if [ -n "${CLAUDE_ENV_FILE:-}" ]; then - echo "export no_proxy='${no_proxy:-}'" >> "$CLAUDE_ENV_FILE" - echo "export NO_PROXY='${NO_PROXY:-}'" >> "$CLAUDE_ENV_FILE" - echo "export JAVA_TOOL_OPTIONS='$JAVA_TOOL_OPTIONS'" >> "$CLAUDE_ENV_FILE" -fi - -echo "Fixed proxy bypass settings (no_proxy, JAVA_TOOL_OPTIONS)" >&2 - # --- Proxy credentials: configure Maven/Gradle if authenticated proxy is set --- proxy="${https_proxy:-${HTTPS_PROXY:-}}" if [ -n "$proxy" ] && echo "$proxy" | grep -q '@'; then @@ -146,11 +99,68 @@ if [ -n "$ANTHROPIC_CA_PEM" ]; then rm -f "$TMPCA" fi -# --- Project setup: local.properties and env vars (before SDK download so these always run) --- ANDROID_SDK_DIR="/root/android-sdk" -REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/amethyst}")" +SDK_REPO_BASE="https://dl.google.com/android/repository" + +# Install Android SDK packages by downloading directly with curl +# (sdkmanager cannot reach the SDK repository through the proxy) +install_sdk_package() { + local zip_url="$1" + local dest_dir="$2" + local inner_dir="$3" # top-level dir inside the zip + + if [ -d "$dest_dir" ]; then + return 0 + fi + + echo "Downloading $zip_url..." + local TMP_ZIP + TMP_ZIP=$(mktemp /tmp/sdk-pkg.XXXXXX.zip) + curl -fsSL "$zip_url" -o "$TMP_ZIP" + + local TMP_DIR + TMP_DIR=$(mktemp -d) + unzip -q "$TMP_ZIP" -d "$TMP_DIR" + rm -f "$TMP_ZIP" + + mkdir -p "$(dirname "$dest_dir")" + mv "$TMP_DIR/$inner_dir" "$dest_dir" + rm -rf "$TMP_DIR" + echo "Installed to $dest_dir" +} + +# Install Android platform 36 +install_sdk_package \ + "$SDK_REPO_BASE/platform-36_r02.zip" \ + "$ANDROID_SDK_DIR/platforms/android-36" \ + "android-36" + +# Install build-tools 36.0.0 (zip uses "android-16" as inner dir name) +install_sdk_package \ + "$SDK_REPO_BASE/build-tools_r36_linux.zip" \ + "$ANDROID_SDK_DIR/build-tools/36.0.0" \ + "android-16" + +# Install platform-tools +install_sdk_package \ + "$SDK_REPO_BASE/platform-tools_r37.0.0-linux.zip" \ + "$ANDROID_SDK_DIR/platform-tools" \ + "platform-tools" + +# Accept SDK licenses (create license files manually) +echo "Writing SDK license files..." +mkdir -p "$ANDROID_SDK_DIR/licenses" +# android-sdk-license +echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_DIR/licenses/android-sdk-license" +echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_SDK_DIR/licenses/android-sdk-license" +# android-sdk-preview-license +echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license" +echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license" +# intel-android-extra-license +echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license" # Create local.properties if missing +REPO_ROOT="$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null || echo "${CLAUDE_PROJECT_DIR:-/home/user/Amber}")" LOCAL_PROPS="$REPO_ROOT/local.properties" if [ ! -f "$LOCAL_PROPS" ]; then echo "sdk.dir=$ANDROID_SDK_DIR" > "$LOCAL_PROPS" @@ -164,123 +174,7 @@ if [ -n "${CLAUDE_ENV_FILE:-}" ]; then echo "export PATH=\$PATH:$ANDROID_SDK_DIR/platform-tools" >> "$CLAUDE_ENV_FILE" fi -# --- Install standalone ktlint (fallback for spotlessApply when Gradle can't resolve AGP) --- -# The egress proxy may block dl.google.com (Google Maven), preventing Gradle from resolving -# the Android Gradle Plugin. Since spotlessApply depends on Gradle's plugin resolution, -# we install ktlint standalone as a reliable alternative. -KTLINT_VERSION="1.5.0" -KTLINT_BIN="/usr/local/bin/ktlint" -if [ ! -x "$KTLINT_BIN" ]; then - echo "Installing ktlint $KTLINT_VERSION..." >&2 - if curl -fsSL "https://github.com/pinterest/ktlint/releases/download/${KTLINT_VERSION}/ktlint" -o "$KTLINT_BIN" 2>/dev/null; then - chmod +x "$KTLINT_BIN" - echo "Installed ktlint $KTLINT_VERSION to $KTLINT_BIN" >&2 - else - echo "WARNING: Failed to install ktlint (GitHub may be unreachable)" >&2 - fi -fi +cd "$CLAUDE_PROJECT_DIR" +./gradlew --version > /dev/null 2>&1 -# Create a spotless-apply wrapper that mimics the Gradle spotless configuration -cat > /usr/local/bin/spotless-apply << 'SPOTLESS_SCRIPT' -#!/bin/bash -# Standalone spotless-apply: runs ktlint with the same settings as the Gradle spotless plugin. -# Use this when ./gradlew spotlessApply fails due to proxy/network issues. -set -euo pipefail - -REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" -cd "$REPO_ROOT" - -if ! command -v ktlint &>/dev/null; then - echo "ERROR: ktlint not found. Install it first." >&2 - exit 1 -fi - -echo "Running ktlint format on Kotlin sources..." -# Find all Kotlin source files matching the spotless target pattern (src/**/*.kt) -# Exclude build directories -ktlint --format "**/*.kt" 2>&1 | grep -v "^$" || true - -echo "Done. Files formatted with ktlint." -SPOTLESS_SCRIPT -chmod +x /usr/local/bin/spotless-apply - -# --- Android SDK download (non-fatal: warns if dl.google.com is blocked) --- -SDK_REPO_BASE="https://dl.google.com/android/repository" - -install_sdk_package() { - local zip_url="$1" - local dest_dir="$2" - local inner_dir="$3" # top-level dir inside the zip - - if [ -d "$dest_dir" ]; then - return 0 - fi - - echo "Downloading $zip_url..." >&2 - local TMP_ZIP - TMP_ZIP=$(mktemp /tmp/sdk-pkg.XXXXXX.zip) - if ! curl -fsSL "$zip_url" -o "$TMP_ZIP" 2>/dev/null; then - rm -f "$TMP_ZIP" - echo "WARNING: Failed to download $zip_url (dl.google.com may be blocked by proxy)" >&2 - echo " -> Add dl.google.com to the egress proxy allowlist to fix this" >&2 - return 1 - fi - - local TMP_DIR - TMP_DIR=$(mktemp -d) - unzip -q "$TMP_ZIP" -d "$TMP_DIR" - rm -f "$TMP_ZIP" - - mkdir -p "$(dirname "$dest_dir")" - mv "$TMP_DIR/$inner_dir" "$dest_dir" - rm -rf "$TMP_DIR" - echo "Installed to $dest_dir" >&2 -} - -sdk_ok=true - -# Install Android platform 36 -if ! install_sdk_package \ - "$SDK_REPO_BASE/platform-36_r02.zip" \ - "$ANDROID_SDK_DIR/platforms/android-36" \ - "android-36"; then - sdk_ok=false -fi - -# Install build-tools 36.0.0 (zip uses "android-16" as inner dir name) -if ! install_sdk_package \ - "$SDK_REPO_BASE/build-tools_r36_linux.zip" \ - "$ANDROID_SDK_DIR/build-tools/36.0.0" \ - "android-16"; then - sdk_ok=false -fi - -# Install platform-tools -if ! install_sdk_package \ - "$SDK_REPO_BASE/platform-tools_r37.0.0-linux.zip" \ - "$ANDROID_SDK_DIR/platform-tools" \ - "platform-tools"; then - sdk_ok=false -fi - -if [ "$sdk_ok" = true ]; then - # Accept SDK licenses (create license files manually) - echo "Writing SDK license files..." >&2 - mkdir -p "$ANDROID_SDK_DIR/licenses" - echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" > "$ANDROID_SDK_DIR/licenses/android-sdk-license" - echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_SDK_DIR/licenses/android-sdk-license" - echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license" - echo -e "\n504667f4c0de7af1a06de9f4b1727b84351f2910" >> "$ANDROID_SDK_DIR/licenses/android-sdk-preview-license" - echo -e "\nd975f751698a77b662f1254ddbeed3901e976f5a" > "$ANDROID_SDK_DIR/licenses/intel-android-extra-license" - echo "Android SDK setup complete." >&2 -else - echo "WARNING: Android SDK download failed. dl.google.com is likely blocked by the egress proxy." >&2 - echo " -> Gradle builds requiring AGP will fail until dl.google.com is added to the proxy allowlist." >&2 - echo " -> Use 'spotless-apply' (standalone ktlint) instead of './gradlew spotlessApply'." >&2 -fi - -# Warm up Gradle wrapper (non-fatal) -cd "$REPO_ROOT" -./gradlew --version > /dev/null 2>&1 || true - -echo "Session start hook complete." >&2 +echo "Android SDK setup complete." \ No newline at end of file From bb32962f7d9f0d2dd07847f0cb5e92ce100787e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 15:29:21 +0000 Subject: [PATCH 127/130] fix: prevent infinite loading spinner on wallet transactions screen - Convert filteredTransactions from cold Flow to StateFlow via stateIn() to ensure reliable state updates with collectAsState() - Add 30s timeout to NWC requests (fetchBalance, fetchTransactions, loadMoreTransactions) so loading state resets if wallet doesn't respond https://claude.ai/code/session_01CdNY7qYDRHJr1jhAFvVgzj --- .../wallet/WalletTransactionsScreen.kt | 2 +- .../screen/loggedIn/wallet/WalletViewModel.kt | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index 9f1dc252f..db4c81307 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -87,7 +87,7 @@ fun WalletTransactionsScreen( walletViewModel.fetchTransactions() } - val transactions by walletViewModel.filteredTransactions.collectAsState(emptyList()) + val transactions by walletViewModel.filteredTransactions.collectAsState() val isLoading by walletViewModel.isLoading.collectAsState() val isLoadingMore by walletViewModel.isLoadingMore.collectAsState() val hasMore by walletViewModel.hasMoreTransactions.collectAsState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 7748b5eff..8f5f98416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -37,7 +37,10 @@ import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch @@ -77,6 +80,8 @@ enum class TransactionFilter { NON_ZAPS, } +private const val NWC_TIMEOUT_MS = 30_000L + class WalletViewModel : ViewModel() { private var account: Account? = null @@ -101,7 +106,7 @@ class WalletViewModel : ViewModel() { TransactionFilter.ZAPS -> txs.filter { it.parsedMetadata()?.nostr != null } TransactionFilter.NON_ZAPS -> txs.filter { it.parsedMetadata()?.nostr == null } } - } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() @@ -123,6 +128,13 @@ class WalletViewModel : ViewModel() { private val _receiveState = MutableStateFlow(ReceiveState.Idle) val receiveState = _receiveState.asStateFlow() + private fun launchTimeout(onTimeout: () -> Unit): Job = + viewModelScope.launch(Dispatchers.IO) { + delay(NWC_TIMEOUT_MS) + _error.value = "Wallet request timed out" + onTimeout() + } + fun init(account: Account) { this.account = account _hasWalletSetup.value = account.nip47SignerState.hasWalletConnectSetup() @@ -137,8 +149,10 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true _error.value = null + val timeoutJob = launchTimeout { _isLoading.value = false } try { acc.sendNwcRequest(GetBalanceMethod.create()) { response -> + timeoutJob.cancel() when (response) { is GetBalanceSuccessResponse -> { // NWC balance is in millisats, convert to sats @@ -154,6 +168,7 @@ class WalletViewModel : ViewModel() { _isLoading.value = false } } catch (e: Exception) { + timeoutJob.cancel() _error.value = e.message _isLoading.value = false } @@ -184,6 +199,7 @@ class WalletViewModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true _hasMoreTransactions.value = true + val timeoutJob = launchTimeout { _isLoading.value = false } try { acc.sendNwcRequest( ListTransactionsMethod.create( @@ -192,6 +208,7 @@ class WalletViewModel : ViewModel() { unpaid = false, ), ) { response -> + timeoutJob.cancel() when (response) { is ListTransactionsSuccessResponse -> { val txs = response.result?.transactions ?: emptyList() @@ -214,6 +231,7 @@ class WalletViewModel : ViewModel() { _isLoading.value = false } } catch (e: Exception) { + timeoutJob.cancel() _error.value = e.message _isLoading.value = false } @@ -226,6 +244,7 @@ class WalletViewModel : ViewModel() { val currentOffset = allTransactions.value.size viewModelScope.launch(Dispatchers.IO) { _isLoadingMore.value = true + val timeoutJob = launchTimeout { _isLoadingMore.value = false } try { acc.sendNwcRequest( ListTransactionsMethod.create( @@ -234,6 +253,7 @@ class WalletViewModel : ViewModel() { unpaid = false, ), ) { response -> + timeoutJob.cancel() when (response) { is ListTransactionsSuccessResponse -> { val newTxs = response.result?.transactions ?: emptyList() @@ -256,6 +276,7 @@ class WalletViewModel : ViewModel() { _isLoadingMore.value = false } } catch (e: Exception) { + timeoutJob.cancel() _error.value = e.message _isLoadingMore.value = false } From 4770f1eb90bc527c30d3fe3d44209d5697b1d5c9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 17 Mar 2026 11:43:24 -0400 Subject: [PATCH 128/130] Fixes sending url without cipher. --- .../privateDM/send/upload/ChatFileSender.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt index 7ec7bd245..9164a505b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileSender.kt @@ -24,11 +24,14 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.utils.ciphers.AESGCM class ChatFileSender( @@ -39,6 +42,8 @@ class ChatFileSender( uploads.forEach { if (it.cipher != null) { sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) + } else { + sendNIP17AsHiddenLink(it.result, it.caption, it.contentWarningReason) } } } @@ -70,6 +75,31 @@ class ChatFileSender( ) } + suspend fun sendNIP17AsHiddenLink( + result: UploadOrchestrator.OrchestratorResult.ServerResult, + caption: String?, + contentWarningReason: String?, + ) { + val iMetaAttachments = IMetaAttachments() + iMetaAttachments.add(result, caption, contentWarningReason) + + val toUsers = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() } + + val template = + ChatMessageEvent.build(result.url, toUsers) { + references(listOf(result.url)) + + if (!caption.isNullOrEmpty()) { + alt(caption) + } + contentWarningReason?.let { contentWarning(it) } + + imetas(iMetaAttachments.filterIsIn(setOf(result.url))) + } + + account.sendNip17PrivateMessage(template) + } + // ------ // NIP 04 // ------ From 3764cd6d7f6b50008de8a4d2326dfebc79a3dcdc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 17 Mar 2026 11:51:02 -0400 Subject: [PATCH 129/130] missing imports --- .../amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 8f5f98416..363aac098 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch sealed class SendState { From cc98ac72c1e05803012d01451b0f38abfd8272eb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 17 Mar 2026 12:04:10 -0400 Subject: [PATCH 130/130] Revert to 20 since Alby only replies if limit is 20. --- .../amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt | 5 +++-- .../com/vitorpamplona/quartz/nip47WalletConnect/Request.kt | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index 363aac098..770ae0cbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -118,7 +118,7 @@ class WalletViewModel : ViewModel() { private val _hasMoreTransactions = MutableStateFlow(true) val hasMoreTransactions = _hasMoreTransactions.asStateFlow() - private val pageSize = 100 + private val pageSize = 20 private val _error = MutableStateFlow(null) val error = _error.asStateFlow() @@ -258,7 +258,8 @@ class WalletViewModel : ViewModel() { when (response) { is ListTransactionsSuccessResponse -> { val newTxs = response.result?.transactions ?: emptyList() - allTransactions.value = allTransactions.value + newTxs + + allTransactions.value += newTxs val totalCount = response.result?.total_count _hasMoreTransactions.value = if (totalCount != null) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt index fe00ca44c..b338438ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Request.kt @@ -131,7 +131,10 @@ class ListTransactionsMethod( type: String? = null, unpaid_outgoing: Boolean? = null, unpaid_incoming: Boolean? = null, - ): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type)) + ): ListTransactionsMethod = + ListTransactionsMethod( + ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type), + ) } }