Merge pull request #2597 from davotoula/fix-chat-cursor-jump
Fix chat cursor jump
This commit is contained in:
+145
@@ -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)
|
||||
}
|
||||
}
|
||||
+52
-124
@@ -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 <space>
|
||||
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 <space>
|
||||
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 <space>
|
||||
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 <space>
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldBuffer
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
// Cheap gate — most keystrokes happen in mention-free text.
|
||||
if (!original.contains("npub1") && !original.contains("nprofile1")) 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, "")
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -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.
|
||||
|
||||
+8
-6
@@ -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 }
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
+2
@@ -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(),
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -83,6 +83,7 @@ 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.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
|
||||
@@ -381,6 +382,7 @@ private fun MarkdownPostScreenBody(
|
||||
ThinPaddingTextField(
|
||||
state = postViewModel.message,
|
||||
onTextChanged = postViewModel::onMessageChanged,
|
||||
inputTransformation = MentionPreservingInputTransformation,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
+2
@@ -55,6 +55,7 @@ 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.MentionPreservingInputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
@@ -417,6 +418,7 @@ fun SendDirectMessageTo(
|
||||
ThinPaddingTextField(
|
||||
state = postViewModel.toUsers,
|
||||
onTextChanged = postViewModel::onToUsersChanged,
|
||||
inputTransformation = MentionPreservingInputTransformation,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
|
||||
Reference in New Issue
Block a user