diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt new file mode 100644 index 000000000..a7c8cb073 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer + +/** + * Rejects any edit that partially modifies a previously-complete Nostr mention + * (`@npub1…`, `nostr:npub1…`, `@nprofile1…`, `nostr:nprofile1…`). + * + * Background: when an `OutputTransformation` collapses an underlying npub into a + * short `@DisplayName`, Compose's auto-derived offset mapping uses identity inside + * the wedge. Some IMEs — notably Microsoft SwiftKey — re-enter "word edit mode" + * over a previously-committed display token after autocorrect-on-space, then issue + * `setComposingText` with a shortened version. Because the mapping is identity + * inside the wedge, the IME's replacement only overwrites the leading characters + * of the underlying bech32, leaving an orphan tail that no longer matches the + * mention regex. The wedge collapses, the orphan bech32 becomes visible, and the + * cursor lands in the middle of it. Gboard never enters this state because it + * does not recompose previously-committed tokens. + * + * This guard runs on every input change. If the change's original-text range + * partially intersects a complete mention but does not fully cover it, the entire + * change is reverted. The mention stays atomic; the IME re-reads the unchanged + * buffer and moves on. + */ +object MentionPreservingInputTransformation : InputTransformation { + private val mentionRegex = + Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") + + override fun TextFieldBuffer.transformInput() { + val changeCount = changes.changeCount + if (changeCount == 0) return + + val original = originalText.toString() + if (original.isEmpty()) return + + val mentions = mentionRegex.findAll(original).toList() + if (mentions.isEmpty()) return + + for (i in 0 until changeCount) { + val origRange = changes.getOriginalRange(i) + val origStart = origRange.min + val origEnd = origRange.max + for (mention in mentions) { + val mStart = mention.range.first + val mEndExclusive = mention.range.last + 1 + val overlaps = origStart < mEndExclusive && origEnd > mStart + val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive + if (overlaps && !fullyCovers) { + revertAllChanges() + return + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt index 36cdecdcd..d25007493 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/messagefield/MessageField.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.stringRes @@ -70,6 +71,7 @@ fun MessageField( state = viewModel.message, onTextChanged = viewModel::onMessageChanged, onContentReceived = onContentReceived, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 270113bd9..af68085c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -530,6 +531,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.toUsers, onTextChanged = postViewModel::onToUsersChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .weight(1f) @@ -572,6 +574,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.subject, onTextChanged = { postViewModel.onSubjectChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index da9d8ed21..51b5bbd3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -53,6 +53,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -203,6 +204,7 @@ fun EditField( ThinPaddingTextField( state = channelScreenModel.message, onTextChanged = { channelScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = PostKeyboard, shape = EditFieldBorder, modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 1c2669b35..c9e63c90f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -116,6 +117,7 @@ fun EditFieldRow( ThinPaddingTextField( state = channelScreenModel.message, onTextChanged = { channelScreenModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 87977bdc4..2eb873924 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -381,6 +382,7 @@ private fun MarkdownPostScreenBody( ThinPaddingTextField( state = postViewModel.message, onTextChanged = postViewModel::onMessageChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .fillMaxWidth() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt index c13346b81..52dfc08ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/SellProduct.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField @@ -111,6 +112,7 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( state = postViewModel.title, onTextChanged = { postViewModel.onTitleChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -311,6 +313,7 @@ fun SellProduct(postViewModel: NewProductViewModel) { ThinPaddingTextField( state = postViewModel.locationText, onTextChanged = { postViewModel.onLocationChanged() }, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 1519ac398..9f284b4c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -417,6 +418,7 @@ fun SendDirectMessageTo( ThinPaddingTextField( state = postViewModel.toUsers, onTextChanged = postViewModel::onToUsersChanged, + inputTransformation = MentionPreservingInputTransformation, modifier = Modifier .weight(1f)