From 951610d9caeb444b3c4f944cda69bf95ef90a75f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 17:10:56 +0000 Subject: [PATCH 01/17] fix: prevent StringIndexOutOfBoundsException in Url.getPart() When UrlDetector.readEnd() trims the last character from URLs ending with characters like '.', ',', etc., the urlMarker indices remain based on the original buffer length. This caused getPart() to call substring() with an end index beyond the trimmed URL's length. Fix by clamping the end index to originalUrl.length and guarding against an out-of-bounds start index. https://claude.ai/code/session_014Q2SvTE5vKgUm1w8dPVjYo --- .../com/vitorpamplona/quartz/utils/urldetector/Url.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt index 538d9ea32..46927f771 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/urldetector/Url.kt @@ -228,11 +228,17 @@ class Url( return null } + val startIndex = urlMarker.indexOf(part) + if (startIndex < 0 || startIndex >= originalUrl.length) { + return null + } + val nextPart = nextExistingPart(part) return if (nextPart == null) { - originalUrl.substring(urlMarker.indexOf(part)) + originalUrl.substring(startIndex) } else { - originalUrl.substring(urlMarker.indexOf(part), urlMarker.indexOf(nextPart)) + val endIndex = urlMarker.indexOf(nextPart) + originalUrl.substring(startIndex, minOf(endIndex, originalUrl.length)) } } From f553489ddf084da90ba3bb9fb25f6b44fb5218f9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 23 Mar 2026 15:51:35 -0400 Subject: [PATCH 02/17] Improves the wording of the Last Seen --- .../amethyst/ui/note/TimeAgoFormatter.kt | 19 ++++++++++++------- .../profile/header/DrawAdditionalInfo.kt | 2 +- amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index d6452fbbc..5f7ce1a3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -45,9 +45,14 @@ var monthNoDayFormatter = SimpleDateFormat(MONTH_NO_DAY_DATE_FORMAT, locale) fun timeAgo( time: Long?, context: Context, + prefix: String = " • ", + seconds: Int = R.string.now, + minutes: Int = R.string.m, + hours: Int = R.string.h, + days: Int = R.string.d, ): String { if (time == null) return " " - if (time == 0L) return " • ${stringRes(context, R.string.never)}" + if (time == 0L) return prefix + stringRes(context, R.string.never) val timeDifference = TimeUtils.now() - time @@ -60,7 +65,7 @@ fun timeAgo( monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) } - " • " + yearFormatter.format(time * 1000) + prefix + yearFormatter.format(time * 1000) } else if (timeDifference > TimeUtils.ONE_MONTH) { // Dec 12 if (locale != Locale.getDefault()) { @@ -69,16 +74,16 @@ fun timeAgo( monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) } - " • " + monthFormatter.format(time * 1000) + prefix + monthFormatter.format(time * 1000) } else if (timeDifference > TimeUtils.ONE_DAY) { // 2 days - " • " + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d) + prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, days) } else if (timeDifference > TimeUtils.ONE_HOUR) { - " • " + (timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h) + prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, hours) } else if (timeDifference > TimeUtils.ONE_MINUTE) { - " • " + (timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m) + prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, minutes) } else { - " • " + stringRes(context, R.string.now) + prefix + stringRes(context, seconds) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index d88b0fb95..3acc587d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -309,7 +309,7 @@ fun DisplayLastSeen( lastSeen?.let { timestamp -> val context = LocalContext.current Text( - text = stringRes(R.string.last_seen, timeAgo(timestamp, context)), + text = stringRes(R.string.last_seen, timeAgo(timestamp, context, prefix = "", seconds = R.string.seconds)), color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bf05100bf..b31cf4974 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -309,6 +309,7 @@ LNURL… never now + seconds h m d From e31e3829a64b08eede744affa39104f396f2acf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 20:31:28 +0000 Subject: [PATCH 03/17] test: add regression tests for PR #1907 URL crash fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover the StringIndexOutOfBoundsException in Url.getPart() that occurred when readEnd() trimmed trailing characters (e.g. ':') from a detected URL but urlMarker indices remained pointing past the trimmed string's end. - UrlMarkerTest: three cases testing PORT/QUERY index at or beyond string length (the boundary conditions fixed by minOf clamping and the startIndex >= length guard) - UrlsDetectorTest: regression for "今北産業" (no crash, 0 URLs) and for a bare "example.com:" host - UrlParserTest: end-to-end regression for "今北産業" and "example.com:" https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2 --- .../commons/richtext/UrlParserTest.kt | 24 +++++++ .../nip10Notes/urls/UrlsDetectorTest.kt | 21 ++++++ .../quartz/utils/urldetector/UrlMarkerTest.kt | 64 +++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index b9613c5cd..8777f5329 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -281,6 +281,30 @@ class UrlParserTest { Urls(withScheme = emptySet()), ) + /** + * Regression test for PR #1907: parsing a note whose content is only the Japanese phrase + * "今北産業" (a common internet abbreviation) must not throw a StringIndexOutOfBoundsException + * from Url.getPart() and must produce no detected URLs. + */ + @Test + fun testImakitaSangyo() = + test( + "今北産業", + Urls(), + ) + + /** + * Regression test for PR #1907: a URL that ends with ':' causes readEnd() to trim it, + * but the PORT marker index was already set to buffer.length (one past the trimmed URL). + * Accessing host/port must not throw StringIndexOutOfBoundsException. + */ + @Test + fun testUrlEndingWithColon() = + test( + "example.com:", + Urls(), + ) + @Test fun testHour() = test( 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 0d526ab17..7e7f66d24 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 @@ -41,4 +41,25 @@ class UrlsDetectorTest { assertContains(detectedLinks, "https://mysite.xyz") assertContains(detectedLinks, "https://myblog.xyz") } + + /** + * Regression test for PR #1907: the Japanese phrase "今北産業" must not crash the URL + * detector with a StringIndexOutOfBoundsException and must return no URLs. + */ + @Test + fun doesNotCrashOnJapaneseText() { + val detectedLinks = fastFindURLs("今北産業") + assertEquals(0, detectedLinks.size) + } + + /** + * Regression test for PR #1907: a bare host ending with ':' triggers PORT marker placement + * at buffer.length, then readEnd() trims the ':', leaving PORT beyond the URL string. + * Url.getPart() must not throw StringIndexOutOfBoundsException. + */ + @Test + fun doesNotCrashOnUrlEndingWithColon() { + // Just verifying no exception is thrown; a bare "example.com:" is not a valid URL. + fastFindURLs("example.com:") + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt index 660759b16..2c9bd35cb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt @@ -318,4 +318,68 @@ class UrlMarkerTest { "#?@Sdsf", intArrayOf(0, 8, 18, 33, 35, 36, 53), ) + + /** + * Regression test for PR #1907: StringIndexOutOfBoundsException in Url.getPart() when a URL + * ending with a character in CANNOT_END_URLS_WITH (e.g. ':') is trimmed by readEnd(), but the + * urlMarker PORT index was set to buffer.length (one past the last valid index). + * + * After trimming, PORT index == originalUrl.length, so getPart(PORT) must return null + * and getPart(HOST) must clamp endIndex to originalUrl.length via minOf. + * + * Simulates what happens when "example.com:" is detected: the ':' is trimmed to produce + * "example.com" but PORT marker remains at index 11 (= trimmed string length). + */ + @Test + fun testPortIndexAtStringLength() = + testUrlMarker( + "example.com", // 11 chars – simulates "example.com:" after trailing ':' is trimmed + "https", + "", + "", + "example.com", // host must not throw; endIndex clamped by minOf + -1, // PORT at index 11 == length → getPart returns null → falls back to scheme default → 443, but no scheme → -1 + "/", + "", + "", + intArrayOf(-1, -1, 0, 11, -1, -1, -1), // HOST=0, PORT=11 (= string length) + ) + + /** + * Regression test for PR #1907: PORT index beyond the end of the trimmed URL. + * Simulates a marker that points one position past the string length. + */ + @Test + fun testPortIndexBeyondStringLength() = + testUrlMarker( + "example.com", // 11 chars + "https", + "", + "", + "example.com", // host must not throw + -1, // PORT at 12 > length → getPart(PORT) returns null → -1 + "/", + "", + "", + intArrayOf(-1, -1, 0, 12, -1, -1, -1), // PORT=12 > string length 11 + ) + + /** + * Regression test for PR #1907: QUERY index at string length (simulates "example.com/path?" + * after trailing '?' is trimmed, leaving urlMarker QUERY at the trimmed string's length). + */ + @Test + fun testQueryIndexAtStringLength() = + testUrlMarker( + "example.com/path", // 16 chars – simulates "example.com/path?" after '?' trimmed + "https", + "", + "", + "example.com", + 443, + "/path", // path must not throw; endIndex clamped by minOf + "", // QUERY at 16 == length → getPart(QUERY) returns null + "", + intArrayOf(-1, -1, 0, -1, 11, 16, -1), // HOST=0, PATH=11, QUERY=16 (= string length) + ) } From 9202b60dcf2e93e3ecfddc149689c390b6835646 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 20:46:59 +0000 Subject: [PATCH 04/17] =?UTF-8?q?test:=20add=20regression=20tests=20for=20?= =?UTF-8?q?=E4=BB=8A=E5=8C=97=E7=94=A3=E6=A5=AD=20URL=20crash=20(PR=20#190?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies that the Japanese phrase "今北産業" does not cause a StringIndexOutOfBoundsException in Url.getPart() and is not detected as a URL. https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2 --- .../commons/richtext/UrlParserTest.kt | 12 ---- .../nip10Notes/urls/UrlsDetectorTest.kt | 11 ---- .../quartz/utils/urldetector/UrlMarkerTest.kt | 63 ------------------- 3 files changed, 86 deletions(-) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index 8777f5329..600c0f27f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -293,18 +293,6 @@ class UrlParserTest { Urls(), ) - /** - * Regression test for PR #1907: a URL that ends with ':' causes readEnd() to trim it, - * but the PORT marker index was already set to buffer.length (one past the trimmed URL). - * Accessing host/port must not throw StringIndexOutOfBoundsException. - */ - @Test - fun testUrlEndingWithColon() = - test( - "example.com:", - Urls(), - ) - @Test fun testHour() = test( 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 7e7f66d24..aae685bf2 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 @@ -51,15 +51,4 @@ class UrlsDetectorTest { val detectedLinks = fastFindURLs("今北産業") assertEquals(0, detectedLinks.size) } - - /** - * Regression test for PR #1907: a bare host ending with ':' triggers PORT marker placement - * at buffer.length, then readEnd() trims the ':', leaving PORT beyond the URL string. - * Url.getPart() must not throw StringIndexOutOfBoundsException. - */ - @Test - fun doesNotCrashOnUrlEndingWithColon() { - // Just verifying no exception is thrown; a bare "example.com:" is not a valid URL. - fastFindURLs("example.com:") - } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt index 2c9bd35cb..b13e3b658 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt @@ -319,67 +319,4 @@ class UrlMarkerTest { intArrayOf(0, 8, 18, 33, 35, 36, 53), ) - /** - * Regression test for PR #1907: StringIndexOutOfBoundsException in Url.getPart() when a URL - * ending with a character in CANNOT_END_URLS_WITH (e.g. ':') is trimmed by readEnd(), but the - * urlMarker PORT index was set to buffer.length (one past the last valid index). - * - * After trimming, PORT index == originalUrl.length, so getPart(PORT) must return null - * and getPart(HOST) must clamp endIndex to originalUrl.length via minOf. - * - * Simulates what happens when "example.com:" is detected: the ':' is trimmed to produce - * "example.com" but PORT marker remains at index 11 (= trimmed string length). - */ - @Test - fun testPortIndexAtStringLength() = - testUrlMarker( - "example.com", // 11 chars – simulates "example.com:" after trailing ':' is trimmed - "https", - "", - "", - "example.com", // host must not throw; endIndex clamped by minOf - -1, // PORT at index 11 == length → getPart returns null → falls back to scheme default → 443, but no scheme → -1 - "/", - "", - "", - intArrayOf(-1, -1, 0, 11, -1, -1, -1), // HOST=0, PORT=11 (= string length) - ) - - /** - * Regression test for PR #1907: PORT index beyond the end of the trimmed URL. - * Simulates a marker that points one position past the string length. - */ - @Test - fun testPortIndexBeyondStringLength() = - testUrlMarker( - "example.com", // 11 chars - "https", - "", - "", - "example.com", // host must not throw - -1, // PORT at 12 > length → getPart(PORT) returns null → -1 - "/", - "", - "", - intArrayOf(-1, -1, 0, 12, -1, -1, -1), // PORT=12 > string length 11 - ) - - /** - * Regression test for PR #1907: QUERY index at string length (simulates "example.com/path?" - * after trailing '?' is trimmed, leaving urlMarker QUERY at the trimmed string's length). - */ - @Test - fun testQueryIndexAtStringLength() = - testUrlMarker( - "example.com/path", // 16 chars – simulates "example.com/path?" after '?' trimmed - "https", - "", - "", - "example.com", - 443, - "/path", // path must not throw; endIndex clamped by minOf - "", // QUERY at 16 == length → getPart(QUERY) returns null - "", - intArrayOf(-1, -1, 0, -1, 11, 16, -1), // HOST=0, PATH=11, QUERY=16 (= string length) - ) } From 9ebc4a1e67ae9f57ff8976ab54139a424937d04c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 20:56:58 +0000 Subject: [PATCH 05/17] style: remove trailing blank line in UrlMarkerTest https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2 --- .../com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt index b13e3b658..660759b16 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlMarkerTest.kt @@ -318,5 +318,4 @@ class UrlMarkerTest { "#?@Sdsf", intArrayOf(0, 8, 18, 33, 35, 36, 53), ) - } From 3a93c4b0750f438fb37126daefab791708f72d6b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 20:57:49 +0000 Subject: [PATCH 06/17] test: add UrlTest for getPart() boundary conditions fixed in PR #1907 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests the StringIndexOutOfBoundsException fix in Url.getPart() by directly constructing Url objects with UrlMarker indices that exceed the trimmed originalUrl length — the exact scenario that occurs when readEnd() strips a trailing character (e.g. ':') while the PORT/QUERY marker still points to the original buffer position. https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2 --- .../quartz/utils/urldetector/UrlTest.kt | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt 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 new file mode 100644 index 000000000..10bd86316 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt @@ -0,0 +1,128 @@ +/* + * 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.urldetector + +import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * Tests for [Url.getPart] boundary conditions fixed in PR #1907. + * + * When [com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector] trims the last + * character of a detected URL (because it is in CANNOT_END_URLS_WITH, e.g. ':'), the + * [UrlMarker] indices remain based on the original buffer length. This can leave a part's + * start index or the next part's end index pointing past the end of the trimmed [Url.originalUrl], + * causing a [StringIndexOutOfBoundsException] in [Url.getPart]. + * + * The fix guards against `startIndex >= originalUrl.length` (returns null) and clamps + * `endIndex` via `minOf(endIndex, originalUrl.length)`. + */ +class UrlTest { + /** + * Simulates a URL whose trailing ':' was trimmed by readEnd(), leaving the PORT marker + * at index == originalUrl.length. Regression for the "今北産業" crash (PR #1907). + * + * Without the fix, getPart(HOST) called substring(0, 11) on an 11-char string which + * is fine, but getPart(PORT) called substring(11) – or in the old code substring(11, ) + * – with startIndex == length, causing StringIndexOutOfBoundsException. + */ + @Test + fun getPartDoesNotThrowWhenPortIndexEqualsStringLength() { + // "example.com" is 11 chars; PORT marker at 11 == length simulates "example.com:" + // after the trailing ':' was stripped by readEnd(). + val marker = UrlMarker() + marker.setIndex(UrlPart.HOST, 0) + marker.setIndex(UrlPart.PORT, 11) // == originalUrl.length + val url = marker.createUrl("example.com") + + assertEquals("example.com", url.host) + assertEquals(-1, url.port) // getPart(PORT) returns null → falls back to -1 + } + + /** + * Simulates a URL whose trailing ':' was trimmed, leaving PORT marker one position + * beyond originalUrl.length. getPart must clamp via minOf and not throw. + */ + @Test + fun getPartDoesNotThrowWhenPortIndexExceedsStringLength() { + val marker = UrlMarker() + marker.setIndex(UrlPart.HOST, 0) + marker.setIndex(UrlPart.PORT, 12) // > originalUrl.length (11) + val url = marker.createUrl("example.com") + + assertEquals("example.com", url.host) + assertEquals(-1, url.port) + } + + /** + * Simulates a URL whose trailing '?' was trimmed, leaving the QUERY marker at + * index == originalUrl.length. getPart(PATH) must clamp its endIndex and not throw. + */ + @Test + fun getPartDoesNotThrowWhenQueryIndexEqualsStringLength() { + // "example.com/a" is 14 chars; QUERY at 14 simulates "example.com/a?" after trim. + val marker = UrlMarker() + marker.setIndex(UrlPart.HOST, 0) + marker.setIndex(UrlPart.PATH, 11) + marker.setIndex(UrlPart.QUERY, 14) // == originalUrl.length + val url = marker.createUrl("example.com/a") + + assertEquals("example.com", url.host) + assertEquals("/a", url.path) + assertEquals("", url.query) // getPart(QUERY) startIndex >= length → null → "" + } + + /** + * Regression test for PR #1907: the Japanese phrase "今北産業" (the crash trigger) + * must not cause a StringIndexOutOfBoundsException when detected URLs have their + * parts accessed, and must produce no URLs. + */ + @Test + fun detectingJapaneseTextDoesNotThrow() { + val urls = UrlDetector("今北産業").detect() + assertEquals(0, urls.size) + } + + /** + * Ensures that even if a Url with out-of-range markers somehow reaches property + * access, all properties return gracefully without throwing. + */ + @Test + fun allPropertiesAreSafeWithOutOfRangeMarkers() { + val marker = UrlMarker() + marker.setIndex(UrlPart.SCHEME, 0) + marker.setIndex(UrlPart.HOST, 8) + marker.setIndex(UrlPart.PORT, 20) // beyond the 19-char string length + val url = marker.createUrl("https://example.com") // 19 chars + + assertNotNull(url.scheme) + assertNotNull(url.host) + assertNotNull(url.username) + assertNotNull(url.password) + assertNotNull(url.path) + assertNotNull(url.query) + assertNotNull(url.fragment) + // port returns -1 when getPart returns null + assertEquals(-1, url.port) + } +} From 7ea55578280191fda50692df7837fc1fb6993742 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 21:02:41 +0000 Subject: [PATCH 07/17] =?UTF-8?q?test:=20fix=20UrlTest=20to=20use=20?= =?UTF-8?q?=E4=BB=8A=E5=8C=97=E7=94=A3=E6=A5=AD=20as=20the=20crash=20input?= =?UTF-8?q?=20(PR=20#1907)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2 --- .../quartz/utils/urldetector/UrlTest.kt | 94 +++---------------- 1 file changed, 15 insertions(+), 79 deletions(-) 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 10bd86316..a29c5e99e 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 @@ -26,103 +26,39 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull /** - * Tests for [Url.getPart] boundary conditions fixed in PR #1907. - * - * When [com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector] trims the last - * character of a detected URL (because it is in CANNOT_END_URLS_WITH, e.g. ':'), the - * [UrlMarker] indices remain based on the original buffer length. This can leave a part's - * start index or the next part's end index pointing past the end of the trimmed [Url.originalUrl], - * causing a [StringIndexOutOfBoundsException] in [Url.getPart]. - * - * The fix guards against `startIndex >= originalUrl.length` (returns null) and clamps - * `endIndex` via `minOf(endIndex, originalUrl.length)`. + * Regression tests for PR #1907: StringIndexOutOfBoundsException in [Url.getPart] when + * processing the Japanese text "今北産業". */ class UrlTest { /** - * Simulates a URL whose trailing ':' was trimmed by readEnd(), leaving the PORT marker - * at index == originalUrl.length. Regression for the "今北産業" crash (PR #1907). - * - * Without the fix, getPart(HOST) called substring(0, 11) on an 11-char string which - * is fine, but getPart(PORT) called substring(11) – or in the old code substring(11, ) - * – with startIndex == length, causing StringIndexOutOfBoundsException. + * Regression: detecting URLs in "今北産業" must not throw and must return no URLs. */ @Test - fun getPartDoesNotThrowWhenPortIndexEqualsStringLength() { - // "example.com" is 11 chars; PORT marker at 11 == length simulates "example.com:" - // after the trailing ':' was stripped by readEnd(). - val marker = UrlMarker() - marker.setIndex(UrlPart.HOST, 0) - marker.setIndex(UrlPart.PORT, 11) // == originalUrl.length - val url = marker.createUrl("example.com") - - assertEquals("example.com", url.host) - assertEquals(-1, url.port) // getPart(PORT) returns null → falls back to -1 - } - - /** - * Simulates a URL whose trailing ':' was trimmed, leaving PORT marker one position - * beyond originalUrl.length. getPart must clamp via minOf and not throw. - */ - @Test - fun getPartDoesNotThrowWhenPortIndexExceedsStringLength() { - val marker = UrlMarker() - marker.setIndex(UrlPart.HOST, 0) - marker.setIndex(UrlPart.PORT, 12) // > originalUrl.length (11) - val url = marker.createUrl("example.com") - - assertEquals("example.com", url.host) - assertEquals(-1, url.port) - } - - /** - * Simulates a URL whose trailing '?' was trimmed, leaving the QUERY marker at - * index == originalUrl.length. getPart(PATH) must clamp its endIndex and not throw. - */ - @Test - fun getPartDoesNotThrowWhenQueryIndexEqualsStringLength() { - // "example.com/a" is 14 chars; QUERY at 14 simulates "example.com/a?" after trim. - val marker = UrlMarker() - marker.setIndex(UrlPart.HOST, 0) - marker.setIndex(UrlPart.PATH, 11) - marker.setIndex(UrlPart.QUERY, 14) // == originalUrl.length - val url = marker.createUrl("example.com/a") - - assertEquals("example.com", url.host) - assertEquals("/a", url.path) - assertEquals("", url.query) // getPart(QUERY) startIndex >= length → null → "" - } - - /** - * Regression test for PR #1907: the Japanese phrase "今北産業" (the crash trigger) - * must not cause a StringIndexOutOfBoundsException when detected URLs have their - * parts accessed, and must produce no URLs. - */ - @Test - fun detectingJapaneseTextDoesNotThrow() { + fun detectingImakitaSangyoDoesNotThrow() { val urls = UrlDetector("今北産業").detect() assertEquals(0, urls.size) } /** - * Ensures that even if a Url with out-of-range markers somehow reaches property - * access, all properties return gracefully without throwing. + * Regression: constructing a Url with "今北産業" as the original string and a HOST + * marker at 0 with PORT at the string length (simulating a trimmed trailing character) + * must not throw StringIndexOutOfBoundsException when accessing any property. + * + * "今北産業" has length 4. PORT at 4 == length triggers the startIndex >= length guard + * added to getPart() in PR #1907. */ @Test - fun allPropertiesAreSafeWithOutOfRangeMarkers() { + fun urlPropertiesDoNotThrowForImakitaSangyoWithOutOfRangeMarker() { val marker = UrlMarker() - marker.setIndex(UrlPart.SCHEME, 0) - marker.setIndex(UrlPart.HOST, 8) - marker.setIndex(UrlPart.PORT, 20) // beyond the 19-char string length - val url = marker.createUrl("https://example.com") // 19 chars + marker.setIndex(UrlPart.HOST, 0) + marker.setIndex(UrlPart.PORT, 4) // == "今北産業".length + val url = marker.createUrl("今北産業") assertNotNull(url.scheme) assertNotNull(url.host) - assertNotNull(url.username) - assertNotNull(url.password) assertNotNull(url.path) assertNotNull(url.query) assertNotNull(url.fragment) - // port returns -1 when getPart returns null - assertEquals(-1, url.port) + assertEquals(-1, url.port) // getPart(PORT) returns null → -1 } } From 3d488165c3cadb6a5f1475551d2daa3bafb2e120 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 23 Mar 2026 21:40:20 +0000 Subject: [PATCH 08/17] New Crowdin translations by GitHub Action --- .../src/main/res/values-pl-rPL/strings.xml | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 5f2c6efd6..d956737d3 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -71,7 +71,7 @@ Zacytuj Sklonuj Zaproponuj zmianę - Nowa kwota w Satsach + Nowa kwota w satoszach Dodaj "odpowiadając do " " i " @@ -92,7 +92,7 @@ Lightning transfer Wiadomość dla odbiorcy Dziękuję bardzo! - Kwota w Satsach + Kwota w satoszach Wyślij Kreator tajnych emoji Dodaj emoji z ukrytą wiadomością do wpisu @@ -1101,6 +1101,13 @@ Nowy wpis w społeczności Nowy produkt Nowy GEO-ekskluzywny Wpis + Nowy artykuł + Tytuł + Podsumowanie (opcjonalnie) + Adres URL miniaturki (opcjonalnie) + Napisz artykuł w formacie markdown… + Podgląd + Edytuj Otwórz wszystkie odzewy na ten post Zamknij wszystkie odzewy na ten post Odpowiedź @@ -1238,6 +1245,7 @@ OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. + Redaguj artykuł Edytuj wpis Propozycja ulepszenia wpisu Podsumowanie zmian @@ -1560,6 +1568,7 @@ wyszukaj, npub1…, alicja@domena.pl Obsługuje npub, nprofile, NIP-05, hex, i namecoin (.bit, d/, id/) Sprawdź listę obserwowanych + Porada Znaleziono %1$d kont(a) Wybrano: %1$d Rozwiązane przez Namecoin @@ -1659,4 +1668,11 @@ Biegłość w sprawdzaniu typów: %1$s Certyfikat dla Żądanie certyfikatu do + Przedział czasu + Od + Do + Teraz + Cały czas + Ostatnia synchronizacja %1$s + Od ostatniej synchronizacji From e5aeb49a4b3945c8a4014f8901bd29567e72f94d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 23 Mar 2026 17:59:14 -0400 Subject: [PATCH 09/17] Solves some of the crashes of concurrent modification exception --- .../relay/client/accessories/RelayOfflineTracker.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt index a1f929def..abda66eb1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/RelayOfflineTracker.kt @@ -36,7 +36,7 @@ class RelayOfflineTracker( const val TAG = "RelayOfflineTracker" } - val cannotConnectRelays = mutableSetOf() + var cannotConnectRelays = setOf() private val clientListener = object : IRelayClientListener { @@ -45,14 +45,14 @@ class RelayOfflineTracker( pingMillis: Int, compressed: Boolean, ) { - cannotConnectRelays.remove(relay.url) + cannotConnectRelays -= relay.url } override fun onCannotConnect( relay: IRelayClient, errorMessage: String, ) { - cannotConnectRelays.add(relay.url) + cannotConnectRelays += relay.url } } From 4c9f2de6f7060b8abe30878e1a930ba0cdcbff6b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 23 Mar 2026 18:01:41 -0400 Subject: [PATCH 10/17] - Improves rendering of the completed polls - Doesn't render quoted polls --- .../amethyst/ui/note/types/Poll.kt | 116 +++++++++++++++--- 1 file changed, 96 insertions(+), 20 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 949b92473..dc302597b 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 @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -56,7 +57,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -82,11 +89,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BigPadding +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.SmallishBorder -import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText @@ -174,11 +180,11 @@ fun InnerRenderPoll( }, ) }, - ) { code, label -> + ) { code, label, closed -> TranslatableRichTextViewer( content = label, canPreview = canPreview, - quotesLeft = 1, + quotesLeft = if (quotesLeft > 0) 1 else 0, modifier = Modifier.fillMaxWidth(), tags = tags, backgroundColor = backgroundColor, @@ -221,7 +227,7 @@ fun RenderPollCard( pollState: PollResponsesCache, accountViewModel: AccountViewModel, galleryUser: @Composable RowScope.(user: User) -> Unit, - labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, + labelContent: @Composable ColumnScope.(code: String, label: String, closed: Boolean) -> Unit, ) { val card = remember(event) { @@ -274,7 +280,7 @@ fun RenderPollCard( card: PollCard, onRespond: (Set) -> Unit, resultContent: @Composable RowScope.(user: User) -> Unit, - labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, + labelContent: @Composable ColumnScope.(code: String, label: String, closed: Boolean) -> Unit, ) { Column( verticalArrangement = SpacedBy5dp, @@ -306,7 +312,7 @@ fun RenderPollCard( @Composable private fun ColumnScope.RenderSingleChoiceOptions( card: PollCard, - labelContent: @Composable (ColumnScope.(String, String) -> Unit), + labelContent: @Composable (ColumnScope.(String, String, closed: Boolean) -> Unit), onRespond: (Set) -> Unit, ) { card.options.forEach { @@ -333,7 +339,7 @@ private fun ColumnScope.RenderSingleChoiceOptions( Modifier .fillMaxWidth(), ) { - labelContent(it.code, it.label) + labelContent(it.code, it.label, false) } } } @@ -343,7 +349,7 @@ private fun ColumnScope.RenderSingleChoiceOptions( @Composable private fun ColumnScope.RenderMultiChoiceOptions( card: PollCard, - labelContent: @Composable (ColumnScope.(String, String) -> Unit), + labelContent: @Composable (ColumnScope.(String, String, closed: Boolean) -> Unit), onRespond: (Set) -> Unit, ) { var multichoice by @@ -380,7 +386,7 @@ private fun ColumnScope.RenderMultiChoiceOptions( Column( modifier = Modifier.weight(1f), ) { - labelContent(option.code, option.label) + labelContent(option.code, option.label, false) } } } @@ -401,11 +407,11 @@ private fun ColumnScope.RenderMultiChoiceOptions( private fun RenderResults( card: PollCard, resultContent: @Composable RowScope.(user: User) -> Unit, - labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), + labelContent: @Composable (ColumnScope.(code: String, label: String, closed: Boolean) -> Unit), ) { card.options.forEach { pollItem -> RenderClosedItem(pollItem, resultContent) { - labelContent(pollItem.code, pollItem.label) + labelContent(pollItem.code, pollItem.label, true) } } } @@ -418,12 +424,13 @@ private fun RenderClosedItem( ) { val tally by item.results.collectAsStateWithLifecycle(item.currentResults()) - RenderClosedItem(tally, resultContent, labelContent) + RenderClosedItem(tally, item.label, resultContent, labelContent) } @Composable private fun RenderClosedItem( tally: TallyResults, + label: String, resultContent: @Composable RowScope.(user: User) -> Unit, labelContent: @Composable ColumnScope.() -> Unit, ) { @@ -481,18 +488,21 @@ private fun RenderClosedItem( content = labelContent, ) - Spacer(StdHorzSpacer) + Spacer(DoubleHorzSpacer) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = SpacedBy10dp, ) { - UserGallery(tally, resultContent) + if (label.length < 30) { + UserGallery(tally, resultContent) + } Text( text = "${(tally.percent * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + modifier = measure100PercentWidthModifier(MaterialTheme.typography.bodyMedium), maxLines = 1, ) } @@ -500,6 +510,24 @@ private fun RenderClosedItem( } } +@Composable +fun measure100PercentWidthModifier(textStyle: TextStyle): Modifier { + val fontFamilyResolver = LocalFontFamilyResolver.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + + return remember(fontFamilyResolver, density, layoutDirection, textStyle) { + val widthPx = + TextMeasurer(fontFamilyResolver, density, layoutDirection, 1) + .measure("100%", style = textStyle.copy(fontWeight = FontWeight.Bold)) + .size + .width + with(density) { + Modifier.width(widthPx.toDp()) + } + } +} + @Composable fun UserGallery( tally: TallyResults, @@ -510,13 +538,13 @@ fun UserGallery( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy((-10).dp), ) { - tally.users.take(6).forEach { + tally.users.take(4).forEach { key(it.pubkeyHex) { galleryUser(it) } } - if (tally.users.size > 6) { + if (tally.users.size > 4) { Box( contentAlignment = Alignment.Center, modifier = @@ -526,7 +554,7 @@ fun UserGallery( .background(MaterialTheme.colorScheme.secondaryContainer), ) { Text( - text = "+" + showCount(tally.users.size - 6), + text = "+" + showCount(tally.users.size - 4), fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface, ) @@ -575,7 +603,55 @@ fun RenderPollManualPreview() { ThemeComparisonColumn { Column(Modifier.padding(10.dp)) { - RenderPollCard(poll, {}, {}) { _, label -> + RenderPollCard(poll, {}, {}) { _, label, closed -> + Text( + text = label, + ) + } + } + } +} + +@Preview +@Composable +fun RenderPollManualLongPreview() { + val poll = + PollCard( + options = + listOf( + PollItemCard( + code = "1", + label = "Yes".repeat(300), + results = flow {}, + currentResults = { + TallyResults( + percent = 1.0f, + isWinning = true, + ) + }, + ), + PollItemCard( + code = "2", + label = "No".repeat(300), + results = flow {}, + currentResults = { + TallyResults( + percent = 0.0f, + isWinning = false, + ) + }, + ), + ), + type = PollType.SINGLE_CHOICE, + endsAt = null, + isMyPoll = true, + haveIVotedFlow = flow {}, + haveIVoted = { true }, + ) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPollCard(poll, {}, {}) { _, label, closed -> Text( text = label, ) From ec4f949782fcac10c53ddfa975d18d1f8da40ee7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 23 Mar 2026 18:15:36 -0400 Subject: [PATCH 11/17] Leaves some space to click when their is no space or new lines on the poll. --- .../amethyst/ui/note/types/Poll.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 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 dc302597b..174a5f58d 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 @@ -180,7 +180,7 @@ fun InnerRenderPoll( }, ) }, - ) { code, label, closed -> + ) { code, label -> TranslatableRichTextViewer( content = label, canPreview = canPreview, @@ -227,7 +227,7 @@ fun RenderPollCard( pollState: PollResponsesCache, accountViewModel: AccountViewModel, galleryUser: @Composable RowScope.(user: User) -> Unit, - labelContent: @Composable ColumnScope.(code: String, label: String, closed: Boolean) -> Unit, + labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, ) { val card = remember(event) { @@ -280,7 +280,7 @@ fun RenderPollCard( card: PollCard, onRespond: (Set) -> Unit, resultContent: @Composable RowScope.(user: User) -> Unit, - labelContent: @Composable ColumnScope.(code: String, label: String, closed: Boolean) -> Unit, + labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, ) { Column( verticalArrangement = SpacedBy5dp, @@ -312,7 +312,7 @@ fun RenderPollCard( @Composable private fun ColumnScope.RenderSingleChoiceOptions( card: PollCard, - labelContent: @Composable (ColumnScope.(String, String, closed: Boolean) -> Unit), + labelContent: @Composable (ColumnScope.(String, String) -> Unit), onRespond: (Set) -> Unit, ) { card.options.forEach { @@ -334,12 +334,15 @@ private fun ColumnScope.RenderSingleChoiceOptions( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + val hasSpaceToClick = + remember { + it.label.contains(' ') || it.label.contains('\n') + } + Column( - modifier = - Modifier - .fillMaxWidth(), + modifier = if (hasSpaceToClick) Modifier.fillMaxWidth() else Modifier.fillMaxWidth(0.9f), ) { - labelContent(it.code, it.label, false) + labelContent(it.code, it.label) } } } @@ -349,7 +352,7 @@ private fun ColumnScope.RenderSingleChoiceOptions( @Composable private fun ColumnScope.RenderMultiChoiceOptions( card: PollCard, - labelContent: @Composable (ColumnScope.(String, String, closed: Boolean) -> Unit), + labelContent: @Composable (ColumnScope.(String, String) -> Unit), onRespond: (Set) -> Unit, ) { var multichoice by @@ -386,7 +389,7 @@ private fun ColumnScope.RenderMultiChoiceOptions( Column( modifier = Modifier.weight(1f), ) { - labelContent(option.code, option.label, false) + labelContent(option.code, option.label) } } } @@ -407,11 +410,11 @@ private fun ColumnScope.RenderMultiChoiceOptions( private fun RenderResults( card: PollCard, resultContent: @Composable RowScope.(user: User) -> Unit, - labelContent: @Composable (ColumnScope.(code: String, label: String, closed: Boolean) -> Unit), + labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), ) { card.options.forEach { pollItem -> RenderClosedItem(pollItem, resultContent) { - labelContent(pollItem.code, pollItem.label, true) + labelContent(pollItem.code, pollItem.label) } } } @@ -516,7 +519,7 @@ fun measure100PercentWidthModifier(textStyle: TextStyle): Modifier { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current - return remember(fontFamilyResolver, density, layoutDirection, textStyle) { + return remember(fontFamilyResolver, density, textStyle) { val widthPx = TextMeasurer(fontFamilyResolver, density, layoutDirection, 1) .measure("100%", style = textStyle.copy(fontWeight = FontWeight.Bold)) @@ -603,7 +606,7 @@ fun RenderPollManualPreview() { ThemeComparisonColumn { Column(Modifier.padding(10.dp)) { - RenderPollCard(poll, {}, {}) { _, label, closed -> + RenderPollCard(poll, {}, {}) { _, label -> Text( text = label, ) @@ -651,7 +654,7 @@ fun RenderPollManualLongPreview() { ThemeComparisonColumn { Column(Modifier.padding(10.dp)) { - RenderPollCard(poll, {}, {}) { _, label, closed -> + RenderPollCard(poll, {}, {}) { _, label -> Text( text = label, ) From 7874893938868512cbca0adc9e38634d03025911 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 23 Mar 2026 18:19:42 -0400 Subject: [PATCH 12/17] v1.06.1 --- .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 | 2 +- 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 29620d1a7..e8153954c 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 = 433 - versionName = "1.06.0" + versionCode = 434 + versionName = "1.06.1" vectorDrawables { useSupportLibrary = true diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 251ee8110..ab9d4293f 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.0` (Maven Central) +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.06.1` (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.0" +quartz = "1.06.1" [libraries] quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } @@ -41,7 +41,7 @@ kotlin { ```kotlin dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.0") + implementation("com.vitorpamplona.quartz:quartz:1.06.1") } ``` diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md index f887dcc59..faeca6314 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.0 +com.vitorpamplona.quartz:quartz:1.06.1 ``` 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.0" +quartz = "1.06.1" [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.0") + implementation("com.vitorpamplona.quartz:quartz:1.06.1") } ``` @@ -70,7 +70,7 @@ plugins { } dependencies { - implementation("com.vitorpamplona.quartz:quartz:1.06.0") + implementation("com.vitorpamplona.quartz:quartz:1.06.1") // 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 4c280a5ef..e373efdf7 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.0 +com.vitorpamplona.quartz:quartz:1.06.1 ``` 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 46ae6c04d..e44777a88 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -55,7 +55,7 @@ android { minSdk = libs.versions.android.minSdk.get().toInteger() targetSdk = libs.versions.android.targetSdk.get().toInteger() versionCode = 433 - versionName = generateVersionName("1.06.0") + versionName = generateVersionName("1.06.1") buildConfigField "String", "RELEASE_NOTES_ID", "\"0b6af7660b44215b0edf9c39a1c9c0b4aafba7aba1ae28665ffcecb1a9717195\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 93a3214c5..a1bf9ab01 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -368,7 +368,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.06.0", + version = "1.06.1", ) // Configure publishing to Maven Central From 346a7ecac2f8d0de7f72d84df14099a7e4f2ab08 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 08:27:32 +0100 Subject: [PATCH 13/17] 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 d76098c76..372c1ff23 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 5246f5567..dcd714fdf 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 7d3b9ef4d..c0ed6031e 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 4011ddf58..1950f9479 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 14/17] 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 36bf02c91..fe0163c5d 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 15/17] 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 dcd714fdf..6d6b6437c 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 0d45efe82..932ae8a94 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 16/17] 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 9eaa1a5da..82888202a 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 32613496f..856e36e47 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 8981e320f..1fb909cc3 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 198cf0711..c87d34b75 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 f6e92f3c4..76b943fa7 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 17/17] =?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 82888202a..0154ca049 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 856e36e47..c4ae7a504 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 }