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
This commit is contained in:
davotoula
2026-04-27 00:13:36 +02:00
parent 7ad54ac33b
commit 338080115f
2 changed files with 55 additions and 44 deletions
@@ -20,57 +20,71 @@
*/ */
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer import androidx.compose.foundation.text.input.TextFieldBuffer
/** /**
* Rejects any edit that partially modifies a previously-complete Nostr mention * Matches a complete Nostr mention token: `@npub1…`, `nostr:npub1…`,
* (`@npub1…`, `nostr:npub1…`, `@nprofile1…`, `nostr:nprofile1…`). * `@nprofile1…`, `nostr:nprofile1…`. Shared with [UrlUserTagOutputTransformation]
* * so the wedge it produces and the input-side guard below agree on what
* Background: when an `OutputTransformation` collapses an underlying npub into a * counts as a mention.
* 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 { internal val MENTION_REGEX = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
private val mentionRegex =
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() { override fun TextFieldBuffer.transformInput() {
val changeCount = changes.changeCount val changeCount = changes.changeCount
if (changeCount == 0) return if (changeCount == 0) return
val original = originalText.toString() val original = originalText
if (original.isEmpty()) return // Cheap gate — most keystrokes happen in mention-free text.
if (!original.contains("npub1") && !original.contains("nprofile1")) return
val mentions = mentionRegex.findAll(original).toList() val touched =
if (mentions.isEmpty()) return MENTION_REGEX.findAll(original).firstOrNull { match ->
val mStart = match.range.first
for (i in 0 until changeCount) { val mEndExclusive = match.range.last + 1
val origRange = changes.getOriginalRange(i) (0 until changeCount).any { i ->
val origStart = origRange.min val origRange = changes.getOriginalRange(i)
val origEnd = origRange.max val origStart = origRange.min
for (mention in mentions) { val origEnd = origRange.max
val mStart = mention.range.first val overlaps = origStart < mEndExclusive && origEnd > mStart
val mEndExclusive = mention.range.last + 1 val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive
val overlaps = origStart < mEndExclusive && origEnd > mStart val isScopeExact = origStart == mStart && origEnd == mEndExclusive
val fullyCovers = origStart <= mStart && origEnd >= mEndExclusive val isPureDelete = changes.getRange(i).length == 0
if (overlaps && !fullyCovers) { overlaps && (!fullyCovers || (isScopeExact && !isPureDelete))
revertAllChanges()
return
} }
} ?: 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, "")
} }
} }
@@ -35,11 +35,8 @@ class UrlUserTagOutputTransformation(
override fun TextFieldBuffer.transformOutput() { override fun TextFieldBuffer.transformOutput() {
val text = asCharSequence().toString() val text = asCharSequence().toString()
// Find all user mentions using regex and replace in reverse order // Reverse so earlier indices remain valid after each replace.
// so that earlier indices remain valid after replacements. val matches = MENTION_REGEX.findAll(text).toList().reversed()
// 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()
// Phase 1: Replace all mentions (reverse order keeps indices valid for replace). // Phase 1: Replace all mentions (reverse order keeps indices valid for replace).
// Collect replacement info because addStyle must be called after all text mutations. // Collect replacement info because addStyle must be called after all text mutations.