From a9c41e1d55546e59a18a3c5c459bd0dbedc58b53 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 23 Mar 2026 19:31:58 +0100 Subject: [PATCH 01/47] Introduce uri-reference-kmp and replace uriBridge usages with it. --- quartz/build.gradle.kts | 3 +++ .../vitorpamplona/quartz/utils/Rfc3986.ios.kt | 20 +++++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 93a3214c5e..3aad8ae615 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -144,6 +144,7 @@ kotlin { } binaries.framework { baseName = xcfName + //isStatic = true binaryOption("bundleId", "com.vitorpamplona.quartz") } } @@ -154,6 +155,7 @@ kotlin { } binaries.framework { baseName = xcfName + //isStatic = true binaryOption("bundleId", "com.vitorpamplona.quartz") } } @@ -329,6 +331,7 @@ kotlin { implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") + implementation("io.github.kotlingeekdev:uri-reference-kmp:1.0") } } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt index c782de2f9c..9c7784a030 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt @@ -20,30 +20,30 @@ */ package com.vitorpamplona.quartz.utils +import io.kotlingeekdev.urireference.URIReference import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSURLComponents -import swiftbridge.Rfc3986UriBridge @OptIn(ExperimentalForeignApi::class) actual object Rfc3986 { - private val rfc3986UriBridge = Rfc3986UriBridge() actual fun normalize(uri: String): String = - rfc3986UriBridge - .normalizeUrlWithUrl(uri, null) - ?.let { if (it.last() == '/') it else "$it/" } ?: throw Exception("Could not normalize URI: $uri") + URIReference.parse(uri).normalize().toString() - actual fun isValidUrl(url: String): Boolean = rfc3986UriBridge.isUrlValidWithUrl(url) + actual fun isValidUrl(url: String): Boolean = runCatching { + URIReference.parse(url) + }.isSuccess actual fun normalizeAndRemoveFragment(url: String): String = - NSURLComponents(url) + URIReference + .parse(url) + .normalize()!! .toStringNoFragment() .internIfPossible() - actual fun host(url: String): String = rfc3986UriBridge.hostFromUriWithUrl(url, null) ?: throw Exception("Could not retrieve host from URL.") + actual fun host(url: String): String = URIReference.parse(url).host?.value.toString() } -fun NSURLComponents.toStringNoFragment(): String { +fun URIReference.toStringNoFragment(): String { val sb = StringBuilder() if (scheme != null) sb.append(scheme).append(":") From f2bdb24ee56056e9ed353da0507f67c069fbe7c9 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 23 Mar 2026 19:38:10 +0100 Subject: [PATCH 02/47] Removal of references to APIs using the Swift bridge. --- .../com/vitorpamplona/quartz/utils/Rfc3986.ios.kt | 2 -- .../kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt | 11 +++-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt index 9c7784a030..69290fc356 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt @@ -21,9 +21,7 @@ package com.vitorpamplona.quartz.utils import io.kotlingeekdev.urireference.URIReference -import kotlinx.cinterop.ExperimentalForeignApi -@OptIn(ExperimentalForeignApi::class) actual object Rfc3986 { actual fun normalize(uri: String): String = diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt index ad697c7f99..46a95ee3ce 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt @@ -20,12 +20,7 @@ */ package com.vitorpamplona.quartz.utils -import kotlinx.cinterop.ExperimentalForeignApi -import swiftbridge.UrlDetector +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -@Suppress("UNCHECKED_CAST") -@OptIn(ExperimentalForeignApi::class) -actual fun fastFindURLs(text: String): List { - val detectorInstance = UrlDetector() - return detectorInstance.findURLsWithText(text) as List -} + +actual fun fastFindURLs(text: String): List = UrlDetector(text).detect().map { it.originalUrl } From cfecfe213047b21214a8caa50b264859254dbc2a Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 23 Mar 2026 19:49:24 +0100 Subject: [PATCH 03/47] Move uri-reference-kmp to version catalog. Remove swift bridge(was a nice run). --- gradle/libs.versions.toml | 2 ++ quartz/build.gradle.kts | 22 +----------------- .../swift/swiftbridge/Rfc3986UriBridge.swift | 23 ------------------- .../src/swift/swiftbridge/UrlDetector.swift | 21 ----------------- 4 files changed, 3 insertions(+), 65 deletions(-) delete mode 100644 quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift delete mode 100644 quartz/src/swift/swiftbridge/UrlDetector.swift diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a2b35468a..899dbb406e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ torAndroid = "0.4.9.5.1" translate = "17.0.3" jetbrainsCompose = "1.10.3" unifiedpush = "3.0.10" +uriReferenceKmp = "1.0" vico-charts-compose = "3.0.3" zelory = "3.0.1" zoomable = "2.11.1" @@ -128,6 +129,7 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" } +uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" } vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 3aad8ae615..3b0eca3cfd 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -1,9 +1,5 @@ -@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 @@ -13,7 +9,6 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) alias(libs.plugins.vanniktech.mavenPublish) - alias(libs.plugins.frankois944.spmForKmp) } kotlin { @@ -121,21 +116,6 @@ kotlin { dependsOn(libsodiumDefFileGeneration) } } - - target.swiftPackageConfig(cinteropName = "swiftbridge") { - minIos = "17" - minMacos = "14" - dependency { - remotePackageVersion( - url = uri("https://github.com/swift-standards/swift-rfc-3986.git"), - packageName = "swift-rfc-3986", - products = { - add("RFC 3986") - }, - version = "0.1.0", - ) - } - } } iosArm64 { @@ -331,7 +311,7 @@ kotlin { implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") - implementation("io.github.kotlingeekdev:uri-reference-kmp:1.0") + implementation(libs.uri.reference.kmp) } } diff --git a/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift b/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift deleted file mode 100644 index bafd34a5b0..0000000000 --- a/quartz/src/swift/swiftbridge/Rfc3986UriBridge.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by NullDev on 31/12/2025. -// - -import Foundation -import RFC_3986 - -@objcMembers public class Rfc3986UriBridge: NSObject { - public func normalizeUrl(url: String) throws -> String { - let uri = try RFC_3986.URI(url) - let normalized = uri.normalized() - return normalized.value - } - - public func isUrlValid(url: String) -> Bool { - return RFC_3986.isValidURI(url) - } - - public func hostFromUri(url: String) throws -> String { - let actualUri = try RFC_3986.URI(url) - return actualUri.host! - } -} diff --git a/quartz/src/swift/swiftbridge/UrlDetector.swift b/quartz/src/swift/swiftbridge/UrlDetector.swift deleted file mode 100644 index e4927c516d..0000000000 --- a/quartz/src/swift/swiftbridge/UrlDetector.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Created by NullDev on 20/01/2026. -// - -import Foundation - -@objcMembers public class UrlDetector: NSObject { - public func findURLs(text: String) -> [String] { - var links = [String]() - let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - let matches = detector.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) - - for match in matches { - guard let range = Range(match.range, in: text) else { continue } - let url = text[range] - links.append(String(url)) - } - - return links - } -} \ No newline at end of file From b495f3f409ed1c08087dc7b694802483a36c4e6d Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 23 Mar 2026 19:50:26 +0100 Subject: [PATCH 04/47] NIP19 tests for iOS. --- .../quartz/nip19Bech32/NIP19EmbedTests.kt | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt new file mode 100644 index 0000000000..f5042e2d1b --- /dev/null +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19EmbedTests.kt @@ -0,0 +1,152 @@ +/* + * 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.nip19Bech32 + +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NIP19EmbedTests { + @Test + fun testEmbedKind1Event() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val textNote = + signer.sign( + TextNoteEvent.build("I like this. It could solve the ninvite problem in #1062, and it seems like it could be applied very broadly to limit the spread of events that shouldn't stand on their own or need to be private. The one question I have is how long are these embeds? If it's 50 lines of text, that breaks the human readable (or at least parseable) requirement of kind 1s. Also, encoding json in a tlv is silly, we should at least use the tlv to reduce the payload size."), + ) + + assertNotNull(textNote) + + val bech32 = NEmbed.create(textNote) + + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(textNote.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionEmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionFhir)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionBundleEmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testVisionPrescriptionBundle2EmbedEvent() = + runTest { + val signer = + NostrSignerInternal( + KeyPair(decodePrivateKeyAsHexOrNull("nsec1arn3jlxv20y76n8ek8ydecy9ga06rl7aw8evznjylc3ap00hwkvqx4vvy6")!!.hexToByteArray()), + ) + + val eyeglassesPrescriptionEvent = signer.sign(FhirResourceEvent.build(visionPrescriptionBundle2)) + + assertNotNull(eyeglassesPrescriptionEvent) + + val bech32 = NEmbed.create(eyeglassesPrescriptionEvent) + + println(eyeglassesPrescriptionEvent.toJson()) + println(bech32) + + val decodedNote = (Nip19Parser.uriToRoute(bech32)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(eyeglassesPrescriptionEvent.toJson(), decodedNote.toJson()) + } + + @Test + fun testTimsNembed() { + val uri = "nembed1r79ssq9446hkwqhl642ukmku8qg0c92pu7w3j0jyfte8tc7tvg85vmrys8x3sqgle5vjy7jpjswqhphl0kd6yf4sz0n3peyjq5rp3zkat4w6c6j3f7um0724jmfu5456xxgg2yxkn8dp23j64xsn9npcggzafyh2effyntqrqxzja8dp52kpcvc9zqxlj86e8mx05vevzxkeprjkfs4wmppxm3p96vj6yvu2mqgf5l4v99492r2qsggquxuv93uzx244652h2kkj8xseg9xkq0afpygknjtty9j4ju5v0nm9mezux9wyl6s5wr7lzce7cj397mnu0u04ha7aq3w7exelrhe3zs3l3urwa9sp36u80npllrs0hmsxqdn0fsuyav3nv0azjs5suzuurg2uymncjxez8p9xksc2j6gw992enjflgrdd7n5uq2xrpvfrd3rckw624ey0elvm6grr27tyzlf4vaswgm5vc3hdyczsl983g2j8e67r6z5zt30lat84ma4wclkwwxxrcflvdsuwd7346h7zqav4vdwe3gkt9lr87sfk4aqd2aey03tt4eyspldrqcmkx9pqe2pn63rv7grwwalr86akuldnvjm6m87wrw9sdwns8wq0rnsmj57vqwtc3g7hkwum3vl2dda78dwkycgfzw6qna3ufhpatcvq5a4hm4ehl45an8umwt0clf7rn77ctke475qglwu86hhfwhn7dkca4pkfpyc4y75rll6nvr5qc8nlhf8mk22celn5mecvyuzxd830drhdck9tcdpcafymk8wajwu2w8ha8gatggjfvq0a4jlf2sdamzj0ysqks9dk8me3q7a0qpmf6vykurkrcls4pug3u4pn4u26ezx3h8e482n07x2nsmu80dpufxqc0ttcyzhnppguxma4d8aumdawnlsyy7yzcuxl7lw5y9p4nv5h8fn6u8anpm2tsze3p6mgxy9j9uuqfxg2jvlmtjpakna5m4hln0msmw804hnun96h66fh62270yhhljnmmdl7jln07ll5vft7e870hemcld34a09n943ed6629fgtctsftma9q6tf4jfm2p0ukd2j2n2dpz53fqrkk4ctdcy2j5jar095g5jntf6u807ggkzauzt6uqkwk4tg5w7w55kskspc9663zx5dzzzfwpg3q546g2ve4kukr70n0a46eyce2crsqqq247ql5" + + val decodedNote = (Nip19Parser.uriToRoute(uri)?.entity as NEmbed).event + + assertTrue(decodedNote.verify()) + + assertEquals(timsPrescription, decodedNote.toJson()) + } + + val visionPrescriptionFhir = "{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"Patient/Donald Duck\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"Practitioner/Adam Careful\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}" + val visionPrescriptionBundle = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Careful\",\"given\":[\"Adam\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Duck\",\"given\":[\"Donald\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"prescriber\":{\"reference\":\"#2\"},\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + + val visionPrescriptionBundle2 = "{\"resourceType\":\"Bundle\",\"id\":\"bundle-vision-test\",\"type\":\"document\",\"entry\":[{\"resourceType\":\"Practitioner\",\"id\":\"2\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Smith\",\"given\":[\"Dr. Joe\"]}],\"gender\":\"male\"},{\"resourceType\":\"Patient\",\"id\":\"1\",\"active\":true,\"name\":[{\"use\":\"official\",\"family\":\"Doe\",\"given\":[\"Jane\"]}],\"gender\":\"male\"},{\"resourceType\":\"VisionPrescription\",\"status\":\"active\",\"created\":\"2014-06-15\",\"patient\":{\"reference\":\"#1\"},\"dateWritten\":\"2014-06-15\",\"lensSpecification\":[{\"eye\":\"right\",\"sphere\":-2,\"prism\":[{\"amount\":0.5,\"base\":\"down\"}],\"add\":2},{\"eye\":\"left\",\"sphere\":-1,\"cylinder\":-0.5,\"axis\":180,\"prism\":[{\"amount\":0.5,\"base\":\"up\"}],\"add\":2}]}]}" + val timsPrescription = "{\"id\":\"18d8b22e6455dfc9f4c6d6be8c2cf015e961b8d160dfe5e4b7fc1578f2c4e0be\",\"pubkey\":\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\",\"created_at\":1739566773,\"kind\":82,\"tags\":[[\"p\",\"46f1826abf5b03de972192e619e25fa94d775a1c555efe53a775412dbf49889b\"]],\"content\":\"{\\\"resourceType\\\": \\\"VisionPrescription\\\", \\\"id\\\": \\\"eyeglass-prescription-001\\\", \\\"status\\\": \\\"active\\\", \\\"created\\\": \\\"2025-02-14T10:00:00Z\\\", \\\"patient\\\": {\\\"reference\\\": \\\"Patient/12345\\\", \\\"display\\\": \\\"John Doe\\\"}, \\\"encounter\\\": {\\\"reference\\\": \\\"Encounter/67890\\\"}, \\\"dateWritten\\\": \\\"2025-02-10T15:00:00Z\\\", \\\"prescriber\\\": {\\\"reference\\\": \\\"Practitioner/56789\\\", \\\"display\\\": \\\"Dr. Emily Smith\\\"}, \\\"lensSpecification\\\": [{\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"right\\\", \\\"sphere\\\": -2.5, \\\"cylinder\\\": -1.0, \\\"axis\\\": 180, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"up\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Right eye prescription for near-sightedness with astigmatism.\\\"}]}, {\\\"product\\\": {\\\"coding\\\": [{\\\"system\\\": \\\"http://terminology.hl7.org/CodeSystem/ex-visionprescriptionproduct\\\", \\\"code\\\": \\\"lens\\\", \\\"display\\\": \\\"Eyeglasses\\\"}]}, \\\"eye\\\": \\\"left\\\", \\\"sphere\\\": -3.0, \\\"cylinder\\\": -0.75, \\\"axis\\\": 160, \\\"prism\\\": [{\\\"amount\\\": 0.5, \\\"base\\\": \\\"down\\\"}], \\\"add\\\": 2.0, \\\"duration\\\": {\\\"value\\\": 24, \\\"unit\\\": \\\"months\\\", \\\"system\\\": \\\"http://unitsofmeasure.org\\\", \\\"code\\\": \\\"mo\\\"}, \\\"note\\\": [{\\\"text\\\": \\\"Left eye prescription for near-sightedness with astigmatism.\\\"}]}]}\",\"sig\":\"d22d3b86aea397094de8b6cdf69decdfd886c90008aeebf95fd43a2770b37d486b313bff5bdb44b78e33bbaa3f336d74ee8b36bc5b16050374054246c72d93c2\"}" +} From bddff36e6ba134aafaeaeef47c1eea28815d768c Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 23 Mar 2026 19:55:35 +0100 Subject: [PATCH 05/47] androidLibrary {} -> android {} --- quartz/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 3b0eca3cfd..30d120c07f 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -21,7 +21,7 @@ kotlin { } } - androidLibrary { + android { namespace = "com.vitorpamplona.quartz" compileSdk = libs.versions.android.compileSdk From ac38a73b5eaf5e1e2ec3831e8bb4a30dd605c77e Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Mon, 23 Mar 2026 20:27:04 +0100 Subject: [PATCH 06/47] spotless apply. --- .../vitorpamplona/quartz/utils/Rfc3986.ios.kt | 18 +++++++++++------- .../com/vitorpamplona/quartz/utils/URLs.ios.kt | 1 - 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt index 69290fc356..07da65b6d6 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt @@ -23,13 +23,12 @@ package com.vitorpamplona.quartz.utils import io.kotlingeekdev.urireference.URIReference actual object Rfc3986 { + actual fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString() - actual fun normalize(uri: String): String = - URIReference.parse(uri).normalize().toString() - - actual fun isValidUrl(url: String): Boolean = runCatching { - URIReference.parse(url) - }.isSuccess + actual fun isValidUrl(url: String): Boolean = + runCatching { + URIReference.parse(url) + }.isSuccess actual fun normalizeAndRemoveFragment(url: String): String = URIReference @@ -38,7 +37,12 @@ actual object Rfc3986 { .toStringNoFragment() .internIfPossible() - actual fun host(url: String): String = URIReference.parse(url).host?.value.toString() + actual fun host(url: String): String = + URIReference + .parse(url) + .host + ?.value + .toString() } fun URIReference.toStringNoFragment(): String { diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt index 46a95ee3ce..bdb01fd24c 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt @@ -22,5 +22,4 @@ package com.vitorpamplona.quartz.utils import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector - actual fun fastFindURLs(text: String): List = UrlDetector(text).detect().map { it.originalUrl } From 8525f2b930fe3762de4f9738b2e4a3ac2dab5950 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 02:50:46 +0000 Subject: [PATCH 07/47] feat: add poll type selector for single and multiple choice polls The poll creation UI now shows filter chips to choose between single choice and multiple choice poll types. Previously it was hardcoded to single choice only. https://claude.ai/code/session_01PEsEyGiNvNcNPSjREN4WzW --- .../note/creators/polls/PollOptionsField.kt | 32 +++++++++++++++++++ .../loggedIn/home/ShortNotePostViewModel.kt | 6 +++- amethyst/src/main/res/values/strings.xml | 2 ++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt index 5a973d3047..a95acdcdf8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.creators.polls import android.annotation.SuppressLint import androidx.compose.foundation.BorderStroke +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 @@ -32,6 +34,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -47,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.utils.RandomInstance @Composable @@ -55,6 +59,13 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) { Column( modifier = Modifier.fillMaxWidth(), ) { + PollTypeSelector( + selectedType = postViewModel.pollType, + onTypeSelected = { postViewModel.pollType = it }, + ) + + Spacer(Modifier.height(4.dp)) + optionsList.forEach { option -> OutlinedTextField( modifier = Modifier.fillMaxWidth(), @@ -118,6 +129,27 @@ fun PollOptionsField(postViewModel: ShortNotePostViewModel) { } } +@Composable +fun PollTypeSelector( + selectedType: PollType, + onTypeSelected: (PollType) -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedType == PollType.SINGLE_CHOICE, + onClick = { onTypeSelected(PollType.SINGLE_CHOICE) }, + label = { Text(stringRes(R.string.poll_single_choice)) }, + ) + FilterChip( + selected = selectedType == PollType.MULTI_CHOICE, + onClick = { onTypeSelected(PollType.MULTI_CHOICE) }, + label = { Text(stringRes(R.string.poll_multiple_choice)) }, + ) + } +} + @SuppressLint("ViewModelConstructorInComposable") @Preview @Composable 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 2bbded69bd..f5d07a8c7f 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 @@ -117,6 +117,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -238,6 +239,7 @@ open class ShortNotePostViewModel : var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) var pollOptions: SnapshotStateMap = newStateMapPollOptions() + var pollType by mutableStateOf(PollType.SINGLE_CHOICE) var closedAt by mutableLongStateOf(TimeUtils.oneDayAhead()) // ZapPolls @@ -596,6 +598,7 @@ open class ShortNotePostViewModel : pollOptions[index] = tag } + pollType = draftEvent.pollType() ?: PollType.SINGLE_CHOICE closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() message = TextFieldValue(draftEvent.content) @@ -828,7 +831,7 @@ open class ShortNotePostViewModel : accountViewModel.account.nip65RelayList.outboxFlow.value .toList() - PollEvent.build(tagger.message, options, closedAt, relays) { + PollEvent.build(tagger.message, options, closedAt, relays, pollType) { pTags(tagger.directMentionsUsers.map { it.toPTag() }) quotes(quotes) hashtags(findHashtags(tagger.message)) @@ -1054,6 +1057,7 @@ open class ShortNotePostViewModel : wantsPoll = false pollOptions = newStateMapPollOptions() + pollType = PollType.SINGLE_CHOICE closedAt = TimeUtils.oneDayAhead() wantsZapPoll = false diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b31cf49746..28f63ffea2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -483,6 +483,8 @@ Zap maximum Consensus (0–100)% + Single choice + Multiple choice Poll Closing Date & Time Poll closes in %1$s Close after From 6e0e8cf00f8e0a76617ce531686b45ba18e9608b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 04:01:27 +0000 Subject: [PATCH 08/47] feat: add drag-to-seek support on video progress indicator The progress scrubber can now be dragged to seek through the video, in addition to the existing tap-to-seek. During drag, the scrubber enlarges for visual feedback and tracks the finger position in real-time. Seeking is applied on drag end. https://claude.ai/code/session_01QvYAvNdC35zGgkdpnujFn2 --- .../HorizontalLinearProgressIndicator.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt index 88628f69a2..252c9e7406 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls import androidx.annotation.OptIn import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -30,7 +31,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable 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 @@ -103,21 +106,43 @@ private fun HorizontalLinearProgressIndicator( scrubberColor: Color = playedColor, rectHeightDp: Dp = 4.dp, ) { + var isDragging by remember { mutableStateOf(false) } + var dragProgress by remember { mutableFloatStateOf(0f) } + Canvas( Modifier .fillMaxWidth() .padding(horizontal = rectHeightDp * 2.5f) .pointerInput(Unit) { detectTapGestures { offset -> - // Capture the exact position onSeek(offset.x / this.size.width.toFloat()) } + }.pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + isDragging = true + dragProgress = (offset.x / this.size.width.toFloat()).coerceIn(0f, 1f) + }, + onDrag = { change, _ -> + change.consume() + dragProgress = (change.position.x / this.size.width.toFloat()).coerceIn(0f, 1f) + }, + onDragEnd = { + onSeek(dragProgress) + isDragging = false + }, + onDragCancel = { + isDragging = false + }, + ) }.padding(vertical = rectHeightDp * 2) .height(rectHeightDp) .onSizeChanged { (w, _) -> onLayoutWidthChanged(w) }, ) { - val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f) + val displayProgress = if (isDragging) dragProgress else currentPositionProgress() + val positionX = (displayProgress * size.width).coerceAtLeast(0f) val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f) + val scrubberRadius = if (isDragging) size.height * 3f else size.height * 2f drawRect(unplayedColor, size = Size(size.width, size.height)) drawRect(bufferedColor, size = Size(bufferX, size.height)) @@ -125,7 +150,7 @@ private fun HorizontalLinearProgressIndicator( drawCircle( color = scrubberColor, - radius = size.height * 2f, + radius = scrubberRadius, center = Offset(x = positionX, y = size.height / 2.0f), ) } From c67d66981c79692a256982c39ae5e85aa1a212c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 06:15:11 +0000 Subject: [PATCH 09/47] feat: hide video controls on playback start Video controls are now hidden by default when playback begins. Users can tap to reveal them. Previously, controls were shown for 2 seconds before auto-hiding (TwoSecondController) or shown by default (VideoViewInner). https://claude.ai/code/session_01WpAqbodB5w8oDJpN6MLBs6 --- .../service/playback/composable/VideoViewInner.kt | 2 +- .../amethyst/ui/components/ZoomableContentView.kt | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index cef95667c7..f9e2ddb595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -47,7 +47,7 @@ fun VideoViewInner( authorName: String? = null, nostrUriCallback: String? = null, automaticallyStartPlayback: Boolean, - controllerVisible: MutableState = mutableStateOf(true), + controllerVisible: MutableState = mutableStateOf(false), onZoom: (() -> Unit)? = null, accountViewModel: AccountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 357f640ef7..722bd77c5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -124,7 +124,6 @@ import okhttp3.coroutines.executeAsync import okio.sink import java.io.File import java.io.IOException -import kotlin.time.Duration.Companion.seconds // Delay before cleaning up shared video temp files. // Allows time for receiving app to copy the file after user confirms share. @@ -224,12 +223,7 @@ fun TwoSecondController( content: BaseMediaContent, inner: @Composable (controllerVisible: MutableState) -> Unit, ) { - val controllerVisible = remember(content) { mutableStateOf(true) } - - LaunchedEffect(content) { - delay(2.seconds) - controllerVisible.value = false - } + val controllerVisible = remember(content) { mutableStateOf(false) } inner(controllerVisible) } From 346a7ecac2f8d0de7f72d84df14099a7e4f2ab08 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 08:27:32 +0100 Subject: [PATCH 10/47] update translations: CZ, DE, PT, SE --- amethyst/src/main/res/values-cs-rCZ/strings.xml | 1 + amethyst/src/main/res/values-de-rDE/strings.xml | 1 + amethyst/src/main/res/values-pt-rBR/strings.xml | 1 + amethyst/src/main/res/values-sv-rSE/strings.xml | 1 + 4 files changed, 4 insertions(+) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index d76098c768..372c1ff23b 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -290,6 +290,7 @@ Nostr Adresa nikdy nyní + sekundy h m d diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 5246f55671..dcd714fdfb 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1679,4 +1679,5 @@ anz der Bedingungen ist erforderlich Gesamter Zeitraum Letzte Synchronisierung: %1$s Seit letzter Synchronisierung + Sekunden diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7d3b9ef4d5..c0ed6031eb 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -290,6 +290,7 @@ Endereço Nostr nunca agora + segundos h m d diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 4011ddf584..1950f94793 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -290,6 +290,7 @@ Nostr-adress aldrig nu + sekunder t m d From 6436668808d18277d3b07ed418cc030b4db8f3bf Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 08:32:56 +0100 Subject: [PATCH 11/47] update skill --- .../skills/find-missing-translations/SKILL.md | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/.claude/skills/find-missing-translations/SKILL.md b/.claude/skills/find-missing-translations/SKILL.md index 36bf02c91c..fe0163c5de 100644 --- a/.claude/skills/find-missing-translations/SKILL.md +++ b/.claude/skills/find-missing-translations/SKILL.md @@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans ## Overview -Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs a table ready for translation. +Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them. ## When to Use @@ -15,6 +15,17 @@ Extract string resource keys from the default `values/strings.xml` that are abse - Preparing a batch of strings for a translator - Checking translation coverage after adding new features +## Target Locales + +The default set of locales (unless the user specifies otherwise): + +| Locale | Language | Directory | +|--------|----------|-----------| +| `cs-rCZ` | Czech | `values-cs-rCZ` | +| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` | +| `sv-rSE` | Swedish | `values-sv-rSE` | +| `de-rDE` | German | `values-de-rDE` | + ## Technique ### 1. Identify files @@ -24,11 +35,9 @@ Default: amethyst/src/main/res/values/strings.xml Target: amethyst/src/main/res/values-/strings.xml ``` -Default locale: `cs-rCZ` if none specified. User may override (e.g., `pt-rBR`, `ja`). +### 2. Find missing keys using cs-rCZ as reference -### 2. Extract and diff keys - -Use a single bash pipeline to extract translatable keys from both files and diff them: +Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales. ```bash # Extract translatable keys from default (exclude translatable="false") @@ -36,11 +45,11 @@ comm -23 \ <(grep 'Valid @@ -70,8 +79,18 @@ Output the missing entries as raw XML resource lines (copy-paste ready for the l Also check `` and `` tags using the same approach if the project uses them. +**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?" + +### 5. Adding translations (if approved) + +When adding translated strings to locale files: + +- **Append new strings at the bottom** of the file, just before the closing `` tag. +- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering. + ## Common Mistakes - **Forgetting `translatable="false"`** — these should never appear in locale files - **Not checking string-arrays/plurals** — only checking `` misses other resource types -- **Modifying files** — this is a read-only research task unless the user asks to add entries \ No newline at end of file +- **Diffing each locale separately** — only diff against `cs-rCZ`; assume the same keys are missing everywhere +- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately \ No newline at end of file From 1dbe552821943f5d1458d2d1350a80a4aa0e8d7c Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 24 Mar 2026 07:35:59 +0000 Subject: [PATCH 12/47] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-de-rDE/strings.xml | 2 +- amethyst/src/main/res/values-zh-rCN/strings.xml | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index dcd714fdfb..6d6b6437c8 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -294,6 +294,7 @@ anz der Bedingungen ist erforderlich Nostr-Adresse nie jetzt + Sekunden s m t @@ -1679,5 +1680,4 @@ anz der Bedingungen ist erforderlich Gesamter Zeitraum Letzte Synchronisierung: %1$s Seit letzter Synchronisierung - Sekunden diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 0d45efe82b..932ae8a94e 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -290,6 +290,7 @@ Nostr 地址 从不 现在 + @@ -1104,6 +1105,13 @@ 新社区笔记 新产品 新建地理位置限定帖文 + 新文章 + 标题 + 摘要(选填) + 封面图片URL (可选) + 用 markdown 格式撰写文章… + 预览 + 编辑 展开对此帖子的所有回应 收起对此帖子的所有回应 回复 @@ -1241,6 +1249,7 @@ OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 + 编辑文章 编辑帖子 提议改进帖子 变动摘要 @@ -1664,4 +1673,11 @@ 熟练验证类型:%1$s 证明 请求证明 + 日期范围 + + + 刚刚 + 全部时间 + 上次同步: %1$s + 自上次同步后 From 4e43b14b09fa26eb7835fb20001033d63a018886 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 09:08:58 +0100 Subject: [PATCH 13/47] entire solid recording indicator bar stops recording when tapped, not just the small stop icon. --- .../amethyst/ui/actions/uploads/RecordAudio.kt | 10 ++++++++-- .../amethyst/ui/actions/uploads/RecordVoiceButton.kt | 8 +++++--- .../amethyst/ui/actions/uploads/RecordingIndicators.kt | 8 ++++++++ .../amethyst/ui/actions/uploads/VoiceMessagePreview.kt | 2 +- .../com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 3 ++- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 9eaa1a5dae..82888202a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -50,7 +50,7 @@ fun RecordAudioBox( modifier: Modifier, onRecordTaken: (RecordingResult) -> Unit, maxDurationSeconds: Int? = null, - content: @Composable (Boolean, Int) -> Unit, + content: @Composable (Boolean, Int, () -> Unit) -> Unit, ) { val mediaRecorder = remember { mutableStateOf(null) } val context = LocalContext.current @@ -136,6 +136,12 @@ fun RecordAudioBox( } } }, - content = { active -> content(active, elapsedSeconds) }, + content = { active -> + content(active, elapsedSeconds) { + if (isRecording) { + stopRecording() + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 32613496f9..856e36e473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -50,15 +50,17 @@ fun RecordVoiceButton( ) { var isRecording by remember { mutableStateOf(false) } var elapsedSeconds by remember { mutableIntStateOf(0) } + var onStopRecording by remember { mutableStateOf({}) } Column( verticalArrangement = Arrangement.Center, ) { - // Floating recording indicator at the top + // Floating recording indicator at the top (outside ToggleableBox to avoid scale/circle) FloatingRecordingIndicator( modifier = Modifier.height(50.dp), isRecording = isRecording, elapsedSeconds = elapsedSeconds, + onClick = onStopRecording, ) RecordAudioBox( @@ -69,8 +71,7 @@ fun RecordVoiceButton( onVoiceTaken(recording) }, maxDurationSeconds = maxDurationSeconds, - ) { recordingState, elapsed -> - // Update parent state after composition completes + ) { recordingState, elapsed, onStop -> SideEffect { if (isRecording != recordingState) { isRecording = recordingState @@ -78,6 +79,7 @@ fun RecordVoiceButton( if (elapsedSeconds != elapsed) { elapsedSeconds = elapsed } + onStopRecording = onStop } Box( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 8981e320f0..1fb909cc35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -27,6 +27,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -176,6 +177,7 @@ fun FloatingRecordingIndicator( isRecording: Boolean, elapsedSeconds: Int, isCompact: Boolean = false, + onClick: (() -> Unit)? = null, ) { if (!isRecording) return @@ -199,6 +201,12 @@ fun FloatingRecordingIndicator( .background( color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(12.dp), + ).then( + if (onClick != null) { + Modifier.clickable(onClick = onClick) + } else { + Modifier + }, ), contentAlignment = Alignment.Center, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt index 198cf07113..c87d34b751 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/VoiceMessagePreview.kt @@ -215,7 +215,7 @@ private fun ReRecordButton( modifier = Modifier, onRecordTaken = onRecordTaken, maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, - ) { isRecording, elapsedSeconds -> + ) { isRecording, elapsedSeconds, _ -> val contentColor = if (isRecording) { MaterialTheme.colorScheme.onPrimary diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index f6e92f3c44..76b943fa7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -672,7 +672,7 @@ fun ReplyViaVoiceReaction( } }, maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, - ) { isRecording, elapsedSeconds -> + ) { isRecording, elapsedSeconds, onStop -> if (voiceRecordingState != null) { SideEffect { if (voiceRecordingState.value != isRecording) { @@ -689,6 +689,7 @@ fun ReplyViaVoiceReaction( isRecording = true, elapsedSeconds = elapsedSeconds, isCompact = true, + onClick = onStop, ) } else { VoiceReplyIcon(iconSizeModifier, grayTint) From e976bf9e2e0b6c737299ff7d36aa3ac96fcab690 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 09:24:28 +0100 Subject: [PATCH 14/47] =?UTF-8?q?code=20review:=20-=20Removed=20redundant?= =?UTF-8?q?=20equality=20guards=20in=20SideEffect=20=E2=80=94=20MutableSta?= =?UTF-8?q?te=20already=20suppresses=20no-op=20writes=20for=20value=20type?= =?UTF-8?q?s=20-=20Changed=20mutableStateOf({})=20to=20mutableStateOf(null?= =?UTF-8?q?)=20with=20explicit=20nullable=20type=20=E2=80=94=20clearer=20i?= =?UTF-8?q?ntent,=20no=20accidental=20no-op=20invocation=20-=20=20stopReco?= =?UTF-8?q?rding()=20now=20early-returns=20with=20=3F:=20return=20when=20n?= =?UTF-8?q?ot=20recording,=20so=20the=20Toast=20only=20shows=20for=20genui?= =?UTF-8?q?nely=20failed=20recordings=20(too=20short)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../amethyst/ui/actions/uploads/RecordAudio.kt | 7 +++---- .../amethyst/ui/actions/uploads/RecordVoiceButton.kt | 10 +++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt index 82888202a0..0154ca0499 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordAudio.kt @@ -79,7 +79,8 @@ fun RecordAudioBox( } fun stopRecording() { - val result = mediaRecorder.value?.stop() + val recorder = mediaRecorder.value ?: return + val result = recorder.stop() mediaRecorder.value = null if (result != null) { onRecordTaken(result) @@ -138,9 +139,7 @@ fun RecordAudioBox( }, content = { active -> content(active, elapsedSeconds) { - if (isRecording) { - stopRecording() - } + stopRecording() } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 856e36e473..c4ae7a504f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -50,7 +50,7 @@ fun RecordVoiceButton( ) { var isRecording by remember { mutableStateOf(false) } var elapsedSeconds by remember { mutableIntStateOf(0) } - var onStopRecording by remember { mutableStateOf({}) } + var onStopRecording: (() -> Unit)? by remember { mutableStateOf(null) } Column( verticalArrangement = Arrangement.Center, @@ -73,12 +73,8 @@ fun RecordVoiceButton( maxDurationSeconds = maxDurationSeconds, ) { recordingState, elapsed, onStop -> SideEffect { - if (isRecording != recordingState) { - isRecording = recordingState - } - if (elapsedSeconds != elapsed) { - elapsedSeconds = elapsed - } + isRecording = recordingState + elapsedSeconds = elapsed onStopRecording = onStop } From 514e90ff178aa80fd4a33c148773c08387726c75 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:13:48 -0400 Subject: [PATCH 15/47] Fixes non http uris in the references tag --- .../nip01Core/tags/references/ReferenceTag.kt | 8 ++++- .../quartz/nip10Notes/content/Urls.kt | 28 +++++++++++------ .../com/vitorpamplona/quartz/utils/Urls.kt | 23 -------------- .../nip10Notes/urls/UrlsDetectorTest.kt | 5 ++- .../vitorpamplona/quartz/utils/URLs.ios.kt | 31 ------------------- .../com/vitorpamplona/quartz/utils/Urls.kt | 25 --------------- 6 files changed, 28 insertions(+), 92 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt delete mode 100644 quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt index 2446661ed5..91d38f0aed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/references/ReferenceTag.kt @@ -52,6 +52,12 @@ class ReferenceTag { fun assemble(url: String) = arrayOf(TAG_NAME, HttpUrlFormatter.normalize(url)) - fun assemble(urls: List): List> = urls.mapTo(HashSet()) { HttpUrlFormatter.normalize(it) }.map { arrayOf(TAG_NAME, it) } + fun assemble(urls: List): List> = + urls + .mapTo(HashSet()) { + HttpUrlFormatter.normalize(it) + }.map { + arrayOf(TAG_NAME, it) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt index ad56ebffa6..c4b864ec5f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/content/Urls.kt @@ -20,15 +20,25 @@ */ package com.vitorpamplona.quartz.nip10Notes.content -import com.vitorpamplona.quartz.nip01Core.tags.references.HttpUrlFormatter -import com.vitorpamplona.quartz.utils.fastFindURLs +import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.startsWithAny +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector -fun findURLs(text: String) = fastFindURLs(text) +val rejectSchemes = + listOf( + DualCase("ftp:"), + DualCase("ftps:"), + DualCase("ws:"), + DualCase("wss:"), + DualCase("nostr:"), + DualCase("blossom:"), + ) -fun buildUrlRefs(urls: List): List> = - urls - .mapTo(HashSet()) { url -> - HttpUrlFormatter.normalize(url) - }.map { - arrayOf("r", it) +fun findURLs(text: String) = + UrlDetector(text).detect().mapNotNull { + if (it.originalUrl.startsWithAny(rejectSchemes)) { + null + } else { + it.originalUrl } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt deleted file mode 100644 index fe9785bb29..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ /dev/null @@ -1,23 +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 - -expect fun fastFindURLs(text: String): List diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt index aae685bf2b..02fbdc7b45 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip10Notes/urls/UrlsDetectorTest.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.nip10Notes.urls import com.vitorpamplona.quartz.nip10Notes.content.findURLs -import com.vitorpamplona.quartz.utils.fastFindURLs import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -31,7 +30,7 @@ class UrlsDetectorTest { @Test fun detectUrlNumber() { - val detectedLinks = fastFindURLs(testSentence) + val detectedLinks = findURLs(testSentence) assertEquals(2, detectedLinks.size) } @@ -48,7 +47,7 @@ class UrlsDetectorTest { */ @Test fun doesNotCrashOnJapaneseText() { - val detectedLinks = fastFindURLs("今北産業") + val detectedLinks = findURLs("今北産業") assertEquals(0, detectedLinks.size) } } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt deleted file mode 100644 index ad697c7f99..0000000000 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/URLs.ios.kt +++ /dev/null @@ -1,31 +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 kotlinx.cinterop.ExperimentalForeignApi -import swiftbridge.UrlDetector - -@Suppress("UNCHECKED_CAST") -@OptIn(ExperimentalForeignApi::class) -actual fun fastFindURLs(text: String): List { - val detectorInstance = UrlDetector() - return detectorInstance.findURLsWithText(text) as List -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt deleted file mode 100644 index bdb01fd24c..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Urls.kt +++ /dev/null @@ -1,25 +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 com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector - -actual fun fastFindURLs(text: String): List = UrlDetector(text).detect().map { it.originalUrl } From 8c4b2fe62b55834b0fb7ba7e7b6a3d7b4e0a402f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:30:07 -0400 Subject: [PATCH 16/47] Makes sure quotes only happen once in events. --- .../quartz/nip01Core/core/TagArrayBuilder.kt | 9 +++++++++ .../quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt | 12 ++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index f388b1dcdb..bafb762185 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -72,6 +72,15 @@ class TagArrayBuilder { return this } + fun addUniqueValueIfNew(tag: Array): TagArrayBuilder { + if (tag.has(1) || tag[0].isEmpty() || tag[1].isEmpty()) return this + val list = tagList.getOrPut(tag[0], ::mutableListOf) + if (list.none { it.valueOrNull() == tag[1] }) { + list.add(tag) + } + return this + } + fun addAll(tag: List>): TagArrayBuilder { tag.forEach(::add) return this diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 7a5bf1d0f8..8bd7e87567 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -37,12 +37,12 @@ fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it fun TagArrayBuilder.quote(entity: Entity) = when (entity) { - is NNote -> add(entity.toQuoteTagArray()) - is NEvent -> add(entity.toQuoteTagArray()) - is NAddress -> add(entity.toQuoteTagArray()) - is NEmbed -> add(entity.toQuoteTagArray()) - is NPub -> add(entity.toQuoteTagArray()) - is NProfile -> add(entity.toQuoteTagArray()) + is NNote -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NEvent -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NAddress -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NEmbed -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NPub -> addUniqueValueIfNew(entity.toQuoteTagArray()) + is NProfile -> addUniqueValueIfNew(entity.toQuoteTagArray()) else -> this } From 8d81d6d57c9a6c1ac85b033606eaf24fe70c4447 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:30:40 -0400 Subject: [PATCH 17/47] Removes the clickable NIP-05 url in the @ tagging of users --- .../userSuggestions/ShowUserSuggestionList.kt | 60 ++++++++++++++++++- .../vitorpamplona/amethyst/ui/theme/Color.kt | 2 +- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index 37c1ad1281..7a0ae4ef37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -30,23 +30,33 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.WatchAndDisplayNip05Row import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.nip05 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -145,3 +155,51 @@ fun UserLine( }, ) } + +@Composable +private fun WatchAndDisplayNip05Row( + user: User, + accountViewModel: AccountViewModel, +) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + when (val nip05State = nip05StateMetadata) { + is Nip05State.Exists -> { + NonClickableObserveAndDisplayNIP05(nip05State, accountViewModel) + } + + else -> { + Text( + text = user.pubkeyDisplayHex(), + fontSize = Font14SP, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun NonClickableObserveAndDisplayNIP05( + nip05State: Nip05State.Exists, + accountViewModel: AccountViewModel, +) { + if (nip05State.nip05.name != "_") { + Text( + text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) }, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.nip05, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel) + + Text( + text = nip05State.nip05.domain, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), + maxLines = 1, + overflow = TextOverflow.Visible, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index d34ba4609b..98c40b26bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -47,7 +47,7 @@ val FollowsFollow = Color.Yellow val NIP05Verified = Color.Blue val Nip05EmailColor = Color(0xFFb198ec) -val Nip05EmailColorDark = Color(0xFF6e5490) +val Nip05EmailColorDark = Color(0xFF765AA2) val Nip05EmailColorLight = Color(0xFFa770f3) val DarkerGreen = Color.Green.copy(alpha = 0.32f) From 94e708b5fc1157889bd1aee1aff636c1db51b5bc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:32:38 -0400 Subject: [PATCH 18/47] Reduces the need to compute naddr to index articles in bookmarks --- .../screen/loggedIn/bookmarkgroups/display/ArticleListView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt index 0c08afd98c..c407c60d2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt @@ -106,7 +106,7 @@ fun ArticleList( contentPadding = FeedPadding, state = listState, ) { - itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item -> + itemsIndexed(articles, key = { _, item -> item.address }) { _, item -> NoteCompose( baseNote = item, modifier = Modifier.animateContentSize(), From 11dabd46ba69156e2b38b3eaac20a64b955361df Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:33:37 -0400 Subject: [PATCH 19/47] Checks if p tags are user pubkeys when loading drafts. --- .../screen/loggedIn/home/ShortNotePostViewModel.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 2bbded69bd..4f6b0b73e0 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 @@ -336,6 +336,7 @@ open class ShortNotePostViewModel : val currentMentions = (replyNote.event as? TextNoteEvent) ?.mentions() + ?.toSet() ?.map { LocalCache.getOrCreateUser(it.pubKey) } ?: emptyList() @@ -490,8 +491,8 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { - LocalCache.getOrCreateUser(it[1]) + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { + LocalCache.checkGetOrCreateUser(it[1]) } draftEvent.tags.filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it[3] == "fork" }.forEach { @@ -576,8 +577,8 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { - LocalCache.getOrCreateUser(it[1]) + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { + LocalCache.checkGetOrCreateUser(it[1]) } canUsePoll = originalNote == null @@ -647,8 +648,8 @@ open class ShortNotePostViewModel : } pTags = - draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { - LocalCache.getOrCreateUser(it[1]) + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { + LocalCache.checkGetOrCreateUser(it[1]) } canUsePoll = originalNote == null From 3d9efa9938ac2ad4dd538d2deba47737fb657fc0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:33:50 -0400 Subject: [PATCH 20/47] Tries to solve a duplicate user in a lazy column bug --- .../screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt index f2c6635985..d49c291018 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt @@ -125,6 +125,7 @@ fun DisplayFollowList( val contactsState by observeNoteEventAndMap?>(contactListNote, accountViewModel) { contactList -> contactList ?.unverifiedFollowKeySet() + ?.toSet() ?.mapNotNull { accountViewModel.checkGetOrCreateUser(it) }?.toPersistentList() From c39eca51728d88b2dff2178b13904fab312afdd8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:39:09 -0400 Subject: [PATCH 21/47] Add new utility methods --- .../quartz/nip01Core/core/TagArrayBuilder.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt index bafb762185..9241a884d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt @@ -91,6 +91,16 @@ class TagArrayBuilder { return this } + fun addAllUnique(tag: Array>): TagArrayBuilder { + tag.forEach(::addUnique) + return this + } + + fun addAllUniqueValueIfNew(tag: List>): TagArrayBuilder { + tag.forEach(::addUniqueValueIfNew) + return this + } + fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() fun build() = toTypedArray() From d6d4bdc5e33d3bac61f2b6569d1e07d3e1fd1474 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:40:02 -0400 Subject: [PATCH 22/47] avoids duplicating quote tags by key + value --- .../quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt index 8bd7e87567..5fc0de6e7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/TagArrayBuilderExt.kt @@ -31,9 +31,9 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -fun TagArrayBuilder.quote(tag: QTag) = add(tag.toTagArray()) +fun TagArrayBuilder.quote(tag: QTag) = addUniqueValueIfNew(tag.toTagArray()) -fun TagArrayBuilder.quotes(tag: List) = addAll(tag.map { it.toTagArray() }) +fun TagArrayBuilder.quotes(tag: List) = addAllUniqueValueIfNew(tag.map { it.toTagArray() }) fun TagArrayBuilder.quote(entity: Entity) = when (entity) { From 898e6ac677c50f80ac30b01c025e63fd8b157868 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 09:41:23 -0400 Subject: [PATCH 23/47] Added credits --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index abb588b584..a96915bac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -268,6 +268,7 @@ Updated translations: - Bengali by @npub13qtw3yu0uc9r4yj5x0rhgy8nj5q0uyeq0pavkgt9ly69uuzxgkfqwvx23t - Chinese by hypnotichemionus4 - Spanish by @npub1luhyzgce7qtcs6r6v00ryjxza8av8u4dzh3avg0zks38tjktnmxspxq903 +- Russian by Anton Zhao # [Release v1.05.1: BugFixes](https://github.com/vitorpamplona/amethyst/releases/tag/v1.05.0) - 2025-01-08 From 0ad3180fc5bf0673b9e3bce7ae38e19470ae2ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 14:25:25 +0000 Subject: [PATCH 24/47] feat: add anonymous reply with throwaway keypair Allow users to reply to notes using a fresh, randomly generated keypair that is not linked to their account. When the "Anonymous" toggle is activated in the compose screen, the reply is signed with a new throwaway identity and broadcast to the user's outbox relays without being cached as the user's own event. - Add AnonymousPostButton composable (PersonOff icon toggle) - Add wantsAnonymousPost state to ShortNotePostViewModel - Add signAnonymouslyAndBroadcast to Account (creates temp KeyPair) - Show warning banner and hide user avatar when anonymous mode is on - Add string resources for anonymous post UI https://claude.ai/code/session_013MaeT2T9Bg4HaVe2ACNADy --- .../vitorpamplona/amethyst/model/Account.kt | 16 +++++ .../creators/anonymous/AnonymousPostButton.kt | 58 +++++++++++++++++++ .../loggedIn/home/ShortNotePostScreen.kt | 36 ++++++++++-- .../loggedIn/home/ShortNotePostViewModel.kt | 9 ++- amethyst/src/main/res/values/strings.xml | 4 ++ 5 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.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 70735fdd83..0cd0018a2f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -139,6 +139,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references @@ -1292,6 +1293,21 @@ class Account( return event } + suspend fun signAnonymouslyAndBroadcast( + template: EventTemplate, + broadcast: List = emptyList(), + ): T { + val anonymousSigner = NostrSignerInternal(KeyPair()) + val event = anonymousSigner.sign(template) + + val relayList = nip65RelayList.outboxFlow.value.toSet() + + client.send(event, relayList) + broadcast.forEach { client.send(it, relayList) } + + return event + } + /** * Creates a post event without sending it. * Returns the event, target relays, and extra events to broadcast. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.kt new file mode 100644 index 0000000000..29a0f43ea7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/anonymous/AnonymousPostButton.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.amethyst.ui.note.creators.anonymous + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PersonOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size19Modifier + +@Composable +fun AnonymousPostButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!isActive) { + Icon( + imageVector = Icons.Default.PersonOff, + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size19Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.PersonOff, + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size19Modifier, + tint = Color.Red, + ) + } + } +} 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 277c5b8439..31fe265df3 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 @@ -25,6 +25,7 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -46,6 +47,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -77,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.anonymous.AnonymousPostButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -278,16 +281,35 @@ private fun NewPostScreenBody( } } + if (postViewModel.wantsAnonymousPost) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = Size10dp, vertical = 8.dp), + verticalAlignment = CenterVertically, + ) { + Text( + text = stringRes(R.string.anonymous_reply_warning), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + ) + } + } + // Only show text input if no voice message is being posted if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { Row( modifier = Modifier.padding(vertical = Size10dp), ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) + if (!postViewModel.wantsAnonymousPost) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + } MessageField( R.string.what_s_on_your_mind, postViewModel, @@ -602,6 +624,10 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } + + AnonymousPostButton(postViewModel.wantsAnonymousPost) { + postViewModel.wantsAnonymousPost = !postViewModel.wantsAnonymousPost + } } } 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 2bbded69bd..67f686e78a 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 @@ -283,6 +283,9 @@ open class ShortNotePostViewModel : var wantsZapRaiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) + // Anonymous Reply + var wantsAnonymousPost by mutableStateOf(false) + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -712,9 +715,12 @@ open class ShortNotePostViewModel : } val version = draftTag.current + val anonymous = wantsAnonymousPost cancel() - if (accountViewModel.settings.isCompleteUIMode()) { + if (anonymous) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + } else if (accountViewModel.settings.isCompleteUIMode()) { // Tracked broadcasting with progress feedback (non-blocking) val (event, relays, extras) = accountViewModel.account.createPostEvent(template, extraNotesToBroadcast) @@ -1073,6 +1079,7 @@ open class ShortNotePostViewModel : wantsToAddGeoHash = false wantsExclusiveGeoPost = false wantsSecretEmoji = false + wantsAnonymousPost = false forwardZapTo.value = SplitBuilder() forwardZapToEditting.value = TextFieldValue("") diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b31cf49746..76130d1b35 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -539,6 +539,10 @@ No trace in Nostr, only in Lightning + Anonymous + Post as a new throwaway identity. Your account will not be linked to this reply. + This reply will be posted from a new anonymous identity + File Server Choose a server to upload this file to From 4d8be5e272928aeff946949b581cbb1dc37dc230 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 14:51:39 +0000 Subject: [PATCH 25/47] feat: scroll to replied message when clicking reply preview in chat When a user clicks on the reply preview (inner quote) in a chat message, the chat feed now scrolls to the original replied-to message in the same screen instead of doing nothing. https://claude.ai/code/session_01UF4VxoWGvvvwtLYBLBYw9w --- .../ui/screen/loggedIn/chats/feed/ChatFeedView.kt | 13 +++++++++++++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 14 +++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index 7c9846dd18..d7538f47f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState @@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import kotlinx.coroutines.launch @Composable fun RefreshingChatroomFeedView( @@ -131,6 +133,16 @@ fun ChatFeedLoaded( } } + val scope = rememberCoroutineScope() + val onScrollToNote: (Note) -> Unit = { note -> + val index = items.list.indexOfFirst { it.idHex == note.idHex } + if (index >= 0) { + scope.launch { + listState.animateScrollToItem(index) + } + } + } + LazyColumn( contentPadding = FeedPadding, modifier = Modifier.fillMaxSize(), @@ -147,6 +159,7 @@ fun ChatFeedLoaded( nav = nav, onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, + onScrollToNote = onScrollToNote, ) NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 3aeb9d13ab..7cb4527128 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -95,6 +95,7 @@ fun ChatroomMessageCompose( nav: INav, onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)? = null, ) { WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) { WatchBlockAndReport( @@ -114,6 +115,7 @@ fun ChatroomMessageCompose( nav, onWantsToReply, onWantsToEditDraft, + onScrollToNote, ) } } @@ -130,6 +132,7 @@ fun NormalChatNote( nav: INav, onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)? = null, ) { val isLoggedInUser = remember(note.author) { @@ -171,6 +174,9 @@ fun NormalChatNote( if (note.event is ChannelCreateEvent) { nav.nav(Route.PublicChatChannel(note.idHex)) true + } else if (innerQuote && onScrollToNote != null) { + onScrollToNote(note) + true } else { false } @@ -253,6 +259,7 @@ fun NormalChatNote( canPreview, accountViewModel, nav, + onScrollToNote, ) } } @@ -288,6 +295,7 @@ private fun MessageBubbleLines( canPreview: Boolean, accountViewModel: AccountViewModel, nav: INav, + onScrollToNote: ((Note) -> Unit)? = null, ) { if (baseNote.event !is DraftWrapEvent) { RenderReplyRow( @@ -298,6 +306,7 @@ private fun MessageBubbleLines( nav = nav, onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, + onScrollToNote = onScrollToNote, ) } @@ -334,9 +343,10 @@ fun RenderReplyRow( nav: INav, onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)? = null, ) { if (!innerQuote && note.replyTo?.lastOrNull() != null) { - RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft) + RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote) } } @@ -348,6 +358,7 @@ private fun RenderReply( nav: INav, onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, + onScrollToNote: ((Note) -> Unit)? = null, ) { Row(verticalAlignment = Alignment.CenterVertically) { @Suppress("ProduceStateDoesNotAssignValue") @@ -368,6 +379,7 @@ private fun RenderReply( nav = nav, onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, + onScrollToNote = onScrollToNote, ) } } From 09054d6ac5740a77b3dfb592df7e587175ac5e5f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 10:58:59 -0400 Subject: [PATCH 26/47] Fixes pool rendering when one label is large and the other is small --- .../vitorpamplona/amethyst/ui/note/types/Poll.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 174a5f58d8..5767a90dc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -412,8 +412,15 @@ private fun RenderResults( resultContent: @Composable RowScope.(user: User) -> Unit, labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), ) { + val showGallery = + remember { + card.options.all { + it.label.length < 50 + } + } + card.options.forEach { pollItem -> - RenderClosedItem(pollItem, resultContent) { + RenderClosedItem(pollItem, showGallery, resultContent) { labelContent(pollItem.code, pollItem.label) } } @@ -422,18 +429,20 @@ private fun RenderResults( @Composable private fun RenderClosedItem( item: PollItemCard, + showGallery: Boolean, resultContent: @Composable RowScope.(user: User) -> Unit, labelContent: @Composable ColumnScope.() -> Unit, ) { val tally by item.results.collectAsStateWithLifecycle(item.currentResults()) - RenderClosedItem(tally, item.label, resultContent, labelContent) + RenderClosedItem(tally, item.label, showGallery, resultContent, labelContent) } @Composable private fun RenderClosedItem( tally: TallyResults, label: String, + showGallery: Boolean, resultContent: @Composable RowScope.(user: User) -> Unit, labelContent: @Composable ColumnScope.() -> Unit, ) { @@ -496,7 +505,7 @@ private fun RenderClosedItem( Row( verticalAlignment = Alignment.CenterVertically, ) { - if (label.length < 30) { + if (showGallery) { UserGallery(tally, resultContent) } From 349781d9beea06051e89384415b1781809c2027d Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Tue, 24 Mar 2026 16:34:13 +0100 Subject: [PATCH 27/47] Use uri-reference-kmp in commonMain. Remove platform-specific implementations. --- quartz/build.gradle.kts | 11 ++-- .../com/vitorpamplona/quartz/utils/Rfc3986.kt | 45 +++++++++++-- .../vitorpamplona/quartz/utils/Rfc3986.ios.kt | 57 ---------------- .../quartz/utils/Rfc3986.jvmAndroid.kt | 65 ------------------- 4 files changed, 45 insertions(+), 133 deletions(-) delete mode 100644 quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index b9a251f694..2e79a5e2ce 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -124,7 +124,7 @@ kotlin { } binaries.framework { baseName = xcfName - //isStatic = true + isStatic = true binaryOption("bundleId", "com.vitorpamplona.quartz") } } @@ -135,7 +135,7 @@ kotlin { } binaries.framework { baseName = xcfName - //isStatic = true + isStatic = true binaryOption("bundleId", "com.vitorpamplona.quartz") } } @@ -183,6 +183,9 @@ kotlin { // SQLite KMP driver for event store api(libs.androidx.sqlite) implementation(libs.androidx.sqlite.bundled) + + // RFC3986 library(normalizes URLs) + api(libs.uri.reference.kmp) } } @@ -203,9 +206,6 @@ kotlin { dependsOn(commonMain.get()) dependencies { - // Normalizes URLs - api(libs.rfc3986.normalizer) - // Performant Parser of JSONs into Events api(libs.jackson.module.kotlin) @@ -311,7 +311,6 @@ kotlin { implementation(libs.dev.whyoleg.cryptography.provider.apple.optimal) implementation("io.github.andreypfau:kotlinx-crypto-hmac:0.0.4") implementation("io.github.andreypfau:kotlinx-crypto-sha2:0.0.4") - implementation(libs.uri.reference.kmp) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt index 3503c2edf0..a0bc76d7f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.kt @@ -20,12 +20,47 @@ */ package com.vitorpamplona.quartz.utils -expect object Rfc3986 { - fun normalize(uri: String): String +import io.kotlingeekdev.urireference.URIReference - fun isValidUrl(url: String): Boolean +object Rfc3986 { + fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString() - fun normalizeAndRemoveFragment(url: String): String + fun isValidUrl(url: String): Boolean = + runCatching { + URIReference.parse(url) + }.isSuccess - fun host(url: String): String + fun normalizeAndRemoveFragment(url: String): String = + URIReference + .parse(url) + .normalize()!! + .toStringNoFragment() + .internIfPossible() + + fun host(url: String): String = + URIReference + .parse(url) + .host + ?.value + .toString() +} + +fun URIReference.toStringSchemeHost(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (authority != null) sb.append("//").append(authority.toString()) + + return sb.toString() +} + +fun URIReference.toStringNoFragment(): String { + val sb = StringBuilder() + + if (scheme != null) sb.append(scheme).append(":") + if (host != null) sb.append("//").append(host.toString()) + if (path != null) sb.append(path) + if (query != null) sb.append("?").append(query) + + return sb.toString() } diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt deleted file mode 100644 index 07da65b6d6..0000000000 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.ios.kt +++ /dev/null @@ -1,57 +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 io.kotlingeekdev.urireference.URIReference - -actual object Rfc3986 { - actual fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString() - - actual fun isValidUrl(url: String): Boolean = - runCatching { - URIReference.parse(url) - }.isSuccess - - actual fun normalizeAndRemoveFragment(url: String): String = - URIReference - .parse(url) - .normalize()!! - .toStringNoFragment() - .internIfPossible() - - actual fun host(url: String): String = - URIReference - .parse(url) - .host - ?.value - .toString() -} - -fun URIReference.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (host != null) sb.append("//").append(host.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt deleted file mode 100644 index 4595678925..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/Rfc3986.jvmAndroid.kt +++ /dev/null @@ -1,65 +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 org.czeal.rfc3986.URIReference - -actual object Rfc3986 { - actual fun normalize(uri: String) = - URIReference - .parse(uri) - .normalize() - .toString() - - actual fun isValidUrl(url: String): Boolean = - runCatching { - URIReference.parse(url) - }.isSuccess - - actual fun normalizeAndRemoveFragment(url: String): String = - URIReference - .parse(url) - .normalize() - .toStringNoFragment() - .intern() - - actual fun host(url: String): String = URIReference.parse(url).host.value -} - -fun URIReference.toStringSchemeHost(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - - return sb.toString() -} - -fun URIReference.toStringNoFragment(): String { - val sb = StringBuilder() - - if (scheme != null) sb.append(scheme).append(":") - if (authority != null) sb.append("//").append(authority.toString()) - if (path != null) sb.append(path) - if (query != null) sb.append("?").append(query) - - return sb.toString() -} From 2a2c61973eb14c181f18e1997d52d391e374134f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 13:30:36 -0400 Subject: [PATCH 28/47] Avoids crashing the app in weird urls. --- .../amethyst/commons/richtext/UrlParser.kt | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt index 9c6c6007ba..6356b2e372 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.richtext import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.utils.DualCase +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.startsWithAny import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import kotlinx.coroutines.CancellationException @Stable class Urls( @@ -77,30 +79,35 @@ class UrlParser { val blossom = mutableSetOf() urls.forEach { url -> - if (url.isValidTopLevelDomain()) { - if (url.wroteWithSchema()) { - if (url.originalUrl.startsWithAny(httpScheme)) { - // quick exit - completeUrls.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(nostrScheme)) { - bech32.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(websocketScheme)) { - relays.add(url.originalUrl) - } else if (url.originalUrl.startsWithAny(blossomScheme)) { - blossom.add(url.originalUrl) - } else { - completeUrls.add(url.originalUrl) - } - } else { - // emails are understood as urls from the detector. - if (url.isEmail()) { - Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach { - emails.add(it.value) + try { + if (url.isValidTopLevelDomain()) { + if (url.wroteWithSchema()) { + if (url.originalUrl.startsWithAny(httpScheme)) { + // quick exit + completeUrls.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(nostrScheme)) { + bech32.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(websocketScheme)) { + relays.add(url.originalUrl) + } else if (url.originalUrl.startsWithAny(blossomScheme)) { + blossom.add(url.originalUrl) + } else { + completeUrls.add(url.originalUrl) } } else { - urlsWithoutScheme.add(url.originalUrl) + // emails are understood as urls from the detector. + if (url.isEmail()) { + Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach { + emails.add(it.value) + } + } else { + urlsWithoutScheme.add(url.originalUrl) + } } } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("UrlParser", "Trying to parse url `${url.originalUrl}` from `$content`", e) } } From cef07a2460c4e78f03cf092e68e35c66e1e5e440 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 13:33:14 -0400 Subject: [PATCH 29/47] Fixes single label domains with non ascii chars around them --- .../urldetector/detection/DomainNameReader.kt | 15 +++++++-- .../urldetector/detection/UrlDetector.kt | 23 ++++++++++++++ .../quartz/utils/urldetector/UrlTest.kt | 2 +- .../urldetector/detection/UriDetectionTest.kt | 31 +++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt index ebe64927c0..d911471726 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/DomainNameReader.kt @@ -298,11 +298,22 @@ class DomainNameReader( topLevelLength = currentLabelLength } - var lastWasAscii: Boolean? = null + var lastWasAscii: Boolean? = + if (current.isNullOrEmpty()) { + null + } else { + val last = current.last() + if (isDot(last)) { + null + } else { + last.code < INTERNATIONAL_CHAR_START + } + } var isAscii = false while (!done && !reader.eof()) { val curr: Char = reader.read() + isAscii = curr.code < INTERNATIONAL_CHAR_START if (lastWasAscii == null) { lastWasAscii = isAscii @@ -765,7 +776,7 @@ class DomainNameReader( * The start of the utf character code table which indicates that this character is an international character. * Everything below this value is either a-z,A-Z,0-9 or symbols that are not included in domain name. */ - private const val INTERNATIONAL_CHAR_START = 192 + const val INTERNATIONAL_CHAR_START = 192 /** * The maximum length of each label in the domain name. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt index f572721a51..0c1f1fa8fe 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UrlDetector.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils.urldetector.detection import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.UrlMarker import com.vitorpamplona.quartz.utils.urldetector.UrlPart +import com.vitorpamplona.quartz.utils.urldetector.detection.DomainNameReader.Companion.INTERNATIONAL_CHAR_START import kotlin.math.max import kotlin.text.deleteRange @@ -84,6 +85,9 @@ class UrlDetector( var length = 0 var position = 0 + var lastWasAscii: Boolean? = null + var isAscii = false + // until end of string read the contents while (!reader.eof()) { // read the next char to process. @@ -96,6 +100,7 @@ class UrlDetector( } readEnd(ReadEndState.InvalidUrl) length = 0 + lastWasAscii = null } '%' -> { @@ -116,6 +121,7 @@ class UrlDetector( length = 0 } } + lastWasAscii = null } '\u3002', '\uFF0E', '\uFF61', '.' -> { @@ -125,6 +131,7 @@ class UrlDetector( readEnd(ReadEndState.InvalidUrl) } length = 0 + lastWasAscii = null } '@' -> { @@ -136,6 +143,7 @@ class UrlDetector( } length = 0 } + lastWasAscii = null } '[' -> { @@ -153,6 +161,7 @@ class UrlDetector( reader.seek(beginning) } length = 0 + lastWasAscii = null } '/' -> { @@ -180,15 +189,29 @@ class UrlDetector( hasScheme = readHtml5Root() length = buffer.length } + lastWasAscii = null } ':' -> { // add the ":" to the url and check for scheme/username buffer.append(curr) length = processColon(length) + lastWasAscii = null } else -> { + isAscii = curr.code < INTERNATIONAL_CHAR_START + if (lastWasAscii == null) { + lastWasAscii = isAscii + } else if (isAscii != lastWasAscii) { + // threat changes in char as a space + if (buffer.isNotEmpty() && hasScheme) { + reader.goBack() + readDomainName(buffer.substring(length)) + } + length = 0 + } + buffer.append(curr) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt index a29c5e99e3..2cf13477b9 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt @@ -59,6 +59,6 @@ class UrlTest { assertNotNull(url.path) assertNotNull(url.query) assertNotNull(url.fragment) - assertEquals(-1, url.port) // getPart(PORT) returns null → -1 + assertEquals(443, url.port) // getPart(PORT) returns null → -1 } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt index 9e73fd43f6..7bdf5afc0f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/detection/UriDetectionTest.kt @@ -739,6 +739,37 @@ class UriDetectionTest { runTest("blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net", "blossom:9584b6d64e43747364b10276f4b821e5df09f46477b3b8c60cced3e8c647fbef.jpg?xs=blossom.primal.net") } + @Test + fun testBrokenCaseInProduction() { + runTest("今北産業") + runTest("http://test.com今北産業", "http://test.com") + runTest("ftp://test.com今北産業", "ftp://test.com") + runTest("test.com今北産業", "test.com") + runTest("wss://test.com今北産業", "wss://test.com") + runTest("blossom:test.com今北産業", "blossom:test.com") + runTest("nostr:test.com今北産業", "nostr:test.com") + runTest("nostr:test今北産業", "nostr:test") + runTest("nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + + runTest("今北産業http://test.com", "http://test.com") + runTest("今北産業ftp://test.com", "ftp://test.com") + runTest("今北産業test.com", "test.com") + runTest("今北産業wss://test.com", "wss://test.com") + runTest("今北産業blossom:test.com", "blossom:test.com") + runTest("今北産業nostr:test.com", "nostr:test.com") + runTest("今北産業nostr:test", "nostr:test") + runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + + runTest("今北産業http://test.com今北産業", "http://test.com") + runTest("今北産業ftp://test.com今北産業", "ftp://test.com") + runTest("今北産業test.com今北産業", "test.com") + runTest("今北産業wss://test.com今北産業", "wss://test.com") + runTest("今北産業blossom:test.com今北産業", "blossom:test.com") + runTest("今北産業nostr:test.com今北産業", "nostr:test.com") + runTest("今北産業nostr:test今北産業", "nostr:test") + runTest("今北産業nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m今北産業", "nostr:nprofile1qqsv0agl52pt4e5pe586fz9vsd5phqz7je49yrcg532h2u5nejsc8gcpzamhxue69uhhxetpwf3kstnwdaejuar0v3shjtcv8453m") + } + @Test fun testFullText() { val text = From 88042f06d4d5b0afbbbc36658f209f4d315a21bc Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 24 Mar 2026 17:40:05 +0000 Subject: [PATCH 30/47] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-hu-rHU/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index db98abc09c..9f9daaf1eb 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -290,6 +290,7 @@ Nostr-cím soha most + másodperc ó p n @@ -447,6 +448,8 @@ Maximum Zap Együttműködés (0–100)% + Egyetlen lehetőség + Több lehetőség Szavazás lezárásának dátuma és ideje A szavazás lezárul %1$s múlva Szavazás lezárása From 2d25bb2592d1bcc8af369b47eb75ab2fdfee9bd6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 14:23:21 -0400 Subject: [PATCH 31/47] Sorts followers into a set to avoid LazyColumn key conflicts. --- .../followers/dal/UserProfileFollowersUserFeedViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d931e2cdc7..122ec9654e 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 @@ -54,8 +54,8 @@ class UserProfileFollowersUserFeedViewModel( { it.pubkeyHex }, ) - fun List.toNonHiddenOwners(): List = - mapNotNull { event -> + fun List.toNonHiddenOwners(): Set = + mapNotNullTo(mutableSetOf()) { event -> if (!account.isHidden(event.pubKey)) { account.cache.getOrCreateUser(event.pubKey) } else { From bcdaf8630c748c75ec370413e87bf9c68485d284 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 20:34:01 +0000 Subject: [PATCH 32/47] feat: make relay rows clickable to open relay info dialog on profile page Adds onClick parameter to RelayCompose and navigates to RelayInformationScreen when a relay row is tapped in the profile relay tab. https://claude.ai/code/session_01PBFQz53xzXoFsEHD5Ghyqr --- .../java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt | 4 +++- .../ui/screen/loggedIn/profile/relays/RelayFeedView.kt | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt index b8ef582696..26e539624a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/RelayCompose.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -59,11 +60,12 @@ fun RelayCompose( accountViewModel: AccountViewModel, onAddRelay: () -> Unit, onRemoveRelay: () -> Unit, + onClick: (() -> Unit)? = null, ) { val context = LocalContext.current Row( - modifier = StdPadding, + modifier = StdPadding.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), verticalAlignment = Alignment.CenterVertically, ) { Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index 55b70ca2c9..1bc249e054 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -105,6 +105,9 @@ private fun RenderRelayRow( onRemoveRelay = { nav.nav(Route.EditRelays) }, + onClick = { + nav.nav(Route.RelayInfo(relay.url.url)) + }, ) HorizontalDivider( thickness = DividerThickness, From cc850c5b7f0039770954255391fb5a97f9399e0d Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 24 Mar 2026 21:17:34 +0000 Subject: [PATCH 33/47] New Crowdin translations by GitHub Action --- .../src/main/res/values-pl-rPL/strings.xml | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index d956737d3e..b94ef6f7e3 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -181,7 +181,7 @@ Neutralna Anonimowy Zmienia barwę głosu. Uwaga: podstawowe zmiany barwy głosu mogą zostać wykryte przez uważnych słuchaczy. - Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satsy + Użytkownik nie ma skonfigurowanego adresu LN, aby odbierać satosze "odpowiedz tutaj.. " Kopiuje ID wpisu do schowka w celu udostępnienia w Nostr Kopiuj ID kanału (wpisu) do schowka @@ -287,6 +287,7 @@ Adres Nostr nigdy teraz + sekund godz. m d @@ -431,7 +432,7 @@ Kontroluje sposób wyświetlania Twojej tożsamości podczas wysyłania zapa. Podłącz portfel Przejrzyj kanał transmitera - Kwota zobowiązania w Satach + Kwota zobowiązania w Satoszach Wyślij Ankietę Wymagane pola: Odbiorcy zap @@ -444,6 +445,8 @@ Maksymalny Zap Konsensus (0–100)% + Pojedynczy wybór + Wielokrotny wybór Data & godzina zakończenia ankiety Ankieta zostanie zamknięta za %1$s Zamknij po @@ -633,7 +636,7 @@ Powiadamia Cię, gdy nadejdzie prywatna wiadomość Otrzymano Zapy Powiadamia Cię, gdy ktoś prześle ci zapy - %1$s Satsów + %1$s Satoszy Od %1$s dla %1$s Odpowiedz @@ -668,9 +671,9 @@ Nowy Symbol Odzewu Brak wstępnie wybranych typów reakcji dla tego użytkownika. Przytrzymaj przycisk serce, aby zmienić Zapraiser - Dodaje docelową liczbę satsów do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn - Docelowa kwota w Satach - Zapraiser przy: %1$s. %2$s satach do celu + Dodaje docelową liczbę satoszy do podniesienia dla tego wpisu. W zależności od aplikacji może być pokazywany to jako pasek postępu, aby zachęcić do darowizn + Docelowa kwota w Satoszach + Zapraiser przy: %1$s. %2$s satoszach do celu Odczytaj z Transmitera Zapisz do Transmitera Ilość w bajtach, która została wysłana do tego transmitera, w tym filtry i wydarzenia @@ -878,7 +881,7 @@ Kopiuj do schowka Skopiuj nprofile do schowka Kopiuj npub do schowka - Udostępnij lub Zapisz + Udostępnij lub zapisz Kopiuj adres URL do schowka Kopiuj ID wpisu do schowka Dodaj pliki do Galerii @@ -916,7 +919,7 @@ Szukaj i dodaj użytkownika Nick lub Login Brakująca konfiguracja LN - Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satsy + Użytkownik %1$s nie ma skonfigurowanego adresu LN, aby odbierać satosze Procentowo 25 Podziel zapsy z @@ -947,7 +950,7 @@ Mint dostarczył następujący komunikat błędu: %1$s Tokeny Cashu zostały już wydane. Cashu odebrano - %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satsów) + %1$s satsy zostały wysłane do Twojego portfela. (opłata: %2$s satoszy) W systemie nie znaleziono kompatybilnego portfela Cashu Nie można pobrać faktury z serwerów odbiorcy Twój dostawca połączenia z portfelem zwrócił następujący błąd: %1$s @@ -965,7 +968,7 @@ Nie znaleziono zwrotnego adresu URL z odpowiedzi %1$s Wystąpił błąd podczas analizowania JSON z pobierania faktury z Lightning Adresu. Sprawdź konfigurację lightning użytkownika Błąd przetwarzania pliku JSON z pobierania faktury %1$s. Sprawdź konfigurację lightning użytkownika - Nieprawidłowa kwota faktury (%1$s satsów) od %2$s. Powinieno być %3$s + Nieprawidłowa kwota faktury (%1$s satoszy) od %2$s. Powinieno być %3$s Nie można utworzyć faktury przed wysłaniem zapa. Portfel odbiorcy wysłał następujący błąd: %1$s Nie można utworzyć faktury. Wiadomość od %1$s: %2$s Nie można utworzyć faktury przed wysłaniem zapa. Element pr nie został znaleziony w powstałym JSON. @@ -992,7 +995,7 @@ iPhone 13 Stan Kategoria - Cena (w Satach) + Cena (w Satoszach) 1000 Lokalizacja Miasto, Województwo, Kraj @@ -1625,7 +1628,7 @@ nowa %1$s brak wydarzeń Eksplorator Bitcoin (OTS) - wydarzenia + wydarzeń DMs profile ustawienia transmiterów From 2ef8be8fdce6550fdf70612e8f603e6ad9b55ddd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 21:18:40 +0000 Subject: [PATCH 34/47] perf: move cache lookups from NavHost route lambdas into screen composables Route lambdas in NavHost run during navigation composition and should be kept lightweight. Cache lookups (getNoteIfExists, checkGetOrCreateNote, getOrCreateAddressableNote) and object construction (RoomId, RelayUrlNormalizer) are now deferred to the screen composables themselves, either via remember {} or inside LaunchedEffect blocks. Affected screens: PublicChatChannelScreen, LiveActivityChannelScreen, EphemeralChatScreen, GeoHashPostScreen, HashtagPostScreen, ReplyCommentPostScreen, NewProductScreen, LongFormPostScreen, ShortNotePostScreen, NewPublicMessageScreen, ImportFollowListPickFollowsScreen. https://claude.ai/code/session_01NrVHL4zdCghQvqE8xmi4Gf --- .../amethyst/ui/navigation/AppNavigation.kt | 78 ++++++++----------- .../nip22Comments/GenericCommentPostScreen.kt | 14 ++-- .../ephemChat/EphemeralChatScreen.kt | 16 +++- .../PublicChatChannelScreen.kt | 9 ++- .../LiveActivityChannelScreen.kt | 10 ++- .../nip23LongForm/LongFormPostScreen.kt | 8 +- .../nip99Classifieds/NewProductScreen.kt | 10 +-- .../loggedIn/geohash/GeoHashPostScreen.kt | 14 ++-- .../loggedIn/hashtag/HashtagPostScreen.kt | 14 ++-- .../loggedIn/home/ShortNotePostScreen.kt | 17 ++-- .../ImportFollowListPickFollowsScreen.kt | 8 +- .../publicMessages/NewPublicMessageScreen.kt | 9 +-- 12 files changed, 110 insertions(+), 97 deletions(-) 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 057a8c6157..889b5058d5 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 @@ -130,10 +130,7 @@ 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 import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -209,11 +206,7 @@ fun AppNavigation( composableFromEnd { ReactionsSettingsScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { - ImportFollowListPickFollowsScreen( - accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(it.userHex)), - accountViewModel, - nav, - ) + ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) } composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } @@ -238,35 +231,28 @@ fun AppNavigation( composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEndArgs { - PublicChatChannelScreen( - it.id, - it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) }, - accountViewModel, - nav, - ) + PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } composableFromEndArgs { LiveActivityChannelScreen( Address(it.kind, it.pubKeyHex, it.dTag), - draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) }, + draftId = it.draftId, + replyToId = it.replyTo, accountViewModel, nav, ) } composableFromEndArgs { - RelayUrlNormalizer.normalizeOrNull(it.relayUrl)?.let { relay -> - EphemeralChatScreen( - channelId = RoomId(it.id, relay), - draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - replyTo = it.replyTo?.let { hex -> accountViewModel.checkGetOrCreateNote(hex) }, - accountViewModel = accountViewModel, - nav = nav, - ) - } + EphemeralChatScreen( + id = it.id, + relayUrl = it.relayUrl, + draftId = it.draftId, + replyToId = it.replyTo, + accountViewModel = accountViewModel, + nav = nav, + ) } composableFromBottomArgs { ChannelMetadataScreen(it.id, accountViewModel, nav) } @@ -280,9 +266,9 @@ fun AppNavigation( geohash = it.geohash, message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, accountViewModel, nav, ) @@ -291,8 +277,8 @@ fun AppNavigation( composableFromBottomArgs { NewPublicMessageScreen( to = it.toKey(), - reply = it.replyId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draftId?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + replyId = it.replyId, + draftId = it.draftId, accountViewModel = accountViewModel, nav = nav, ) @@ -303,9 +289,9 @@ fun AppNavigation( hashtag = it.hashtag, message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + replyId = it.replyTo, + quoteId = it.quote, + draftId = it.draft, accountViewModel, nav, ) @@ -313,11 +299,11 @@ fun AppNavigation( composableFromBottomArgs { ReplyCommentPostScreen( - reply = it.replyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + replyId = it.replyTo, message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + quoteId = it.quote, + draftId = it.draft, accountViewModel, nav, ) @@ -327,8 +313,8 @@ fun AppNavigation( NewProductScreen( message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + quoteId = it.quote, + draftId = it.draft, accountViewModel, nav, ) @@ -336,8 +322,8 @@ fun AppNavigation( composableFromBottomArgs { LongFormPostScreen( - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + draftId = it.draft, + versionId = it.version, accountViewModel = accountViewModel, nav = nav, ) @@ -347,11 +333,11 @@ fun AppNavigation( ShortNotePostScreen( message = it.message, attachment = it.attachment?.ifBlank { null }?.toUri(), - baseReplyTo = it.baseReplyTo?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - quote = it.quote?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - fork = it.fork?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - version = it.version?.let { hex -> accountViewModel.getNoteIfExists(hex) }, - draft = it.draft?.let { hex -> accountViewModel.getNoteIfExists(hex) }, + baseReplyToId = it.baseReplyTo, + quoteId = it.quote, + forkId = it.fork, + versionId = it.version, + draftId = it.draft, accountViewModel = accountViewModel, nav = nav, ) 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 aba7a01200..74f92eb373 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 @@ -47,7 +47,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -87,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -94,11 +94,11 @@ import kotlinx.coroutines.withContext @Composable fun ReplyCommentPostScreen( - reply: Note? = null, + replyId: HexKey? = null, message: String? = null, attachment: Uri? = null, - quote: Note? = null, - draft: Note? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -108,13 +108,13 @@ fun ReplyCommentPostScreen( val context = LocalContext.current LaunchedEffect(postViewModel, accountViewModel) { - reply?.let { + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.reply(it) } - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } - quote?.let { + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.quote(it) } message?.ifBlank { null }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt index bd7a9bb5b5..6fe892d32c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/EphemeralChatScreen.kt @@ -23,22 +23,30 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephem import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.EphemeralChatTopBar import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @Composable fun EphemeralChatScreen( - channelId: RoomId, - draft: Note? = null, - replyTo: Note? = null, + id: HexKey, + relayUrl: String, + draftId: HexKey? = null, + replyToId: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { + val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return + val channelId = remember(id, relay) { RoomId(id, relay) } + val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } } + val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } } + DisappearingScaffold( isInvertedLayout = true, topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt index f0f273dc5c..1132fd56f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28 import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel @@ -35,13 +35,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable fun PublicChatChannelScreen( channelId: HexKey?, - draft: Note?, - replyTo: Note? = null, + draftId: HexKey? = null, + replyToId: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { if (channelId == null) return + val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } } + val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } } + DisappearingScaffold( isInvertedLayout = true, topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt index 3b39ca4cb6..d04ad5cb38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivityChannelScreen.kt @@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -33,17 +33,21 @@ import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey @Composable fun LiveActivityChannelScreen( channelId: Address?, - draft: Note? = null, - replyTo: Note? = null, + draftId: HexKey? = null, + replyToId: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { if (channelId == null) return + val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } } + val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } } + DisappearingScaffold( isInvertedLayout = true, topBar = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 91678e062c..664331698b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -61,7 +61,6 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -98,12 +97,13 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @Composable fun LongFormPostScreen( - draft: Note? = null, - version: Note? = null, + draftId: HexKey? = null, + versionId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -111,6 +111,8 @@ fun LongFormPostScreen( postViewModel.init(accountViewModel) LaunchedEffect(postViewModel, accountViewModel) { + val draft = draftId?.let { accountViewModel.getNoteIfExists(it) } + val version = versionId?.let { accountViewModel.getNoteIfExists(it) } postViewModel.load(draft, version) } 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 b148a0f174..5f39bccc99 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 @@ -46,7 +46,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -83,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -93,8 +93,8 @@ import kotlinx.coroutines.withContext fun NewProductScreen( message: String? = null, attachment: Uri? = null, - quote: Note? = null, - draft: Note? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -105,10 +105,10 @@ fun NewProductScreen( LaunchedEffect(postViewModel, accountViewModel) { postViewModel.reloadRelaySet() - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } - quote?.let { + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.quote(it) } message?.ifBlank { null }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt index de1140b04f..8eb16a3965 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashPostScreen.kt @@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -42,9 +42,9 @@ fun GeoHashPostScreen( geohash: String? = null, message: String? = null, attachment: Uri? = null, - reply: Note? = null, - quote: Note? = null, - draft: Note? = null, + replyId: HexKey? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -57,13 +57,13 @@ fun GeoHashPostScreen( geohash?.let { postViewModel.newPostFor(GeohashId(it)) } - reply?.let { + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.reply(it) } - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } - quote?.let { + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.quote(it) } message?.ifBlank { null }?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt index e8e17100d1..c547347d18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagPostScreen.kt @@ -26,12 +26,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel import com.vitorpamplona.amethyst.ui.note.nip22Comments.GenericCommentPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -42,9 +42,9 @@ fun HashtagPostScreen( hashtag: String? = null, message: String? = null, attachment: Uri? = null, - reply: Note? = null, - quote: Note? = null, - draft: Note? = null, + replyId: HexKey? = null, + quoteId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -57,13 +57,13 @@ fun HashtagPostScreen( hashtag?.let { postViewModel.newPostFor(HashtagId(it)) } - reply?.let { + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.reply(it) } - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } - quote?.let { + quoteId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.quote(it) } message?.ifBlank { null }?.let { 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 277c5b8439..603376d7f8 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 @@ -59,7 +59,6 @@ import androidx.compose.ui.unit.dp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS @@ -112,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -123,11 +123,11 @@ import kotlinx.coroutines.withContext fun ShortNotePostScreen( message: String? = null, attachment: Uri? = null, - baseReplyTo: Note? = null, - quote: Note? = null, - fork: Note? = null, - version: Note? = null, - draft: Note? = null, + baseReplyToId: HexKey? = null, + quoteId: HexKey? = null, + forkId: HexKey? = null, + versionId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -138,6 +138,11 @@ fun ShortNotePostScreen( val activity = context.getActivity() LaunchedEffect(postViewModel, accountViewModel) { + val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) } + val quote = quoteId?.let { accountViewModel.getNoteIfExists(it) } + val fork = forkId?.let { accountViewModel.getNoteIfExists(it) } + val version = versionId?.let { accountViewModel.getNoteIfExists(it) } + val draft = draftId?.let { accountViewModel.getNoteIfExists(it) } postViewModel.load(baseReplyTo, quote, fork, version, draft) message?.ifBlank { null }?.let { postViewModel.updateMessage(TextFieldValue(it)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt index f2c6635985..71d1e95569 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt @@ -68,16 +68,22 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserLine import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList @Composable fun ImportFollowListPickFollowsScreen( - contactListNote: AddressableNote, + userHex: HexKey, accountViewModel: AccountViewModel, nav: INav, ) { + val contactListNote = + remember(userHex) { + accountViewModel.getOrCreateAddressableNote(ContactListEvent.createAddress(userHex)) + } + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { 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 3a0e7ae936..f6ab68d8a0 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 @@ -55,7 +55,6 @@ 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.model.Note import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -108,8 +107,8 @@ import kotlinx.coroutines.withContext @Composable fun NewPublicMessageScreen( to: Set? = null, - reply: Note? = null, - draft: Note? = null, + replyId: HexKey? = null, + draftId: HexKey? = null, accountViewModel: AccountViewModel, nav: Nav, ) { @@ -121,10 +120,10 @@ fun NewPublicMessageScreen( to?.let { postViewModel.load(it) } - reply?.let { + replyId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.reply(it) } - draft?.let { + draftId?.let { accountViewModel.getNoteIfExists(it) }?.let { postViewModel.editFromDraft(it) } } From f11172216d43beac0e99c2cf4320e047e1441081 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 17:20:11 -0400 Subject: [PATCH 35/47] Improvements to the status of attestations. Validity first, then Processing status. --- .../amethyst/ui/note/types/Attestation.kt | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt index 6eb4dae2e7..49dfbc4401 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Attestation.kt @@ -34,13 +34,17 @@ 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.Approval import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HourglassEmpty import androidx.compose.material.icons.filled.HourglassTop import androidx.compose.material.icons.filled.Recommend +import androidx.compose.material.icons.filled.RemoveDone import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -591,13 +595,13 @@ private fun attestationColor( validity: Validity?, ): Color = when { - status == AttestationStatus.REVOKED -> Color(0xFFB71C1C) - status == AttestationStatus.REJECTED -> Color(0xFFB71C1C) validity == Validity.INVALID -> Color(0xFFB71C1C) - status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32) validity == Validity.VALID -> Color(0xFF2E7D32) - status == AttestationStatus.VERIFYING -> Color(0xFFF57F17) - status == AttestationStatus.ACCEPTED -> Color(0xFF1565C0) + status == AttestationStatus.REVOKED -> Color(0xFFB21CB7) + status == AttestationStatus.REJECTED -> Color(0xFFB71C1C) + status == AttestationStatus.VERIFIED -> Color(0xFF2E7D32) + status == AttestationStatus.VERIFYING -> Color(0xFF173CF5) + status == AttestationStatus.ACCEPTED -> Color(0xFF15C0C0) else -> Color(0xFF757575) } @@ -606,14 +610,14 @@ private fun attestationIcon( validity: Validity?, ): ImageVector = when { - status == AttestationStatus.REVOKED -> Icons.Default.Close - status == AttestationStatus.REJECTED -> Icons.Default.Close validity == Validity.INVALID -> Icons.Default.Close - status == AttestationStatus.VERIFIED -> Icons.Default.VerifiedUser validity == Validity.VALID -> Icons.Default.CheckCircle + status == AttestationStatus.REVOKED -> Icons.Default.RemoveDone + status == AttestationStatus.REJECTED -> Icons.Default.Delete + status == AttestationStatus.VERIFIED -> Icons.Default.Approval status == AttestationStatus.VERIFYING -> Icons.Default.HourglassTop - status == AttestationStatus.ACCEPTED -> Icons.Default.CheckCircle - else -> Icons.Default.VerifiedUser + status == AttestationStatus.ACCEPTED -> Icons.Default.HourglassEmpty + else -> Icons.Default.ErrorOutline } @Composable @@ -622,11 +626,11 @@ private fun attestationStatusLabel( validity: Validity?, ): String = when { + validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) + validity == Validity.VALID -> stringRes(R.string.attestation_valid) status == AttestationStatus.REVOKED -> stringRes(R.string.attestation_status_revoked) status == AttestationStatus.REJECTED -> stringRes(R.string.attestation_status_rejected) - validity == Validity.INVALID -> stringRes(R.string.attestation_invalid) status == AttestationStatus.VERIFIED -> stringRes(R.string.attestation_status_verified) - validity == Validity.VALID -> stringRes(R.string.attestation_valid) status == AttestationStatus.VERIFYING -> stringRes(R.string.attestation_status_verifying) status == AttestationStatus.ACCEPTED -> stringRes(R.string.attestation_status_accepted) else -> stringRes(R.string.attestation) From ad9596ab926da94cc9270ebd98de3c12c41b096d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 17:32:24 -0400 Subject: [PATCH 36/47] Fixes flow value and improves tor binding lifecycle. --- .../connectivity/ConnectivityManager.kt | 4 ++-- .../amethyst/ui/tor/TorManager.kt | 2 +- .../amethyst/ui/tor/TorService.kt | 22 +++++++++++++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt index b03a03fafd..91f6517ad7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/connectivity/ConnectivityManager.kt @@ -47,7 +47,7 @@ class ConnectivityManager( val isMobileOrNull: StateFlow = status .map { - (status.value as? ConnectivityStatus.Active)?.isMobile + (it as? ConnectivityStatus.Active)?.isMobile }.stateIn( scope, SharingStarted.WhileSubscribed(2000), @@ -57,7 +57,7 @@ class ConnectivityManager( val isMobileOrFalse: StateFlow = status .map { - (status.value as? ConnectivityStatus.Active)?.isMobile ?: false + (it as? ConnectivityStatus.Active)?.isMobile ?: false }.stateIn( scope, SharingStarted.WhileSubscribed(2000), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 7c17155565..3781515ff1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -81,7 +81,7 @@ class TorManager( val activePortOrNull: StateFlow = status .map { - (status.value as? TorServiceStatus.Active)?.port + (it as? TorServiceStatus.Active)?.port }.stateIn( scope, SharingStarted.WhileSubscribed(2000), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index d8c81f5648..ccdea4a0ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -31,12 +31,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import org.torproject.jni.TorService import org.torproject.jni.TorService.LocalBinder +private const val SOCKS_PORT_POLL_INTERVAL_MS = 100L + class TorService( val context: Context, ) { @@ -46,9 +47,7 @@ class TorService( trySend(TorServiceStatus.Connecting) val currentIntent = Intent(context, TorService::class.java) - - context.bindService( - currentIntent, + val serviceConnection: ServiceConnection = object : ServiceConnection { override fun onServiceConnected( name: ComponentName, @@ -59,7 +58,7 @@ class TorService( val torService = (service as LocalBinder).service while (torService.socksPort < 0) { - delay(100) + delay(SOCKS_PORT_POLL_INTERVAL_MS) } val active = TorServiceStatus.Active(torService.socksPort) @@ -74,16 +73,25 @@ class TorService( Log.d("TorService", "Tor Service Disconnected") trySend(TorServiceStatus.Off) } - }, + } + + context.bindService( + currentIntent, + serviceConnection, BIND_AUTO_CREATE, ) awaitClose { Log.d("TorService", "Stopping Tor Service") + try { + context.unbindService(serviceConnection) + } catch (e: Exception) { + Log.d("TorService", "Failed to unbind Tor Service: ${e.message}") + } launch { context.stopService(currentIntent) } trySend(TorServiceStatus.Off) } - }.distinctUntilChanged().flowOn(Dispatchers.IO) + }.flowOn(Dispatchers.IO) } From 5d0f9ac866ddd86e9e53d5e060edca90d13cbe7d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 17:41:20 -0400 Subject: [PATCH 37/47] Cleaning up residue from removing the need for rfc 3986 and smpForKmp --- gradle/libs.versions.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 899dbb406e..db9af4a9ea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,7 +48,6 @@ netUrlencoderLibVersion = "1.6.0" navigationCompose = "2.9.7" okhttp = "5.3.2" runner = "1.7.0" -rfc3986 = "0.1.2" secp256k1KmpJniAndroid = "0.23.0" securityCryptoKtx = "1.1.0" slf4j = "2.0.17" @@ -73,7 +72,6 @@ androidxExifinterface = "1.4.2" kotlinTest = "2.3.0" core = "1.7.0" mavenPublish = "0.36.0" -spmForKmpVersion = "1.6.1" sqlite = "2.6.2" [libraries] @@ -167,7 +165,6 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor net-thauvin-erik-urlencoder-lib = { module = "net.thauvin.erik.urlencoder:urlencoder-lib", version.ref = "netUrlencoderLibVersion" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", version.ref = "okhttp" } -rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" } secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } @@ -201,4 +198,3 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } -frankois944-spmForKmp = { id = "io.github.frankois944.spmForKmp", version.ref = "spmForKmpVersion" } From d90c868f323c0f9313e005d5311552afad7239a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 21:49:15 +0000 Subject: [PATCH 38/47] feat: activate anonymous reply by tapping user picture instead of bottom button Replace the bottom bar AnonymousPostButton with a tap-on-avatar activation scheme. Tapping the user picture enables anonymous mode and shows the incognito icon in its place. Tapping the incognito icon toggles back to normal mode. https://claude.ai/code/session_013MaeT2T9Bg4HaVe2ACNADy --- .../loggedIn/home/ShortNotePostScreen.kt | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) 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 31fe265df3..b66568b310 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 @@ -26,7 +26,9 @@ import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -79,7 +81,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose -import com.vitorpamplona.amethyst.ui.note.creators.anonymous.AnonymousPostButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -110,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size19Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage @@ -303,12 +305,30 @@ private fun NewPostScreenBody( Row( modifier = Modifier.padding(vertical = Size10dp), ) { - if (!postViewModel.wantsAnonymousPost) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) + if (postViewModel.wantsAnonymousPost) { + IconButton( + onClick = { postViewModel.wantsAnonymousPost = false }, + ) { + Icon( + painter = painterRes(resourceId = R.drawable.incognito, 1), + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size35Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } else { + Box( + modifier = + Modifier.clickable { + postViewModel.wantsAnonymousPost = true + }, + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + } } MessageField( R.string.what_s_on_your_mind, @@ -624,10 +644,6 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.wantsInvoice = !postViewModel.wantsInvoice } } - - AnonymousPostButton(postViewModel.wantsAnonymousPost) { - postViewModel.wantsAnonymousPost = !postViewModel.wantsAnonymousPost - } } } From fb976d168a9ea2a604371d232be62430e04d618f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 18:30:52 -0400 Subject: [PATCH 39/47] Simplifies report feed in the user profile --- .../reqCommand/user/UserObservers.kt | 36 ------------- .../screen/loggedIn/profile/ProfileScreen.kt | 4 +- .../profile/reports/ReportsTabHeader.kt | 6 ++- .../loggedIn/profile/reports/TabReports.kt | 52 +++++++++++++++---- .../reports/WatchReportsAndUpdateFeed.kt | 36 ------------- .../dal/UserProfileReportFeedViewModel.kt | 48 +++++++++++++++-- .../dal/UserProfileReportsFeedFilter.kt | 48 ----------------- 7 files changed, 93 insertions(+), 137 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 735920c6e3..34381f2de2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -45,7 +45,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.sample @@ -426,41 +425,6 @@ fun observeUserIsFollowingChannel( return flow.collectAsStateWithLifecycle(channel.roomId in account.ephemeralChatList.liveEphemeralChatList.value) } -@Composable -fun observeUserReports( - user: User, - accountViewModel: AccountViewModel, - onUpdate: () -> Unit, -) { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) - - // Subscribe in the LocalCache for changes that arrive in the device - val flow = - remember(user, onUpdate) { - user - .reports() - .receivedReportsByAuthor - .onEach { onUpdate() } - .onStart { onUpdate() } - }.collectAsStateWithLifecycle(emptyMap()) -} - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -@Composable -fun observeUserReportCount( - user: User, - accountViewModel: AccountViewModel, -): State { - // Subscribe in the relay for changes in the metadata of this user. - UserFinderFilterAssemblerSubscription(user, accountViewModel) - - // Subscribe in the LocalCache for changes that arrive in the device - val flow = remember(user) { user.reports().countFlow() } - - return flow.collectAsStateWithLifecycle(0) -} - @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserContactCardsScore( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index f2568cecf1..0fd7328768 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -229,6 +229,7 @@ fun PrepareViewModels( factory = UserProfileReportFeedViewModel.Factory( baseUser, + accountViewModel.account, ), ) @@ -273,7 +274,6 @@ fun ProfileScreen( WatchLifecycleAndUpdateModel(appRecommendations) WatchLifecycleAndUpdateModel(bookmarksFeedViewModel) WatchLifecycleAndUpdateModel(galleryFeedViewModel) - WatchLifecycleAndUpdateModel(reportsFeedViewModel) UserProfileFilterAssemblerSubscription(baseUser, accountViewModel.dataSources().profile) @@ -521,7 +521,7 @@ private fun CreateAndRenderTabs( { ZapTabHeader(zapFeedViewModel, accountViewModel) }, { BookmarkTabHeader(baseUser, accountViewModel) }, { FollowedTagsTabHeader(baseUser, accountViewModel) }, - { ReportsTabHeader(baseUser, accountViewModel) }, + { ReportsTabHeader(baseUser, reportsFeedViewModel, accountViewModel) }, { RelaysTabHeader(baseUser, accountViewModel) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt index 6de79479bc..5213e99b36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/ReportsTabHeader.kt @@ -23,18 +23,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReportCount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes @Composable fun ReportsTabHeader( baseUser: User, + reportsFeedViewModel: UserProfileReportFeedViewModel, accountViewModel: AccountViewModel, ) { - val reportCount by observeUserReportCount(baseUser, accountViewModel) + val reportCount by reportsFeedViewModel.followerCount.collectAsStateWithLifecycle() if (reportCount > 0) { Text(text = stringRes(R.string.number_reports, reportCount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt index 6c17f1b379..a08713bfe8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/TabReports.kt @@ -21,14 +21,25 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun TabReports( @@ -37,15 +48,36 @@ fun TabReports( accountViewModel: AccountViewModel, nav: INav, ) { - WatchReportsAndUpdateFeed(baseUser, feedViewModel, accountViewModel) - Column(Modifier.fillMaxHeight()) { - RefresheableFeedView( - feedViewModel, - null, - enablePullRefresh = false, - accountViewModel = accountViewModel, - nav = nav, - ) + val items by feedViewModel.followersFlow.collectAsStateWithLifecycle() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = FeedPadding, + state = rememberLazyListState(), + ) { + itemsIndexed( + items, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + Row(Modifier.fillMaxWidth().animateItem()) { + NoteCompose( + item, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = null, + isBoostedNote = false, + isHiddenFeed = false, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt deleted file mode 100644 index f5f4eb2432..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/WatchReportsAndUpdateFeed.kt +++ /dev/null @@ -1,36 +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.amethyst.ui.screen.loggedIn.profile.reports - -import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReports -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel - -@Composable -fun WatchReportsAndUpdateFeed( - baseUser: User, - feedViewModel: UserProfileReportFeedViewModel, - accountViewModel: AccountViewModel, -) { - observeUserReports(baseUser, accountViewModel) { feedViewModel.invalidateData() } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt index 8effc41007..c73d900650 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportFeedViewModel.kt @@ -23,17 +23,59 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.stateIn @Stable class UserProfileReportFeedViewModel( val user: User, -) : AndroidFeedViewModel(UserProfileReportsFeedFilter(user)) { + val account: Account, +) : ViewModel() { + val sortingModel: Comparator = + compareBy( + { it.author?.let { !account.isFollowing(it) } }, + { it.idHex }, + ) + + @OptIn(kotlinx.coroutines.FlowPreview::class) + val followersFlow: StateFlow> = + user + .reports() + .receivedReportsByAuthor + .map { + it.values.flatten().sortedWith(sortingModel) + }.sample(500) + .flowOn(Dispatchers.IO) + .stateIn( + viewModelScope, + initialValue = emptyList(), + started = SharingStarted.Lazily, + ) + + val followerCount = + followersFlow + .map { it.size } + .flowOn(Dispatchers.IO) + .stateIn( + viewModelScope, + initialValue = 0, + started = SharingStarted.Lazily, + ) + class Factory( val user: User, + val account: Account, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = UserProfileReportFeedViewModel(user) as T + override fun create(modelClass: Class): T = UserProfileReportFeedViewModel(user, account) as T } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt deleted file mode 100644 index 2027b5106e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/reports/dal/UserProfileReportsFeedFilter.kt +++ /dev/null @@ -1,48 +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.amethyst.ui.screen.loggedIn.profile.reports.dal - -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip56Reports.ReportEvent - -class UserProfileReportsFeedFilter( - val user: User, -) : AdditiveFeedFilter() { - override fun feedKey(): String = user.pubkeyHex - - override fun feed(): List = sort(innerApplyFilter(user.reportsOrNull()?.all() ?: emptyList())) - - override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) - - private fun innerApplyFilter(collection: Collection): Set = - collection - .filterTo(mutableSetOf()) { - it.event is ReportEvent && it.event?.isTaggedUser(user.pubkeyHex) == true - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) - - override fun limit() = 400 -} From 6f7c550034506ae6978502512eaa0f6a13548ba7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 18:31:10 -0400 Subject: [PATCH 40/47] Clears some inconsistencies in translations --- amethyst/src/main/res/values-bn-rBD/strings.xml | 1 - amethyst/src/main/res/values-cs-rCZ/strings.xml | 2 +- amethyst/src/main/res/values-cs/strings.xml | 2 +- amethyst/src/main/res/values-de-rDE/strings.xml | 2 +- amethyst/src/main/res/values-de/strings.xml | 2 +- amethyst/src/main/res/values-hu-rHU/strings.xml | 2 +- amethyst/src/main/res/values-lv-rLV/strings.xml | 2 +- amethyst/src/main/res/values-pl-rPL/strings.xml | 4 ++-- amethyst/src/main/res/values-pt-rBR/strings.xml | 2 +- amethyst/src/main/res/values-sl-rSI/strings.xml | 2 +- amethyst/src/main/res/values-sv-rSE/strings.xml | 2 +- amethyst/src/main/res/values-zh-rCN/strings.xml | 2 +- 12 files changed, 12 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index e371e164f0..c223f985e1 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -71,7 +71,6 @@ " অনুসরণ" " অনুসারী" "%1$s অনুসরণ" - "" প্রোফাইল নিরাপত্তা-ফিল্টার লগ আউট diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 372c1ff23b..7775e36281 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -537,7 +537,7 @@ Nový Přidat autora do seznamu sledování Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem. - Ikona pro seznam %1$s + Ikona pro seznam %1$s je veřejný člen %1$s je soukromý člen Přidat jako veřejného člena diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index b3e6b35dc8..8ad423f5f6 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -961,7 +961,7 @@ Zdá se, že zatím nemáte žádné sady sledování.\nKlepněte níže pro obnovení nebo použijte tlačítko přidat k vytvoření nové. Přidat autora do sady sledování Přidat nebo odebrat uživatele ze seznamů, nebo vytvořit nový seznam s tímto uživatelem. - Ikona pro seznam %1$s + Ikona pro seznam %1$s není v tomto seznamu Vaše sady sledování Nebyly nalezeny žádné sady sledování, nebo žádné nemáte. Klepněte níže pro obnovení nebo použijte menu pro vytvoření nové. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 6d6b6437c8..fcf5a470e9 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -543,7 +543,7 @@ anz der Bedingungen ist erforderlich Neu Autor zur Follower-Liste hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste + Symbol für Liste %1$s ist ein öffentliches Mitglied %1$s ist ein privates Mitglied Als öffentliches Mitglied hinzufügen diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 7e962d5e02..e30acbafd6 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -1001,7 +1001,7 @@ anz der Bedingungen ist erforderlich Es scheint, dass du noch keine Folge-Sets hast.\nTippe unten zum Aktualisieren oder verwende die Plus-Taste, um ein neues zu erstellen. Autor zum Folge-Set hinzufügen Benutzer zu Listen hinzufügen oder entfernen, oder eine neue Liste mit diesem Benutzer erstellen. - Symbol für %1$s-Liste + Symbol für Liste %1$s ist nicht in dieser Liste Deine Folge-Sets Keine Folge-Sets gefunden oder du hast keine. Tippe unten zum Aktualisieren oder verwende das Menü, um eines zu erstellen. diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 9f9daaf1eb..3d7172fb04 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1183,7 +1183,7 @@ Átjátszók a kimenő üzenetkhez Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. - Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért + Adjon meg 1–3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Átjátszók a bejövő üzenetkhez Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index eea0aa7ada..5d0dd475a7 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -115,7 +115,7 @@ Sekot saraksts - Ikona %1$s sarakstam + Ikona sarakstam Izveidot jaunu sarakstu Jaunais %1$s saraksts Kolekcijas nosaukums diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index b94ef6f7e3..e0790763fe 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -540,7 +540,7 @@ Nowa Dodaj autora do listy obserwowanych Dodaj lub usuń użytkownika z list, lub utwórz nową listę z tym użytkownikiem. - Ikona dla listy %1$s + Ikona dla listy %1$s jest uczestnikiem publicznym %1$s jest uczestnikiem prywatnym Dodaj jako uczestnika publicznego @@ -1408,7 +1408,7 @@ %1$d/%2$d Przekaz Przekaz %1$s - Liczba przekazów: %1$d... + Liczba przekazów: %1$d… Wysłanych przekazów: %1$d Niektóre akcje nie powiodły się Wszystkie akcje udane diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index c0ed6031eb..e22277af24 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -537,7 +537,7 @@ Novo Adicionar autor à lista de seguidores Adicionar ou remover usuário de listas, ou criar uma nova lista com este usuário. - Ícone da lista %1$s + Ícone da lista %1$s é um membro público %1$s é um membro privado Adicionar como membro público diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 755b4832f8..da5af67392 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1419,7 +1419,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem %1$d/%2$d Oddajam Oddajam %1$s - Oddajam %1$d dogodkov... + Oddajam %1$d dogodkov… Poslano %1$d dogodkov Nekaterim dogodkom ni uspelo Vsem dogodkom je uspelo diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 1950f94793..777f8fa18f 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -537,7 +537,7 @@ Ny Lägg till författare i följelista Lägg till eller ta bort användare från listor, eller skapa en ny lista med denna användare. - Ikon för %1$s-lista + Ikon för lista %1$s är en offentlig medlem %1$s är en privat medlem Lägg till som offentlig medlem diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 932ae8a94e..a63640a6ff 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1181,7 +1181,7 @@ 发件箱中继 设置您的公共发件箱中继来发布内容 创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 - 插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 + 插入 1–3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social 收件箱中继 设置您的公共收件箱中继来接收通知 From b3f190410f9965133139debff8aef01b86e49892 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 24 Mar 2026 22:33:27 +0000 Subject: [PATCH 41/47] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-bn-rBD/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index c223f985e1..e371e164f0 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -71,6 +71,7 @@ " অনুসরণ" " অনুসারী" "%1$s অনুসরণ" + "" প্রোফাইল নিরাপত্তা-ফিল্টার লগ আউট From bc9b6dc9129102bee5076e89a966d33ae8884dec Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 19:24:33 -0400 Subject: [PATCH 42/47] fixes client tags --- .../quartz/nip89AppHandlers/clientTag/EventExt.kt | 3 +-- .../quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt index 66f140b471..ca48ffadab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/EventExt.kt @@ -21,6 +21,5 @@ package com.vitorpamplona.quartz.nip89AppHandlers.clientTag import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount -fun Event.client() = tags.zapraiserAmount() +fun Event.client() = tags.client() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt index 7af90417f6..5403ffca59 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/TagArrayBuilderExt.kt @@ -24,9 +24,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) +fun TagArrayBuilder.client(name: String) = addUnique(ClientTag.assemble(name)) -fun TagArrayBuilder.client( +fun TagArrayBuilder.client( name: String, address: AddressHint, ) = addUnique(ClientTag.assemble(name, address.addressId, address.relay)) From 976de28ece3e83a9b7e077327f450b0ce76ecdbc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 19:25:01 -0400 Subject: [PATCH 43/47] Fixes routes for AppDefinition events. --- .../amethyst/ui/navigation/routes/RouteMaker.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 26dad5aaaf..905b203bd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -86,7 +86,11 @@ fun routeForInner( ): Route? = when (noteEvent) { is AppDefinitionEvent -> { - Route.ContentDiscovery(noteEvent.id) + if (noteEvent.includeKind(5300)) { + Route.ContentDiscovery(noteEvent.id) + } else { + Route.Note(noteEvent.id) + } } is IsInPublicChatChannel -> { From 1f2e24bc1f724f172f4be69d720e2a0996c6f089 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 19:36:30 -0400 Subject: [PATCH 44/47] v1.06.2 --- .claude/skills/android-expert/SKILL.md | 2 +- .claude/skills/quartz-integration/SKILL.md | 6 +++--- .../skills/quartz-integration/references/gradle-setup.md | 8 ++++---- .claude/skills/quartz-kmp.md | 2 +- amethyst/build.gradle | 4 ++-- quartz/build.gradle.kts | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index e8153954ce..809928a677 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -746,7 +746,7 @@ android { minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 versionCode = 434 - versionName = "1.06.1" + versionName = "1.06.2" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index ab9d4293fb..776b73f890 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.1` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.2` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.06.1" +quartz = "1.06.2" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.2") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index faeca63146..3a86a1518c 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.06.1 +com.vitorpamplona.quartz:quartz:1.06.2 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.06.1" +quartz = "1.06.2" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.2") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.1") + implementation("com.vitorpamplona.quartz:quartz:1.06.2") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index e373efdf7d..9aefccc7c9 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.06.1 +com.vitorpamplona.quartz:quartz:1.06.2 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/amethyst/build.gradle b/amethyst/build.gradle index e44777a88b..9b07aab152 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -54,8 +54,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() - versionCode = 433 - versionName = generateVersionName("1.06.1") + versionCode = 434 + versionName = generateVersionName("1.06.2") buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 2e79a5e2ce..22b2bc8b34 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -350,7 +350,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.06.1", + version = "1.06.2", ) // Configure publishing to Maven Central From 041c788e21c79d6d09806f378d934b0c62f97fce Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 20:42:06 -0400 Subject: [PATCH 45/47] Improvements to anonymous replies --- .../vitorpamplona/amethyst/model/Account.kt | 11 +++++- .../nip22Comments/CommentPostViewModel.kt | 12 +++++- .../nip22Comments/GenericCommentPostScreen.kt | 38 ++++++++++++++++--- .../loggedIn/home/ShortNotePostScreen.kt | 23 ++--------- 4 files changed, 56 insertions(+), 28 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 0cd0018a2f..32bb83c9bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1300,9 +1300,18 @@ class Account( val anonymousSigner = NostrSignerInternal(KeyPair()) val event = anonymousSigner.sign(template) - val relayList = nip65RelayList.outboxFlow.value.toSet() + cache.justConsumeMyOwnEvent(event) + val note = + if (event is AddressableEvent) { + cache.getOrCreateAddressableNote(event.address()) + } else { + cache.getOrCreateNote(event.id) + } + + val relayList = computeRelayListToBroadcast(note) client.send(event, relayList) + broadcast.forEach { client.send(it, relayList) } return event 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 10051c2945..b150efe393 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 @@ -194,6 +194,8 @@ open class CommentPostViewModel : var wantsZapraiser by mutableStateOf(false) override val zapRaiserAmount = mutableStateOf(null) + var wantsAnonymousPost by mutableStateOf(false) + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -342,10 +344,15 @@ open class CommentPostViewModel : } val version = draftTag.current - + val anonymous = wantsAnonymousPost cancel() - accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + if (anonymous) { + accountViewModel.account.signAnonymouslyAndBroadcast(template, extraNotesToBroadcast) + } else { + accountViewModel.account.signAndComputeBroadcast(template, extraNotesToBroadcast) + } + accountViewModel.viewModelScope.launch(Dispatchers.IO) { accountViewModel.account.deleteDraftIgnoreErrors(version) } @@ -588,6 +595,7 @@ open class CommentPostViewModel : contentWarningDescription = "" wantsToAddGeoHash = false wantsSecretEmoji = false + wantsAnonymousPost = false forwardZapTo.value = SplitBuilder() forwardZapToEditting.value = TextFieldValue("") 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 74f92eb373..3bee7dfe07 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 @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments import android.net.Uri import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -35,6 +37,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -79,9 +83,12 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType +import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size30Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage @@ -241,11 +248,32 @@ private fun GenericCommentPostBody( Row( modifier = Modifier.padding(vertical = Size10dp), ) { - BaseUserPicture( - accountViewModel.userProfile(), - Size35dp, - accountViewModel = accountViewModel, - ) + if (postViewModel.wantsAnonymousPost) { + IconButton( + modifier = Size35Modifier, + onClick = { postViewModel.wantsAnonymousPost = false }, + ) { + Icon( + painter = painterRes(resourceId = R.drawable.incognito, 1), + contentDescription = stringRes(R.string.post_anonymously), + modifier = Size30Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } else { + Box( + modifier = + Modifier.clickable { + postViewModel.wantsAnonymousPost = true + }, + ) { + BaseUserPicture( + accountViewModel.userProfile(), + Size35dp, + accountViewModel = accountViewModel, + ) + } + } MessageField( R.string.what_s_on_your_mind, postViewModel, 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 b598a3459d..3f0e0d79d1 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 @@ -25,7 +25,6 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box @@ -49,7 +48,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -110,6 +108,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size19Modifier +import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -288,23 +287,6 @@ private fun NewPostScreenBody( } } - if (postViewModel.wantsAnonymousPost) { - Row( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.errorContainer) - .padding(horizontal = Size10dp, vertical = 8.dp), - verticalAlignment = CenterVertically, - ) { - Text( - text = stringRes(R.string.anonymous_reply_warning), - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodySmall, - ) - } - } - // Only show text input if no voice message is being posted if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { Row( @@ -312,12 +294,13 @@ private fun NewPostScreenBody( ) { if (postViewModel.wantsAnonymousPost) { IconButton( + modifier = Size35Modifier, onClick = { postViewModel.wantsAnonymousPost = false }, ) { Icon( painter = painterRes(resourceId = R.drawable.incognito, 1), contentDescription = stringRes(R.string.post_anonymously), - modifier = Size35Modifier, + modifier = Size30Modifier, tint = MaterialTheme.colorScheme.onBackground, ) } From 6fc72f47bb21ada9a07b52e74e9169412b6e9fa7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 20:53:22 -0400 Subject: [PATCH 46/47] Highlights when arriving at the reply message --- .../loggedIn/chats/feed/ChatFeedView.kt | 6 +++++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 8 ++++++ .../chats/feed/layouts/ChatBubbleLayout.kt | 27 ++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt index d7538f47f8..9fc8f3aa96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatFeedView.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -134,11 +136,13 @@ fun ChatFeedLoaded( } val scope = rememberCoroutineScope() + val highlightedNoteId = remember { mutableStateOf(null) } val onScrollToNote: (Note) -> Unit = { note -> val index = items.list.indexOfFirst { it.idHex == note.idHex } if (index >= 0) { scope.launch { listState.animateScrollToItem(index) + highlightedNoteId.value = note.idHex } } } @@ -160,6 +164,8 @@ fun ChatFeedLoaded( onWantsToReply = onWantsToReply, onWantsToEditDraft = onWantsToEditDraft, onScrollToNote = onScrollToNote, + shouldHighlight = highlightedNoteId.value == item.idHex, + onHighlightFinished = { highlightedNoteId.value = null }, ) NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 7cb4527128..27a811b3ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -96,6 +96,8 @@ fun ChatroomMessageCompose( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, onScrollToNote: ((Note) -> Unit)? = null, + shouldHighlight: Boolean = false, + onHighlightFinished: (() -> Unit)? = null, ) { WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav) { WatchBlockAndReport( @@ -116,6 +118,8 @@ fun ChatroomMessageCompose( onWantsToReply, onWantsToEditDraft, onScrollToNote, + shouldHighlight, + onHighlightFinished, ) } } @@ -133,6 +137,8 @@ fun NormalChatNote( onWantsToReply: (Note) -> Unit, onWantsToEditDraft: (Note) -> Unit, onScrollToNote: ((Note) -> Unit)? = null, + shouldHighlight: Boolean = false, + onHighlightFinished: (() -> Unit)? = null, ) { val isLoggedInUser = remember(note.author) { @@ -170,6 +176,8 @@ fun NormalChatNote( hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(), drawAuthorInfo = drawAuthorInfo, parentBackgroundColor = parentBackgroundColor, + shouldHighlight = shouldHighlight, + onHighlightFinished = onHighlightFinished, onClick = { if (note.event is ChannelCreateEvent) { nav.nav(Route.PublicChatChannel(note.idHex)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index 4fa4ac9136..3734873309 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -36,7 +38,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -58,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.theme.chatBackground import com.vitorpamplona.amethyst.ui.theme.chatDraftBackground import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits +import kotlinx.coroutines.delay private const val RELAYS_AND_ACTIONS_TEXT = "Relays and Actions" @@ -71,6 +76,8 @@ fun ChatBubbleLayout( hasDetailsToShow: Boolean, drawAuthorInfo: Boolean, parentBackgroundColor: MutableState? = null, + shouldHighlight: Boolean = false, + onHighlightFinished: (() -> Unit)? = null, onClick: () -> Boolean, onAuthorClick: () -> Unit, actionMenu: @Composable (onDismiss: () -> Unit) -> Unit, @@ -100,6 +107,24 @@ fun ChatBubbleLayout( } } + val highlightActive = remember { mutableStateOf(false) } + + LaunchedEffect(shouldHighlight) { + if (shouldHighlight) { + highlightActive.value = true + delay(1500) + highlightActive.value = false + onHighlightFinished?.invoke() + } + } + + val highlightColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + val animatedColor by animateColorAsState( + targetValue = if (highlightActive.value) highlightColor.compositeOver(bgColor.value) else bgColor.value, + animationSpec = tween(durationMillis = if (highlightActive.value) 300 else 800), + label = "highlightAnimation", + ) + Row( modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, @@ -136,7 +161,7 @@ fun ChatBubbleLayout( modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier, ) { Surface( - color = bgColor.value, + color = animatedColor, shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, modifier = clickableModifier, ) { From f42881a48e2efc0558cd68a11e255b5297d015bb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 24 Mar 2026 20:55:04 -0400 Subject: [PATCH 47/47] v1.06.3 --- .claude/skills/android-expert/SKILL.md | 4 ++-- .claude/skills/quartz-integration/SKILL.md | 6 +++--- .../skills/quartz-integration/references/gradle-setup.md | 8 ++++---- .claude/skills/quartz-kmp.md | 2 +- amethyst/build.gradle | 4 ++-- quartz/build.gradle.kts | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md index 809928a677..fc9693e924 100644 --- a/.claude/skills/android-expert/SKILL.md +++ b/.claude/skills/android-expert/SKILL.md @@ -745,8 +745,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = 26 // Android 8.0 (Oreo) targetSdk = 36 // Android 15 - versionCode = 434 - versionName = "1.06.2" + versionCode = 435 + versionName = "1.06.3" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 776b73f890..e0d193b0f8 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. -**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.2` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.3` (Maven Central) **Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) **License**: MIT @@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr ```toml [versions] -quartz = "1.06.2" +quartz = "1.06.3" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.2") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index 3a86a1518c..eae28c616f 100644 --- a/.claude/skills/quartz-integration/references/gradle-setup.md +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -3,7 +3,7 @@ ## Current version ``` -com.vitorpamplona.quartz:quartz:1.06.2 +com.vitorpamplona.quartz:quartz:1.06.3 ``` Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz @@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua ```toml [versions] -quartz = "1.06.2" +quartz = "1.06.3" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -55,7 +55,7 @@ kotlin { ```kotlin // build.gradle.kts (app module) dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.2") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.2") + implementation("com.vitorpamplona.quartz:quartz:1.06.3") // JNA needed for libsodium (NIP-44) on JVM implementation("net.java.dev.jna:jna:5.18.1") } diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index 9aefccc7c9..3ebfdd9b70 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -17,7 +17,7 @@ The Quartz library was successfully converted from Android-only to full KMP supp ## Current artifact ``` -com.vitorpamplona.quartz:quartz:1.06.2 +com.vitorpamplona.quartz:quartz:1.06.3 ``` See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 9b07aab152..24b923d5d6 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -54,8 +54,8 @@ android { applicationId = "com.vitorpamplona.amethyst" minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() - versionCode = 434 - versionName = generateVersionName("1.06.2") + versionCode = 435 + versionName = generateVersionName("1.06.3") buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 22b2bc8b34..1fed30907a 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -350,7 +350,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.06.2", + version = "1.06.3", ) // Configure publishing to Maven Central