From 9d2ce5e460a34f0be17bf3916f09ae7119bca516 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 17:37:18 +0000 Subject: [PATCH 1/5] fix(compose): snap cursor to wedge boundary in UrlUserTagTransformation The custom OffsetMapping for the @-mention VisualTransformation used percentage-based interpolation when the cursor offset fell inside a substituted "@npub1..." range. An IME using extracted-text mode (e.g. SwiftKey on Pixel 9a) could place the cursor in the middle of the displayed "@DisplayName", which mapped to the middle of the underlying bech32 npub. A subsequent backspace then deleted a char from inside the bech32, the npub stopped matching the regex's 58-char length check, and the collapsed mention "expanded" with the cursor stuck in the middle of the now-visible raw npub. Treat each substitution as an atomic wedge: any cursor strictly inside a substituted range snaps to the wedge's trailing edge in both directions. Tests are rewritten to verify the snap-to-boundary semantics; the prior assertions pinned the buggy percentage behavior. This fixes the cursor-jump-into-npub symptom in EditPostView and ForwardZapTo (which use VisualTransformation directly). Chat input fields use OutputTransformation with Compose's auto-derived mapping and are not affected by this code path. https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ --- .../amethyst/UrlUserTagTransformationTest.kt | 176 ++++++------------ .../ui/actions/UrlUserTagTransformation.kt | 14 +- 2 files changed, 60 insertions(+), 130 deletions(-) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt index 4a4a3ec15..b36312e75 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.TransformedText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.LocalCache @@ -49,28 +48,6 @@ class UrlUserTagTransformationTest { assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) } - fun debugCursor( - original: String, - transformedText: TransformedText, - offset: Int, - ): String { - val offsetTransformed = transformedText.offsetMapping.originalToTransformed(offset) - val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length) - val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length) - return "$originalWithCursor $transformedWithCursor" - } - - fun debugCursorReverse( - original: String, - transformedText: TransformedText, - offsetTransformed: Int, - ): String { - val offset = transformedText.offsetMapping.transformedToOriginal(offsetTransformed) - val originalWithCursor = original.substring(0, offset) + "|" + original.substring(offset, original.length) - val transformedWithCursor = transformedText.text.text.substring(0, offsetTransformed) + "|" + transformedText.text.text.substring(offsetTransformed, transformedText.text.text.length) - return "$originalWithCursor $transformedWithCursor" - } - @Test fun testKeepTransformedIndexFullyInsideTransformedText() { val user = @@ -103,91 +80,21 @@ class UrlUserTagTransformationTest { val expected = "@Vitor Pamplona" assertEquals(expected, transformedText.text.text) - assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 0)) - assertEquals("@|npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 1)) - assertEquals("@n|pub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 2)) - assertEquals("@np|ub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 3)) - assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursor(original, transformedText, 4)) - assertEquals("@npub|1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 5)) - assertEquals("@npub1|gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 6)) - assertEquals("@npub1g|cxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 7)) - assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursor(original, transformedText, 8)) - assertEquals("@npub1gcx|zte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 9)) - assertEquals("@npub1gcxz|te5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 10)) - assertEquals("@npub1gcxzt|e5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 11)) - assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursor(original, transformedText, 12)) - assertEquals("@npub1gcxzte5|zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 13)) - assertEquals("@npub1gcxzte5z|lkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 14)) - assertEquals("@npub1gcxzte5zl|kncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 15)) - assertEquals("@npub1gcxzte5zlk|ncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 16)) - assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursor(original, transformedText, 17)) - assertEquals("@npub1gcxzte5zlknc|x26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 18)) - assertEquals("@npub1gcxzte5zlkncx|26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 19)) - assertEquals("@npub1gcxzte5zlkncx2|6j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 20)) - assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursor(original, transformedText, 21)) - assertEquals("@npub1gcxzte5zlkncx26j|68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 22)) - assertEquals("@npub1gcxzte5zlkncx26j6|8ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 23)) - assertEquals("@npub1gcxzte5zlkncx26j68|ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 24)) - assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursor(original, transformedText, 25)) - assertEquals("@npub1gcxzte5zlkncx26j68ez|60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 26)) - assertEquals("@npub1gcxzte5zlkncx26j68ez6|0fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 27)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60|fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 28)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursor(original, transformedText, 29)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fz|kvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 30)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzk|vtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 31)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkv|tkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 32)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvt|km9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 33)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursor(original, transformedText, 34)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm|9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 35)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9|e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 36)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e|0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 37)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursor(original, transformedText, 38)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0v|rwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 39)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vr|wdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 40)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrw|dcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 41)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursor(original, transformedText, 42)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdc|vsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 43)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcv|sjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 44)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvs|jakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 45)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursor(original, transformedText, 46)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsja|kxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 47)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjak|xf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 48)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakx|f9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 49)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf|9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 50)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pamp|lona", debugCursor(original, transformedText, 51)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9m|u9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 52)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu|9qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 53)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9|qewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 54)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pampl|ona", debugCursor(original, transformedText, 55)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qe|wqlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 56)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qew|qlfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 57)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewq|lfnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 58)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplo|na", debugCursor(original, transformedText, 59)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlf|nj5z @Vitor Pamplon|a", debugCursor(original, transformedText, 60)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfn|j5z @Vitor Pamplon|a", debugCursor(original, transformedText, 61)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj|5z @Vitor Pamplon|a", debugCursor(original, transformedText, 62)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5|z @Vitor Pamplon|a", debugCursor(original, transformedText, 63)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursor(original, transformedText, 64)) - - assertEquals("|@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z |@Vitor Pamplona", debugCursorReverse(original, transformedText, 0)) - assertEquals("@npu|b1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @|Vitor Pamplona", debugCursorReverse(original, transformedText, 1)) - assertEquals("@npub1gc|xzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @V|itor Pamplona", debugCursorReverse(original, transformedText, 2)) - assertEquals("@npub1gcxzte|5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vi|tor Pamplona", debugCursorReverse(original, transformedText, 3)) - assertEquals("@npub1gcxzte5zlkn|cx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vit|or Pamplona", debugCursorReverse(original, transformedText, 4)) - assertEquals("@npub1gcxzte5zlkncx26|j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vito|r Pamplona", debugCursorReverse(original, transformedText, 5)) - assertEquals("@npub1gcxzte5zlkncx26j68e|z60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor| Pamplona", debugCursorReverse(original, transformedText, 6)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60f|zkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor |Pamplona", debugCursorReverse(original, transformedText, 7)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtk|m9e0vrwdcvsjakxf9mu9qewqlfnj5z @Vitor P|amplona", debugCursorReverse(original, transformedText, 8)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0|vrwdcvsjakxf9mu9qewqlfnj5z @Vitor Pa|mplona", debugCursorReverse(original, transformedText, 9)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwd|cvsjakxf9mu9qewqlfnj5z @Vitor Pam|plona", debugCursorReverse(original, transformedText, 10)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsj|akxf9mu9qewqlfnj5z @Vitor Pamp|lona", debugCursorReverse(original, transformedText, 11)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9|mu9qewqlfnj5z @Vitor Pampl|ona", debugCursorReverse(original, transformedText, 12)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9q|ewqlfnj5z @Vitor Pamplo|na", debugCursorReverse(original, transformedText, 13)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewql|fnj5z @Vitor Pamplon|a", debugCursorReverse(original, transformedText, 14)) - assertEquals("@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z| @Vitor Pamplona|", debugCursorReverse(original, transformedText, 15)) - + // The mention is treated as an atomic wedge: any cursor strictly inside the + // underlying npub snaps to the trailing edge of the displayed "@Vitor Pamplona" + // (and vice versa). This prevents an IME from placing the cursor in the middle + // of the bech32 and corrupting it on backspace. assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) + for (i in 1..63) { + assertEquals("originalToTransformed($i)", 15, transformedText.offsetMapping.originalToTransformed(i)) + } assertEquals(15, transformedText.offsetMapping.originalToTransformed(64)) + + assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) + for (i in 1..14) { + assertEquals("transformedToOriginal($i)", 64, transformedText.offsetMapping.transformedToOriginal(i)) + } + assertEquals(64, transformedText.offsetMapping.transformedToOriginal(15)) } @Test @@ -219,23 +126,30 @@ class UrlUserTagTransformationTest { assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) + // Outside the wedge: identity mapping. assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H - assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ - assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n - assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p - assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) // Before u - assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) // Before b - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ (boundary) - assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) + // Strictly inside the underlying npub: snaps to the end of "@Vitor Pamplona" (offset 23). + assertEquals(23, transformedText.offsetMapping.originalToTransformed(9)) // Before n + assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) // Before b + assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z + + // End-of-wedge boundary maps to end of displayed mention. assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) + // Outside the wedge in displayed: identity. assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) - assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) - assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) // Before @ (boundary) + // Strictly inside displayed "@Vitor Pamplona": snaps to end of underlying npub (offset 72). + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(9)) + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) + + // End-of-wedge boundary maps to end of underlying mention; past it shifts by deltas. assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) } @@ -272,26 +186,40 @@ class UrlUserTagTransformationTest { assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) - assertEquals(8, transformedText.offsetMapping.originalToTransformed(11)) - assertEquals(8, transformedText.offsetMapping.originalToTransformed(12)) - assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) + // Strictly inside the first underlying npub [8, 72): snap to end of first + // displayed "@Vitor Pamplona" (offset 23). + assertEquals(23, transformedText.offsetMapping.originalToTransformed(11)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(12)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(13)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) - assertEquals(22, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 - assertEquals(22, transformedText.offsetMapping.originalToTransformed(71)) // Before z + // Boundary at end of first wedge: end of first displayed mention. assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before - assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ - assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n + assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ (boundary, second wedge) - assertEquals(67, transformedText.offsetMapping.transformedToOriginal(22)) // Before a + // Strictly inside the second underlying npub [77, 141): snap to end of second + // displayed "@Vitor Pamplona" (offset 43). + assertEquals(43, transformedText.offsetMapping.originalToTransformed(78)) // Before n + assertEquals(43, transformedText.offsetMapping.originalToTransformed(140)) + + // Strictly inside first displayed "@Vitor Pamplona" [8, 23): snap to end of + // first underlying npub (offset 72). + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(22)) // Before a (display) assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before - assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ + assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ (boundary, second wedge) + + // Strictly inside second displayed "@Vitor Pamplona" [28, 43): snap to end of + // second underlying npub (offset 141). + assertEquals(141, transformedText.offsetMapping.transformedToOriginal(29)) + assertEquals(141, transformedText.offsetMapping.transformedToOriginal(42)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 2bd2817af..a56eb7052 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -138,14 +138,18 @@ fun buildAnnotatedStringWithUrlHighlighting( val numberOffsetTranslator = object : OffsetMapping { + // Treat each substitution as an atomic wedge: any cursor position that falls + // strictly inside a substituted range snaps to the wedge's trailing edge. + // Without this, an IME (e.g. SwiftKey in extracted-text mode) can place the + // cursor in the middle of an "@npub1..." mention, and a subsequent backspace + // deletes a char from inside the bech32, breaking the npub and "expanding" + // the collapsed mention. override fun originalToTransformed(offset: Int): Int { val inInsideRange = substitutions.firstOrNull { offset > it.original.start && offset < it.original.end } if (inInsideRange != null) { - val percentInRange = - (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) - return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).toInt() + return inInsideRange.modified.end } val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } @@ -162,9 +166,7 @@ fun buildAnnotatedStringWithUrlHighlighting( substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end } if (inInsideRange != null) { - val percentInRange = - (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) - return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).toInt() + return inInsideRange.original.end } val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } From b3e1f360abcd695031373ec394d281b926618446 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 19:59:43 +0000 Subject: [PATCH 2/5] fix(compose): preserve mentions atomically against IME word-recomposition Microsoft SwiftKey re-enters word-edit mode over a previously-committed display token after autocorrect-on-space, then issues setComposingText with a shortened version. Compose's auto-derived offset mapping for OutputTransformation uses identity inside a wedge, so the IME's replacement only overwrites the leading characters of the underlying @npub1... bech32, leaving an orphan tail that no longer matches the mention regex. The wedge collapses, the orphan bech32 becomes visible, and the cursor lands in the middle of it. Gboard never enters word-edit mode for previously-committed tokens, so it doesn't trigger this. Add MentionPreservingInputTransformation that runs on every input change and reverts any edit whose original-text range partially intersects a complete mention without fully covering it. The mention stays atomic; the IME re-reads the unchanged buffer and moves on. Wire it into all OutputTransformation-using fields: chats, new note, group DM, public channel, public message, classifieds, long-form. https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ --- .../MentionPreservingInputTransformation.kt | 76 +++++++++++++++++++ .../creators/messagefield/MessageField.kt | 2 + .../chats/privateDM/send/NewGroupDMScreen.kt | 3 + .../send/PrivateMessageEditFieldRow.kt | 2 + .../chats/publicChannels/send/EditFieldRow.kt | 2 + .../nip23LongForm/LongFormPostScreen.kt | 2 + .../discover/nip99Classifieds/SellProduct.kt | 3 + .../publicMessages/NewPublicMessageScreen.kt | 2 + 8 files changed, 92 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt new file mode 100644 index 000000000..a7c8cb073 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer + +/** + * Rejects any edit that partially modifies a previously-complete Nostr mention + * (`@npub1…`, `nostr:npub1…`, `@nprofile1…`, `nostr:nprofile1…`). + * + * Background: when an `OutputTransformation` collapses an underlying npub into a + * short `@DisplayName`, Compose's auto-derived offset mapping uses identity inside + * the wedge. Some IMEs — notably Microsoft SwiftKey — re-enter "word edit mode" + * over a previously-committed display token after autocorrect-on-space, then issue + * `setComposingText` with a shortened version. Because the mapping is identity + * inside the wedge, the IME's replacement only overwrites the leading characters + * of the underlying bech32, leaving an orphan tail that no longer matches the + * mention regex. The wedge collapses, the orphan bech32 becomes visible, and the + * cursor lands in the middle of it. Gboard never enters this state because it + * does not recompose previously-committed tokens. + * + * This guard runs on every input change. If the change's original-text range + * partially intersects a complete mention but does not fully cover it, the entire + * change is reverted. The mention stays atomic; the IME re-reads the unchanged + * buffer and moves on. + */ +object MentionPreservingInputTransformation : InputTransformation { + private val mentionRegex = + Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") + + override fun TextFieldBuffer.transformInput() { + val changeCount = changes.changeCount + if (changeCount == 0) return + + val original = originalText.toString() + if (original.isEmpty()) return + + val mentions = mentionRegex.findAll(original).toList() + if (mentions.isEmpty()) return + + for (i in 0 until changeCount) { + val origRange = changes.getOriginalRange(i) + val origStart = origRange.min + val origEnd = origRange.max + for (mention in mentions) { + val mStart = mention.range.first + val mEndExclusive = mention.range.last + 1 + val overlaps = origStart < mEndExclusive && origEnd > mStart + val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive + if (overlaps && !fullyCovers) { + revertAllChanges() + return + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt index 36cdecdcd..d25007493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.stringRes @@ -70,6 +71,7 @@ fun MessageField( state = viewModel.message, onTextChanged = viewModel::onMessageChanged, onContentReceived = onContentReceived, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 270113bd9..af68085c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -530,6 +531,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.toUsers, onTextChanged = postViewModel::onToUsersChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .weight(1f) @@ -572,6 +574,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.subject, onTextChanged = { postViewModel.onSubjectChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index da9d8ed21..51b5bbd3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -53,6 +53,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -203,6 +204,7 @@ fun EditField( ThinPaddingTextField( state = channelScreenModel.message, onTextChanged = { channelScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = PostKeyboard, shape = EditFieldBorder, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 1c2669b35..c9e63c90f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -116,6 +117,7 @@ fun EditFieldRow( ThinPaddingTextField( state = channelScreenModel.message, onTextChanged = { channelScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 87977bdc4..2eb873924 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -381,6 +382,7 @@ private fun MarkdownPostScreenBody( ThinPaddingTextField( state = postViewModel.message, onTextChanged = postViewModel::onMessageChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .fillMaxWidth() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt index c13346b81..52dfc08ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField @@ -111,6 +112,7 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( state = postViewModel.title, onTextChanged = { postViewModel.onTitleChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -311,6 +313,7 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( state = postViewModel.locationText, onTextChanged = { postViewModel.onLocationChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 1519ac398..9f284b4c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -417,6 +418,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.toUsers, onTextChanged = postViewModel::onToUsersChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .weight(1f) From 7ad54ac33bba815b3305359323ef77b8a1864e1a Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 26 Apr 2026 23:51:57 +0200 Subject: [PATCH 3/5] style: spotless import order Co-Authored-By: Claude Opus 4.7 (1M context) --- .../loggedIn/discover/nip23LongForm/LongFormPostScreen.kt | 2 +- .../notifications/publicMessages/NewPublicMessageScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 2eb873924..35296e88d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -83,8 +83,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList -import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 9f284b4c9..06d46689e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -55,8 +55,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery From 338080115fa8e011ba6893f9d7ee8b23de48cb5d Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 27 Apr 2026 00:13:36 +0200 Subject: [PATCH 4/5] fix(compose): also collapse on scope-exact replace; user-reported @Vitor Pamplona regression fix(compose): tighten @OptIn scope from @file to the object fix(compose): allow full-cover changes through; collapse only on partial overlap refactor(compose): hoist MENTION_REGEX, fast-path mention-free text, drop redundant scaffolding fix(compose): also collapse mention atomically on full-range non-empty replaces fix(compose): atomically delete the whole mention on partial-overlap edits fix(compose): opt-in ExperimentalFoundationApi in MentionPreservingInputTransformation --- .../MentionPreservingInputTransformation.kt | 92 +++++++++++-------- .../actions/UrlUserTagOutputTransformation.kt | 7 +- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt index a7c8cb073..608099835 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt @@ -20,57 +20,71 @@ */ package com.vitorpamplona.amethyst.ui.actions +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer /** - * Rejects any edit that partially modifies a previously-complete Nostr mention - * (`@npub1…`, `nostr:npub1…`, `@nprofile1…`, `nostr:nprofile1…`). - * - * Background: when an `OutputTransformation` collapses an underlying npub into a - * short `@DisplayName`, Compose's auto-derived offset mapping uses identity inside - * the wedge. Some IMEs — notably Microsoft SwiftKey — re-enter "word edit mode" - * over a previously-committed display token after autocorrect-on-space, then issue - * `setComposingText` with a shortened version. Because the mapping is identity - * inside the wedge, the IME's replacement only overwrites the leading characters - * of the underlying bech32, leaving an orphan tail that no longer matches the - * mention regex. The wedge collapses, the orphan bech32 becomes visible, and the - * cursor lands in the middle of it. Gboard never enters this state because it - * does not recompose previously-committed tokens. - * - * This guard runs on every input change. If the change's original-text range - * partially intersects a complete mention but does not fully cover it, the entire - * change is reverted. The mention stays atomic; the IME re-reads the unchanged - * buffer and moves on. + * Matches a complete Nostr mention token: `@npub1…`, `nostr:npub1…`, + * `@nprofile1…`, `nostr:nprofile1…`. Shared with [UrlUserTagOutputTransformation] + * so the wedge it produces and the input-side guard below agree on what + * counts as a mention. */ -object MentionPreservingInputTransformation : InputTransformation { - private val mentionRegex = - Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") +internal val MENTION_REGEX = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") +/** + * Keeps Nostr mentions atomic against IME edits that would only modify part of + * the underlying bech32 (notably Microsoft SwiftKey, which re-enters word-edit + * mode over a previously-committed display token and rewrites a single word of + * a multi-word `@DisplayName`, leaving an orphan tail of the npub that no + * longer matches the mention regex). + * + * Three change shapes are blocked and routed to atomic-collapse: + * - Partial overlap (the change's `originalRange` overlaps a mention but does + * not fully cover it). + * - Scope-exact replace (the change's `originalRange` matches the mention's + * range exactly and the replacement is non-empty — covers IMEs that + * fully-cover-replace a multi-word display token with one of its words). + * + * Anything else passes through: + * - Pure delete that fully covers a mention (the user removed the chip). + * - A change whose range covers more than just the mention (select-all + type, + * select-paragraph + paste, etc.) — treated as deliberate broader edit. + */ +@OptIn(ExperimentalFoundationApi::class) +object MentionPreservingInputTransformation : InputTransformation { override fun TextFieldBuffer.transformInput() { val changeCount = changes.changeCount if (changeCount == 0) return - val original = originalText.toString() - if (original.isEmpty()) return + val original = originalText + // Cheap gate — most keystrokes happen in mention-free text. + if (!original.contains("npub1") && !original.contains("nprofile1")) return - val mentions = mentionRegex.findAll(original).toList() - if (mentions.isEmpty()) return - - for (i in 0 until changeCount) { - val origRange = changes.getOriginalRange(i) - val origStart = origRange.min - val origEnd = origRange.max - for (mention in mentions) { - val mStart = mention.range.first - val mEndExclusive = mention.range.last + 1 - val overlaps = origStart < mEndExclusive && origEnd > mStart - val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive - if (overlaps && !fullyCovers) { - revertAllChanges() - return + val touched = + MENTION_REGEX.findAll(original).firstOrNull { match -> + val mStart = match.range.first + val mEndExclusive = match.range.last + 1 + (0 until changeCount).any { i -> + val origRange = changes.getOriginalRange(i) + val origStart = origRange.min + val origEnd = origRange.max + val overlaps = origStart < mEndExclusive && origEnd > mStart + val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive + val isScopeExact = origStart == mStart && origEnd == mEndExclusive + val isPureDelete = changes.getRange(i).length == 0 + overlaps && (!fullyCovers || (isScopeExact && !isPureDelete)) } + } ?: return + + revertAllChanges() + val mEndExclusive = touched.range.last + 1 + val deleteEnd = + if (mEndExclusive < length && asCharSequence()[mEndExclusive].isWhitespace()) { + mEndExclusive + 1 + } else { + mEndExclusive } - } + replace(touched.range.first, deleteEnd, "") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt index 9d5dff684..506a8d338 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -35,11 +35,8 @@ class UrlUserTagOutputTransformation( override fun TextFieldBuffer.transformOutput() { val text = asCharSequence().toString() - // Find all user mentions using regex and replace in reverse order - // so that earlier indices remain valid after replacements. - // Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1... - val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") - val matches = mentionRegex.findAll(text).toList().reversed() + // Reverse so earlier indices remain valid after each replace. + val matches = MENTION_REGEX.findAll(text).toList().reversed() // Phase 1: Replace all mentions (reverse order keeps indices valid for replace). // Collect replacement info because addStyle must be called after all text mutations. From c78c1336759ad44fcb67161ca43f1fb75c7c2341 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 27 Apr 2026 10:51:53 +0200 Subject: [PATCH 5/5] test(compose): instrumented coverage for MentionPreservingInputTransformation 11 cases driving the predicate matrix against a real TextFieldState on device: - mention-free text passes through - pure delete fully covering a mention is allowed - partial overlap (at start, at end, inside) collapses atomically - scope-exact replace with non-empty text collapses (SwiftKey case) - scope-broader replace passes through - append after mention preserves it - trailing space and trailing newline are consumed during atomic collapse - multiple mentions: only the touched one collapses - cheap-gate path (mention-free original) is verified All 11 pass on Pixel 9a; gives the predicate a regression net so future predicate-tuning doesn't reintroduce the @Vitor Pamplona bug. Run via: ./gradlew :amethyst:connectedPlayDebugAndroidTest \ -Pandroid.testInstrumentationRunnerArguments.class=\ com.vitorpamplona.amethyst.MentionPreservingInputTransformationTest Co-Authored-By: Claude Opus 4.7 (1M context) --- ...entionPreservingInputTransformationTest.kt | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt new file mode 100644 index 000000000..227812f32 --- /dev/null +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/MentionPreservingInputTransformationTest.kt @@ -0,0 +1,145 @@ +/* + * 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. + */ +@file:OptIn(ExperimentalFoundationApi::class) + +package com.vitorpamplona.amethyst + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldState +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Drives [MentionPreservingInputTransformation] against a real [TextFieldState] + * with simulated IME edits. The npub literal has no metadata loaded — these + * tests only exercise the input-side guard, which keys off the underlying bech32 + * text rather than any display-name resolution. + */ +@RunWith(AndroidJUnit4::class) +class MentionPreservingInputTransformationTest { + /** 64 characters: leading `@` + bech32 (`npub1` + 58 chars). */ + private val npub = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" + + /** + * Apply [stage] inside an edit session, then run the InputTransformation + * exactly as the framework would, and return the committed text. + */ + private fun TextFieldState.applyChange(stage: TextFieldBuffer.() -> Unit): String { + edit { + stage() + with(MentionPreservingInputTransformation) { + transformInput() + } + } + return text.toString() + } + + @Test + fun mentionFreeText_passesThrough() { + val state = TextFieldState("hello world") + val result = state.applyChange { replace(0, 5, "HELLO") } + assertEquals("HELLO world", result) + } + + @Test + fun pureDeleteFullyCoveringMention_passesThrough() { + val state = TextFieldState(npub) + val result = state.applyChange { replace(0, npub.length, "") } + assertEquals("", result) + } + + @Test + fun partialDeleteInsideMention_collapsesAtomically() { + val state = TextFieldState(npub) + // delete a chunk near the end of the bech32 + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("", result) + } + + @Test + fun partialDeleteAtMentionStart_collapsesAtomically() { + val state = TextFieldState(npub) + // delete the leading "@npub" prefix only + val result = state.applyChange { replace(0, 5, "") } + assertEquals("", result) + } + + @Test + fun scopeExactReplaceWithNonEmpty_collapsesAtomically() { + // SwiftKey case: IME fully covers the mention range and writes a + // shortened replacement (e.g. one of the multi-word display tokens). + val state = TextFieldState(npub) + val result = state.applyChange { replace(0, npub.length, "@John") } + assertEquals("", result) + } + + @Test + fun scopeBroaderReplace_passesThrough() { + // Select-all + type: change covers the mention plus surrounding text. + // Treated as a deliberate broader edit; the typed character is preserved. + val state = TextFieldState("hi $npub world") + val result = state.applyChange { replace(0, length, "x") } + assertEquals("x", result) + } + + @Test + fun appendAfterMention_passesThrough() { + val state = TextFieldState(npub) + val result = state.applyChange { append(" hello") } + assertEquals("$npub hello", result) + } + + @Test + fun mentionWithTrailingSpace_collapseConsumesSpace() { + val state = TextFieldState("$npub hello") + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("hello", result) + } + + @Test + fun mentionWithTrailingNewline_collapseConsumesNewline() { + val state = TextFieldState("$npub\nhello") + val result = state.applyChange { replace(60, npub.length, "") } + assertEquals("hello", result) + } + + @Test + fun multipleMentions_partialOnSecond_onlySecondCollapsed() { + val text = "$npub and $npub" + val state = TextFieldState(text) + // partial delete inside the second mention only + val result = state.applyChange { replace(text.length - 4, text.length, "") } + assertEquals("$npub and ", result) + } + + @Test + fun mentionFreeChange_skipsRegexEntirely() { + // No "npub1" or "nprofile1" substring in the original text — the + // cheap-gate path should exit before any regex work. + val state = TextFieldState("hello world this is plain text") + val result = state.applyChange { replace(5, 11, "") } + assertEquals("hello this is plain text", result) + } +}