From 338080115fa8e011ba6893f9d7ee8b23de48cb5d Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 27 Apr 2026 00:13:36 +0200 Subject: [PATCH] fix(compose): also collapse on scope-exact replace; user-reported @Vitor Pamplona regression fix(compose): tighten @OptIn scope from @file to the object fix(compose): allow full-cover changes through; collapse only on partial overlap refactor(compose): hoist MENTION_REGEX, fast-path mention-free text, drop redundant scaffolding fix(compose): also collapse mention atomically on full-range non-empty replaces fix(compose): atomically delete the whole mention on partial-overlap edits fix(compose): opt-in ExperimentalFoundationApi in MentionPreservingInputTransformation --- .../MentionPreservingInputTransformation.kt | 92 +++++++++++-------- .../actions/UrlUserTagOutputTransformation.kt | 7 +- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt index a7c8cb073..608099835 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MentionPreservingInputTransformation.kt @@ -20,57 +20,71 @@ */ package com.vitorpamplona.amethyst.ui.actions +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldBuffer /** - * Rejects any edit that partially modifies a previously-complete Nostr mention - * (`@npub1…`, `nostr:npub1…`, `@nprofile1…`, `nostr:nprofile1…`). - * - * Background: when an `OutputTransformation` collapses an underlying npub into a - * short `@DisplayName`, Compose's auto-derived offset mapping uses identity inside - * the wedge. Some IMEs — notably Microsoft SwiftKey — re-enter "word edit mode" - * over a previously-committed display token after autocorrect-on-space, then issue - * `setComposingText` with a shortened version. Because the mapping is identity - * inside the wedge, the IME's replacement only overwrites the leading characters - * of the underlying bech32, leaving an orphan tail that no longer matches the - * mention regex. The wedge collapses, the orphan bech32 becomes visible, and the - * cursor lands in the middle of it. Gboard never enters this state because it - * does not recompose previously-committed tokens. - * - * This guard runs on every input change. If the change's original-text range - * partially intersects a complete mention but does not fully cover it, the entire - * change is reverted. The mention stays atomic; the IME re-reads the unchanged - * buffer and moves on. + * Matches a complete Nostr mention token: `@npub1…`, `nostr:npub1…`, + * `@nprofile1…`, `nostr:nprofile1…`. Shared with [UrlUserTagOutputTransformation] + * so the wedge it produces and the input-side guard below agree on what + * counts as a mention. */ -object MentionPreservingInputTransformation : InputTransformation { - private val mentionRegex = - Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") +internal val MENTION_REGEX = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") +/** + * Keeps Nostr mentions atomic against IME edits that would only modify part of + * the underlying bech32 (notably Microsoft SwiftKey, which re-enters word-edit + * mode over a previously-committed display token and rewrites a single word of + * a multi-word `@DisplayName`, leaving an orphan tail of the npub that no + * longer matches the mention regex). + * + * Three change shapes are blocked and routed to atomic-collapse: + * - Partial overlap (the change's `originalRange` overlaps a mention but does + * not fully cover it). + * - Scope-exact replace (the change's `originalRange` matches the mention's + * range exactly and the replacement is non-empty — covers IMEs that + * fully-cover-replace a multi-word display token with one of its words). + * + * Anything else passes through: + * - Pure delete that fully covers a mention (the user removed the chip). + * - A change whose range covers more than just the mention (select-all + type, + * select-paragraph + paste, etc.) — treated as deliberate broader edit. + */ +@OptIn(ExperimentalFoundationApi::class) +object MentionPreservingInputTransformation : InputTransformation { override fun TextFieldBuffer.transformInput() { val changeCount = changes.changeCount if (changeCount == 0) return - val original = originalText.toString() - if (original.isEmpty()) return + val original = originalText + // Cheap gate — most keystrokes happen in mention-free text. + if (!original.contains("npub1") && !original.contains("nprofile1")) return - val mentions = mentionRegex.findAll(original).toList() - if (mentions.isEmpty()) return - - for (i in 0 until changeCount) { - val origRange = changes.getOriginalRange(i) - val origStart = origRange.min - val origEnd = origRange.max - for (mention in mentions) { - val mStart = mention.range.first - val mEndExclusive = mention.range.last + 1 - val overlaps = origStart < mEndExclusive && origEnd > mStart - val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive - if (overlaps && !fullyCovers) { - revertAllChanges() - return + val touched = + MENTION_REGEX.findAll(original).firstOrNull { match -> + val mStart = match.range.first + val mEndExclusive = match.range.last + 1 + (0 until changeCount).any { i -> + val origRange = changes.getOriginalRange(i) + val origStart = origRange.min + val origEnd = origRange.max + val overlaps = origStart < mEndExclusive && origEnd > mStart + val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive + val isScopeExact = origStart == mStart && origEnd == mEndExclusive + val isPureDelete = changes.getRange(i).length == 0 + overlaps && (!fullyCovers || (isScopeExact && !isPureDelete)) } + } ?: return + + revertAllChanges() + val mEndExclusive = touched.range.last + 1 + val deleteEnd = + if (mEndExclusive < length && asCharSequence()[mEndExclusive].isWhitespace()) { + mEndExclusive + 1 + } else { + mEndExclusive } - } + replace(touched.range.first, deleteEnd, "") } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt index 9d5dff684..506a8d338 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -35,11 +35,8 @@ class UrlUserTagOutputTransformation( override fun TextFieldBuffer.transformOutput() { val text = asCharSequence().toString() - // Find all user mentions using regex and replace in reverse order - // so that earlier indices remain valid after replacements. - // Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1... - val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") - val matches = mentionRegex.findAll(text).toList().reversed() + // Reverse so earlier indices remain valid after each replace. + val matches = MENTION_REGEX.findAll(text).toList().reversed() // Phase 1: Replace all mentions (reverse order keeps indices valid for replace). // Collect replacement info because addStyle must be called after all text mutations.