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:
+53
-39
@@ -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, "")
|
||||
}
|
||||
}
|
||||
|
||||
+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.
|
||||
|
||||
Reference in New Issue
Block a user