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/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 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/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..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 @@ -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 @@ -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) @@ -136,6 +137,10 @@ fun RecordAudioBox( } } }, - content = { active -> content(active, elapsedSeconds) }, + content = { active -> + content(active, elapsedSeconds) { + 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..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,15 +50,17 @@ fun RecordVoiceButton( ) { var isRecording by remember { mutableStateOf(false) } var elapsedSeconds by remember { mutableIntStateOf(0) } + var onStopRecording: (() -> Unit)? by remember { mutableStateOf(null) } 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,15 +71,11 @@ fun RecordVoiceButton( onVoiceTaken(recording) }, maxDurationSeconds = maxDurationSeconds, - ) { recordingState, elapsed -> - // Update parent state after composition completes + ) { recordingState, elapsed, onStop -> SideEffect { - if (isRecording != recordingState) { - isRecording = recordingState - } - if (elapsedSeconds != elapsed) { - elapsedSeconds = elapsed - } + isRecording = recordingState + 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) 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/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index 949b92473..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 @@ -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 @@ -178,7 +184,7 @@ fun InnerRenderPoll( TranslatableRichTextViewer( content = label, canPreview = canPreview, - quotesLeft = 1, + quotesLeft = if (quotesLeft > 0) 1 else 0, modifier = Modifier.fillMaxWidth(), tags = tags, backgroundColor = backgroundColor, @@ -328,10 +334,13 @@ 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) } @@ -418,12 +427,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 +491,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 +513,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, 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 +541,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 +557,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, ) @@ -584,6 +615,54 @@ fun RenderPollManualPreview() { } } +@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 -> + Text( + text = label, + ) + } + } + } +} + @SuppressLint("StateFlowValueCalledInComposition") @Preview @Composable 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-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..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 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 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 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 + 自上次同步后 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 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..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 @@ -281,6 +281,18 @@ 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(), + ) + @Test fun testHour() = test( diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 30d120c07..b9a251f69 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -351,7 +351,7 @@ mavenPublishing { coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.06.0", + version = "1.06.1", ) // Configure publishing to Maven Central 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 } } 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)) } } 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..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 @@ -41,4 +41,14 @@ 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) + } } 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..a29c5e99e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/urldetector/UrlTest.kt @@ -0,0 +1,64 @@ +/* + * 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 + +/** + * Regression tests for PR #1907: StringIndexOutOfBoundsException in [Url.getPart] when + * processing the Japanese text "今北産業". + */ +class UrlTest { + /** + * Regression: detecting URLs in "今北産業" must not throw and must return no URLs. + */ + @Test + fun detectingImakitaSangyoDoesNotThrow() { + val urls = UrlDetector("今北産業").detect() + assertEquals(0, urls.size) + } + + /** + * 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 urlPropertiesDoNotThrowForImakitaSangyoWithOutOfRangeMarker() { + val marker = UrlMarker() + marker.setIndex(UrlPart.HOST, 0) + marker.setIndex(UrlPart.PORT, 4) // == "今北産業".length + val url = marker.createUrl("今北産業") + + assertNotNull(url.scheme) + assertNotNull(url.host) + assertNotNull(url.path) + assertNotNull(url.query) + assertNotNull(url.fragment) + assertEquals(-1, url.port) // getPart(PORT) returns null → -1 + } +}