diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt deleted file mode 100644 index b36312e75f..0000000000 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class UrlUserTagTransformationTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug")) - } - - @Test - fun testKeepTransformedIndexFullyInsideTransformedText() { - val user = - LocalCache.getOrCreateUser( - decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - .toHexKey(), - ) - user.metadata().newMetadata( - UserMetadata().also { - it.displayName = "Vitor Pamplona" - }, - MetadataEvent( - id = "", - pubKey = "", - createdAt = 0, - tags = emptyArray(), - content = "", - sig = "", - ), - ) - - val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z" - - val transformedText = - buildAnnotatedStringWithUrlHighlighting( - AnnotatedString(original), - Color.Red, - ) - - val expected = "@Vitor Pamplona" - assertEquals(expected, transformedText.text.text) - - // 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 - fun transformationText() { - val user = - LocalCache.getOrCreateUser( - decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - .toHexKey(), - ) - user.metadata().newMetadata( - UserMetadata().also { - it.displayName = "Vitor Pamplona" - }, - MetadataEvent( - id = "", - pubKey = "", - createdAt = 0, - tags = emptyArray(), - content = "", - sig = "", - ), - ) - - val transformedText = - buildAnnotatedStringWithUrlHighlighting( - AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), - Color.Red, - ) - - 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 @ (boundary) - - // 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)) // 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)) - } - - @Test - fun transformationTextTwoKeys() { - val user = - LocalCache.getOrCreateUser( - decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z") - .toHexKey(), - ) - - user.metadata().newMetadata( - UserMetadata().also { - it.displayName = "Vitor Pamplona" - }, - MetadataEvent( - id = "", - pubKey = "", - createdAt = 0, - tags = emptyArray(), - content = "", - sig = "", - ), - ) - - val transformedText = - buildAnnotatedStringWithUrlHighlighting( - AnnotatedString( - "New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", - ), - Color.Red, - ) - - assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) - - // 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)) - - // 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 @ (boundary, second wedge) - - // 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 @ (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/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 46655aecf0..4ac9fefc0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst import android.app.Application -import com.vitorpamplona.amethyst.service.crashreports.MainThreadCrashGuard import com.vitorpamplona.amethyst.service.logging.Logging import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook import com.vitorpamplona.quartz.utils.Log @@ -41,12 +40,6 @@ class Amethyst : Application() { override fun onCreate() { super.onCreate() Log.d("AmethystApp") { "onCreate $this" } - - // Wrap the main thread's Looper.loop() so we can survive a narrow set - // of known Compose framework crashes (currently: the LegacyCursorAnchorInfo - // IllegalArgumentException). Must be installed before any UI is shown. - MainThreadCrashGuard.install() - instance = AppModules(this) // After-background foreground recycle: when the app returns to diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/MainThreadCrashGuard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/MainThreadCrashGuard.kt deleted file mode 100644 index ca6626102c..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/MainThreadCrashGuard.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.crashreports - -import android.os.Handler -import android.os.Looper -import com.vitorpamplona.quartz.utils.Log - -/** - * Wraps the main thread's `Looper.loop()` so a narrow set of known, benign - * framework crashes can be swallowed instead of taking the whole process down. - * - * Only one case is handled today: `IllegalArgumentException("endOffset must be - * greater than startOffset")` thrown from - * `androidx.compose.foundation.text.input.internal.LegacyCursorAnchorInfoBuilder`. - * It fires when the IME has requested `CURSOR_UPDATE_MONITOR` on a TextField - * whose visible bounds momentarily collapse to a zero-width region (during a - * layout transition or sheet animation): `addCharacterBounds` computes - * `startOffset == endOffset` and `MultiParagraph.fillBoundingBoxes` rejects - * the empty range. The IME just misses a cursor update — the app does not - * need to die. - * - * Any other Throwable propagates so the default uncaught-exception handler - * (and our crash report saver) still see it. - */ -object MainThreadCrashGuard { - fun install() { - Handler(Looper.getMainLooper()).post { - while (true) { - try { - Looper.loop() - // Looper.loop() returned cleanly — main thread is shutting down. - return@post - } catch (e: Throwable) { - if (!isSwallowable(e)) throw e - Log.w("MainThreadCrashGuard", "Swallowed known framework crash: ${e.javaClass.name}: ${e.message}", e) - } - } - } - } - - private fun isSwallowable(e: Throwable): Boolean = isComposeLegacyCursorAnchorInfoCrash(e) - - private fun isComposeLegacyCursorAnchorInfoCrash(e: Throwable): Boolean { - if (e !is IllegalArgumentException) return false - if (e.message != "endOffset must be greater than startOffset") return false - // Cap the scan: in production this stack is ~25 frames; 40 is generous and bounds the - // cost of every main-thread exception we don't swallow. - val frames = e.stackTrace - val limit = minOf(frames.size, 40) - for (i in 0 until limit) { - val frame = frames[i] - val cls = frame.className - if (cls.contains("LegacyCursorAnchorInfo") || - cls.contains("MultiParagraph") || - cls.contains("AndroidParagraph") || - cls.endsWith("TextLayout") - ) { - return true - } - } - return false - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 54c4b3c315..aae12541f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -36,16 +36,16 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -63,7 +63,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -81,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose @@ -300,8 +300,9 @@ fun EditPostView( stringRes(id = R.string.lightning_invoice), stringRes(id = R.string.lightning_create_and_add_invoice), onNewInvoice = { - postViewModel.message = - TextFieldValue(postViewModel.message.text + "\n\n" + it) + postViewModel.message.setTextAndPlaceCursorAtEnd( + postViewModel.message.text.toString() + "\n\n" + it, + ) postViewModel.wantsInvoice = false }, onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, @@ -309,43 +310,6 @@ fun EditPostView( } } } - - /* - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp, horizontal = Size10dp), - ) { - Column { - Text( - text = stringRes(R.string.message_to_author), - fontSize = 18.sp, - fontWeight = FontWeight.W500, - ) - - HorizontalDivider(thickness = DividerThickness) - - MyTextField( - value = postViewModel.subject, - onValueChange = { postViewModel.updateSubject(it) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringRes(R.string.message_to_author_placeholder), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - visualTransformation = - UrlUserTagTransformation( - MaterialTheme.colorScheme.primary, - ), - colors = - OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - ), - ) - } - }*/ } } @@ -416,9 +380,11 @@ private fun MessageField(postViewModel: EditPostViewModel) { } } - OutlinedTextField( - value = postViewModel.message, - onValueChange = { postViewModel.updateMessage(it) }, + ThinPaddingTextField( + state = postViewModel.message, + onTextChanged = { postViewModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -443,11 +409,10 @@ private fun MessageField(postViewModel: EditPostViewModel) { ) }, colors = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 583dac2920..2b971a72dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.Context +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -77,7 +79,7 @@ open class EditPostViewModel : ViewModel() { var nip95attachments by mutableStateOf>>(emptyList()) - var message by mutableStateOf(TextFieldValue("")) + val message = TextFieldState() var urlPreview by mutableStateOf(null) val mediaUploadTracker = MediaUploadTracker() val isUploadingImage: Boolean get() = mediaUploadTracker.isUploadingImage @@ -111,7 +113,7 @@ open class EditPostViewModel : ViewModel() { canAddInvoice = accountViewModel.userProfile().lnAddress() != null multiOrchestrator = null - message = TextFieldValue(versionLookingAt?.event?.content ?: edit.event?.content ?: "") + message.setTextAndPlaceCursorAtEnd(versionLookingAt?.event?.content ?: edit.event?.content ?: "") urlPreview = findUrlInMessage() this.editedFromNote = edit @@ -141,7 +143,7 @@ open class EditPostViewModel : ViewModel() { } account.sendEdit( - message = message.text, + message = message.text.toString(), originalNote = editedFromNote!!, notify = notify, summary = subject.text.ifBlank { null }, @@ -248,7 +250,7 @@ open class EditPostViewModel : ViewModel() { } } - message = message.insertUrlAtCursor(urls.joinToString(" ")) + message.insertUrlAtCursor(urls.joinToString(" ")) urlPreview = findUrlInMessage() this@EditPostViewModel.multiOrchestrator = null @@ -264,7 +266,7 @@ open class EditPostViewModel : ViewModel() { } open fun cancel() { - message = TextFieldValue("") + message.setTextAndPlaceCursorAtEnd("") subject = TextFieldValue("") editedFromNote = null @@ -280,17 +282,16 @@ open class EditPostViewModel : ViewModel() { } open fun findUrlInMessage(): String? = - message.text.split('\n').firstNotNullOfOrNull { paragraph -> + message.text.toString().split('\n').firstNotNullOfOrNull { paragraph -> paragraph.split(' ').firstOrNull { word: String -> RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word) } } - open fun updateMessage(it: TextFieldValue) { - message = it + open fun onMessageChanged() { urlPreview = findUrlInMessage() - if (it.selection.collapsed) { + if (message.selection.collapsed) { val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE @@ -305,7 +306,7 @@ open class EditPostViewModel : ViewModel() { open fun autocompleteWithUser(item: User) { userSuggestions?.let { userSuggestions -> val lastWord = message.currentWord() - message = userSuggestions.replaceCurrentWord(message, lastWord, item) + userSuggestions.replaceCurrentWord(message, lastWord, item) userSuggestionsMainMessage = null userSuggestions.reset() 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 deleted file mode 100644 index a56eb70522..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -import android.util.Patterns -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextDecoration -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull -import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import kotlin.coroutines.cancellation.CancellationException - -data class RangesChanges( - val original: TextRange, - val modified: TextRange, -) - -class UrlUserTagTransformation( - val color: Color, -) : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText = buildAnnotatedStringWithUrlHighlighting(text, color) -} - -fun buildAnnotatedStringWithUrlHighlighting( - text: AnnotatedString, - color: Color, -): TransformedText { - val substitutions = mutableListOf() - - val newText = - buildAnnotatedString { - val builderBefore = StringBuilder() // important to correctly measure Tag start and end - val builderAfter = StringBuilder() // important to correctly measure Tag start and end - append( - text.text.split('\n').joinToString("\n") { paragraph: String -> - paragraph.split(' ').joinToString(" ") { word: String -> - try { - val mention = parseUserMention(word) - if (mention != null) { - val (keyPortion, restOfWord, hexKey) = mention - - val startIndex = builderBefore.toString().length - - builderBefore.append( - "$keyPortion$restOfWord ", - ) // accounts for the \n at the end of each paragraph - - val endIndex = startIndex + keyPortion.length - - val user = LocalCache.getOrCreateUser(hexKey) - - val newWord = "@${user.toBestDisplayName()}" - val startNew = builderAfter.toString().length - - builderAfter.append( - "$newWord$restOfWord ", - ) // accounts for the \n at the end of each paragraph - - substitutions.add( - RangesChanges( - TextRange(startIndex, endIndex), - TextRange(startNew, startNew + newWord.length), - ), - ) - newWord + restOfWord - } else if (Patterns.WEB_URL.matcher(word).matches()) { - val startIndex = builderBefore.toString().length - val endIndex = startIndex + word.length - - val startNew = builderAfter.toString().length - val endNew = startNew + word.length - - substitutions.add( - RangesChanges( - TextRange(startIndex, endIndex), - TextRange(startNew, endNew), - ), - ) - - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } else { - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } - } catch (e: Exception) { - if (e is CancellationException) throw e - // if it can't parse the key, don't try to change. - builderBefore.append("$word ") - builderAfter.append("$word ") - word - } - } - }, - ) - - substitutions.forEach { - addStyle( - style = - SpanStyle( - color = color, - textDecoration = TextDecoration.None, - ), - start = it.modified.start, - end = it.modified.end, - ) - } - } - - 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) { - return inInsideRange.modified.end - } - - val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } - - return if (lastRangeThrough != null) { - lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end) - } else { - offset - } - } - - override fun transformedToOriginal(offset: Int): Int { - val inInsideRange = - substitutions.firstOrNull { offset > it.modified.start && offset < it.modified.end } - - if (inInsideRange != null) { - return inInsideRange.original.end - } - - val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } - - return if (lastRangeThrough != null) { - lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end) - } else { - offset - } - } - } - - return TransformedText( - newText, - numberOffsetTranslator, - ) -} - -private data class UserMention( - val keyPortion: String, - val restOfWord: String, - val hexKey: String, -) - -private fun parseUserMention(word: String): UserMention? { - var key = word - val prefix: String - - if (key.startsWith("nostr:", true)) { - prefix = key.substring(0, 6) - key = key.substring(6) - } else if (key.startsWith("@")) { - prefix = "@" - key = key.substring(1) - } else { - return null - } - - if (key.startsWith("npub1", true) && key.length >= 63) { - val keyB32 = key.substring(0, 63) - val restOfWord = key.substring(63) - val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null - return UserMention("$prefix$keyB32", restOfWord, hex) - } else if (key.startsWith("nprofile1", true)) { - val parsed = Nip19Parser.uriToRoute(key) ?: return null - val entity = parsed.entity - if (entity !is NProfile && entity !is NPub) return null - val hex = - when (entity) { - is NProfile -> entity.hex - is NPub -> entity.hex - else -> return null - } - val bech32Len = parsed.nip19raw.length - val restOfWord = key.substring(bech32Len) - return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex) - } - - return null -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt index c521a22292..5c9e15baa7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/ForwardZapTo.kt @@ -29,19 +29,22 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +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.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -123,9 +126,11 @@ fun ForwardZapTo( } } - OutlinedTextField( - value = postViewModel.forwardZapToEditting.value, - onValueChange = { postViewModel.updateZapForwardTo(it) }, + ThinPaddingTextField( + state = postViewModel.forwardZapToEditting, + onTextChanged = postViewModel::onForwardZapTextChanged, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), label = { Text(text = stringRes(R.string.zap_split_search_and_add_user)) }, modifier = Modifier.fillMaxWidth(), placeholder = { @@ -135,9 +140,10 @@ fun ForwardZapTo( ) }, singleLine = true, - visualTransformation = - UrlUserTagTransformation( - MaterialTheme.colorScheme.primary, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, ), textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt index 011ec6cc59..f720f50af6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt @@ -20,15 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.zapsplits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable -import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.model.User @Stable interface IZapField { val forwardZapTo: MutableState> - val forwardZapToEditting: MutableState + val forwardZapToEditting: TextFieldState fun updateZapFromText() @@ -37,5 +37,5 @@ interface IZapField { sliderValue: Float, ) - fun updateZapForwardTo(newZapForwardTo: TextFieldValue) + fun onForwardZapTextChanged() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 630ed2cd3a..fe0ab36d11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.note.nip22Comments import android.content.Context import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -194,7 +194,7 @@ open class CommentPostViewModel : // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) override var forwardZapTo = mutableStateOf>(SplitBuilder()) - override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + override val forwardZapToEditting = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -389,7 +389,7 @@ open class CommentPostViewModel : val value = it.last().toFloatOrNull() ?: 0f forwardZapTo.value.addItem(user, value) } - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -713,7 +713,7 @@ open class CommentPostViewModel : wantsAnonymousPost = false forwardZapTo.value = SplitBuilder() - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() urlPreviews.reset() @@ -753,10 +753,9 @@ open class CommentPostViewModel : draftTag.newVersion() } - override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting.value = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text + override fun onForwardZapTextChanged() { + if (forwardZapToEditting.selection.collapsed) { + val lastWord = forwardZapToEditting.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestions?.processCurrentWord(lastWord) } @@ -770,7 +769,7 @@ open class CommentPostViewModel : urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 18181978ea..88ed8237f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send import android.content.Context import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -229,7 +229,7 @@ class ChatNewMessageViewModel : // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) override val forwardZapTo = mutableStateOf>(SplitBuilder()) - override val forwardZapToEditting = mutableStateOf(TextFieldValue("")) + override val forwardZapToEditting = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -347,7 +347,7 @@ class ChatNewMessageViewModel : } // don't support editing old-style splits. } - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -651,7 +651,7 @@ class ChatNewMessageViewModel : wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() urlPreviews.reset() @@ -724,10 +724,9 @@ class ChatNewMessageViewModel : draftTag.newVersion() } - override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting.value = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text + override fun onForwardZapTextChanged() { + if (forwardZapToEditting.selection.collapsed) { + val lastWord = forwardZapToEditting.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestions?.processCurrentWord(lastWord) } @@ -744,7 +743,7 @@ class ChatNewMessageViewModel : UserSuggestionAnchor.FORWARD_ZAPS -> { forwardZapTo.value.addItem(item) - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() } UserSuggestionAnchor.TO_USERS -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 1380c965b4..e9b59f6c10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send import android.content.Context import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -156,7 +156,7 @@ open class ChannelNewMessageViewModel : // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) var forwardZapTo by mutableStateOf>(SplitBuilder()) - var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + val forwardZapToEditting = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -240,7 +240,7 @@ open class ChannelNewMessageViewModel : } // don't support edditing old-style splits. } - forwardZapToEditting = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -561,7 +561,7 @@ open class ChannelNewMessageViewModel : wantsToAddGeoHash = false forwardZapTo = SplitBuilder() - forwardZapToEditting = TextFieldValue("") + forwardZapToEditting.clearText() userSuggestions?.reset() userSuggestionsMainMessage = null @@ -599,10 +599,9 @@ open class ChannelNewMessageViewModel : draftTag.newVersion() } - open fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text + open fun onForwardZapTextChanged() { + if (forwardZapToEditting.selection.collapsed) { + val lastWord = forwardZapToEditting.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestions?.processCurrentWord(lastWord) } @@ -615,7 +614,7 @@ open class ChannelNewMessageViewModel : it.replaceCurrentWord(message, lastWord, item) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.addItem(item) - forwardZapToEditting = TextFieldValue("") + forwardZapToEditting.clearText() } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index cc508bf0d7..7dbcb40d1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm import android.content.Context import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -181,7 +182,7 @@ class LongFormPostViewModel : // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) override var forwardZapTo = mutableStateOf>(SplitBuilder()) - override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + override val forwardZapToEditting = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -290,7 +291,7 @@ class LongFormPostViewModel : val value = it.last().toFloatOrNull() ?: 0f forwardZapTo.value.addItem(user, value) } - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -603,7 +604,7 @@ class LongFormPostViewModel : wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() userSuggestions?.reset() userSuggestionsMainMessage = null @@ -634,10 +635,9 @@ class LongFormPostViewModel : draftTag.newVersion() } - override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting.value = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text + override fun onForwardZapTextChanged() { + if (forwardZapToEditting.selection.collapsed) { + val lastWord = forwardZapToEditting.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestions?.processCurrentWord(lastWord) } @@ -650,7 +650,7 @@ class LongFormPostViewModel : userSuggestions.replaceCurrentWord(message, lastWord, item) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 14d7a13347..c020b029c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds import android.content.Context import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -168,7 +168,7 @@ open class NewProductViewModel : // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) override var forwardZapTo = mutableStateOf>(SplitBuilder()) - override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + override val forwardZapToEditting = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -266,7 +266,7 @@ open class NewProductViewModel : val value = it.last().toFloatOrNull() ?: 0f forwardZapTo.value.addItem(user, value) } - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localfowardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -491,7 +491,7 @@ open class NewProductViewModel : wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() urlPreviews.reset() @@ -535,10 +535,9 @@ open class NewProductViewModel : draftTag.newVersion() } - override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting.value = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text + override fun onForwardZapTextChanged() { + if (forwardZapToEditting.selection.collapsed) { + val lastWord = forwardZapToEditting.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestions?.processCurrentWord(lastWord) } @@ -552,7 +551,7 @@ open class NewProductViewModel : urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 1504473697..cba34a5171 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import android.content.Context import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -30,7 +31,6 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -283,7 +283,7 @@ open class ShortNotePostViewModel : // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) override var forwardZapTo = mutableStateOf>(SplitBuilder()) - override var forwardZapToEditting = mutableStateOf(TextFieldValue("")) + override val forwardZapToEditting = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -578,7 +578,7 @@ open class ShortNotePostViewModel : val value = it.last().toFloatOrNull() ?: 0f forwardZapTo.value.addItem(user, value) } - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -664,7 +664,7 @@ open class ShortNotePostViewModel : val value = it.last().toFloatOrNull() ?: 0f forwardZapTo.value.addItem(user, value) } - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -736,7 +736,7 @@ open class ShortNotePostViewModel : val value = it.last().toFloatOrNull() ?: 0f forwardZapTo.value.addItem(user, value) } - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -1238,7 +1238,7 @@ open class ShortNotePostViewModel : scheduledForSec = null forwardZapTo.value = SplitBuilder() - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() urlPreviews.reset() @@ -1283,10 +1283,9 @@ open class ShortNotePostViewModel : draftTag.newVersion() } - override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting.value = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text + override fun onForwardZapTextChanged() { + if (forwardZapToEditting.selection.collapsed) { + val lastWord = forwardZapToEditting.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestions?.processCurrentWord(lastWord) } @@ -1300,7 +1299,7 @@ open class ShortNotePostViewModel : urlPreviews.update(message.text.toString()) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 648a369000..93485b50a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat import android.content.Context import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -162,7 +162,7 @@ open class NestNewMessageViewModel : // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) var forwardZapTo by mutableStateOf>(SplitBuilder()) - var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + val forwardZapToEditting = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -246,7 +246,7 @@ open class NestNewMessageViewModel : } // don't support edditing old-style splits. } - forwardZapToEditting = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -480,7 +480,7 @@ open class NestNewMessageViewModel : wantsToAddGeoHash = false forwardZapTo = SplitBuilder() - forwardZapToEditting = TextFieldValue("") + forwardZapToEditting.clearText() userSuggestions?.reset() userSuggestionsMainMessage = null @@ -518,10 +518,9 @@ open class NestNewMessageViewModel : draftTag.newVersion() } - open fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text + open fun onForwardZapTextChanged() { + if (forwardZapToEditting.selection.collapsed) { + val lastWord = forwardZapToEditting.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestions?.processCurrentWord(lastWord) } @@ -534,7 +533,7 @@ open class NestNewMessageViewModel : it.replaceCurrentWord(message, lastWord, item) } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.addItem(item) - forwardZapToEditting = TextFieldValue("") + forwardZapToEditting.clearText() } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 43d8ac6ca6..655e058025 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessag import android.content.Context import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst @@ -180,7 +180,7 @@ class NewPublicMessageViewModel : // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) override val forwardZapTo = mutableStateOf>(SplitBuilder()) - override val forwardZapToEditting = mutableStateOf(TextFieldValue("")) + override val forwardZapToEditting = TextFieldState() // NSFW, Sensitive var wantsToMarkAsSensitive by mutableStateOf(false) @@ -282,7 +282,7 @@ class NewPublicMessageViewModel : } // don't support editing old-style splits. } - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() wantsForwardZapTo = localForwardZapTo.isNotEmpty() wantsToMarkAsSensitive = draftEvent.isSensitive() @@ -535,7 +535,7 @@ class NewPublicMessageViewModel : wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() urlPreviews.reset() @@ -587,10 +587,9 @@ class NewPublicMessageViewModel : draftTag.newVersion() } - override fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { - forwardZapToEditting.value = newZapForwardTo - if (newZapForwardTo.selection.collapsed) { - val lastWord = newZapForwardTo.text + override fun onForwardZapTextChanged() { + if (forwardZapToEditting.selection.collapsed) { + val lastWord = forwardZapToEditting.text.toString() userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS userSuggestions?.processCurrentWord(lastWord) } @@ -607,7 +606,7 @@ class NewPublicMessageViewModel : UserSuggestionAnchor.FORWARD_ZAPS -> { forwardZapTo.value.addItem(item) - forwardZapToEditting.value = TextFieldValue("") + forwardZapToEditting.clearText() } UserSuggestionAnchor.TO_USERS -> {