diff --git a/lib/app/components/custom_text_editing_controllers.dart b/lib/app/components/custom_text_editing_controllers.dart index cdc16775e9..dbe2969497 100644 --- a/lib/app/components/custom_text_editing_controllers.dart +++ b/lib/app/components/custom_text_editing_controllers.dart @@ -1,6 +1,7 @@ import "dart:math"; import "package:bluebubbles/helpers/helpers.dart"; +import "package:bluebubbles/helpers/ui/grapheme_caret.dart"; import "package:bluebubbles/database/models.dart"; import "package:bluebubbles/services/services.dart"; import 'package:bluebubbles/utils/emoji.dart'; @@ -92,6 +93,9 @@ class SpellCheckTextEditingController extends TextEditingController { _mistakeTooltip?.remove(); _mistakeTooltip = null; } + // Never leave the caret inside a UTF-16 surrogate pair (app-side fix for + // flutter/flutter#188713) — a following edit would split the emoji into "??". + newValue = snapSelectionOffSurrogatePairs(newValue); super.value = newValue; return; } @@ -138,6 +142,9 @@ class SpellCheckTextEditingController extends TextEditingController { } } + // Never leave the caret inside a UTF-16 surrogate pair (app-side fix for + // flutter/flutter#188713) — a following edit would split the emoji into "??". + newValue = snapSelectionOffSurrogatePairs(newValue); super.value = newValue; } diff --git a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart index 7e4f300f40..6b2519ab71 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart @@ -203,6 +203,7 @@ class _ChatTitleState extends CustomState with ThemeHelpers { style: context.textTheme.labelLarge!.copyWith(color: context.theme.colorScheme.onSurface), maxLines: 2, overflow: TextOverflow.ellipsis, + textDirection: getTextDirection(text), ), ), ), @@ -224,6 +225,7 @@ class _ReplyBubbleState extends State with ThemeHelpers { builder: (context, snapshot) { if (snapshot.data != null) { return RichText( + textDirection: getTextDirection(part.fullText), text: TextSpan( children: snapshot.data!, ), diff --git a/lib/app/layouts/conversation_view/widgets/message/send_animation.dart b/lib/app/layouts/conversation_view/widgets/message/send_animation.dart index 4c1eacafca..c1a1c7bbb4 100644 --- a/lib/app/layouts/conversation_view/widgets/message/send_animation.dart +++ b/lib/app/layouts/conversation_view/widgets/message/send_animation.dart @@ -344,6 +344,7 @@ class _SendAnimationState extends CustomState with ThemeHelpers { return Transform.scale(scale: value1, alignment: Alignment.center, child: child); }, child: RichText( + textDirection: getTextDirection(part.fullText), text: TextSpan( children: snapshot.data!, ), @@ -194,6 +195,7 @@ class _TextBubbleState extends State with ThemeHelpers { padding: message.fullText.length == 1 ? const EdgeInsets.only(left: 3, right: 3) : EdgeInsets.zero, child: RichText( + textDirection: getTextDirection(part.fullText), text: TextSpan( children: snapshot.data!, ), diff --git a/lib/app/layouts/conversation_view/widgets/text_field/text_field_component.dart b/lib/app/layouts/conversation_view/widgets/text_field/text_field_component.dart index 87c38b97ff..39a2c21ef4 100644 --- a/lib/app/layouts/conversation_view/widgets/text_field/text_field_component.dart +++ b/lib/app/layouts/conversation_view/widgets/text_field/text_field_component.dart @@ -219,7 +219,10 @@ class TextFieldComponentState extends State { SettingsSvc.settings.enablePrivateAPI.value && SettingsSvc.settings.privateSubjectLine.value && chat!.isIMessage) - TextField( + TextDirectionBuilder( + controller: subjController!, + builder: (context, direction) => TextField( + textDirection: direction, textCapitalization: TextCapitalization.sentences, focusNode: controller!.subjectFocusNode, autocorrect: true, @@ -256,7 +259,7 @@ class TextFieldComponentState extends State { }, contentInsertionConfiguration: ContentInsertionConfiguration(onContentInserted: onContentCommit), - ), + )), if (!isChatCreator && SettingsSvc.settings.enablePrivateAPI.value && SettingsSvc.settings.privateSubjectLine.value && @@ -271,7 +274,10 @@ class TextFieldComponentState extends State { Obx(() { final chatTitle = chat == null ? null : (ChatsSvc.getChatState(chat!.guid)?.title.value ?? chat!.getTitle()); - return TextField( + return TextDirectionBuilder( + controller: txtController, + builder: (context, direction) => TextField( + textDirection: direction, textCapitalization: TextCapitalization.sentences, focusNode: controller?.focusNode ?? focusNode, autocorrect: true, @@ -428,7 +434,7 @@ class TextFieldComponentState extends State { }, contentInsertionConfiguration: ContentInsertionConfiguration(onContentInserted: onContentCommit), - ); + )); }), ], ), diff --git a/lib/helpers/helpers.dart b/lib/helpers/helpers.dart index cc3cd0e65e..eb61bff369 100644 --- a/lib/helpers/helpers.dart +++ b/lib/helpers/helpers.dart @@ -19,3 +19,4 @@ export 'ui/theme_helpers.dart'; export 'ui/dialog_helpers.dart'; export 'ui/findmy_helpers.dart'; export 'ui/ui_helpers.dart'; +export 'ui/text_direction_helpers.dart'; diff --git a/lib/helpers/types/extensions/extensions.dart b/lib/helpers/types/extensions/extensions.dart index 2d6bcbd29a..1bd66f0d1a 100644 --- a/lib/helpers/types/extensions/extensions.dart +++ b/lib/helpers/types/extensions/extensions.dart @@ -447,7 +447,11 @@ extension MessageNotificationExtension on Message { (associatedMessage.text ?? ""); } } - return '$reactionSender $verb ${attachment ? "" : "“"}$messageText${attachment ? "" : "”"}'; + // Wrap the quoted message in a Unicode First-Strong Isolate (U+2068 ... U+2069) + // so an RTL message (e.g. Farsi) embedded in this LTR sentence renders as a + // self-contained bidi unit; keeps trailing emoji/punctuation on the correct + // side of the quotes instead of escaping into the surrounding text. + return '$reactionSender $verb ${attachment ? "" : "\u2068“"}$messageText${attachment ? "" : "”\u2069"}'; } } // if we can't fetch the associated message for some reason diff --git a/lib/helpers/ui/grapheme_caret.dart b/lib/helpers/ui/grapheme_caret.dart new file mode 100644 index 0000000000..05a69a7160 --- /dev/null +++ b/lib/helpers/ui/grapheme_caret.dart @@ -0,0 +1,37 @@ +import 'package:flutter/services.dart' show TextEditingValue, TextSelection; + +/// Returns [offset] moved forward to just past a UTF-16 surrogate pair when it falls between the +/// pair's two code units; otherwise returns it unchanged. The result is never inside a pair. +/// +/// We snap to the end of the pair (offset + 1) rather than the start (offset - 1) so the caret +/// lands *after* the emoji. In RTL a tap aiming for the spot after a trailing emoji (visually to +/// its left) often lands mid-glyph; snapping to the start would yank the caret before the emoji, +/// where backspace deletes the wrong character (e.g. the preceding space) and the emoji can never +/// be removed. Snapping past the pair keeps a tap on a trailing emoji able to backspace it, and is +/// still a clean boundary so the next edit cannot split the pair. offset + 1 is always valid here: +/// a low surrogate at [offset] guarantees offset < text.length. +int _offsetOffSurrogatePair(String text, int offset) { + if (offset <= 0 || offset >= text.length) return offset; + final int prev = text.codeUnitAt(offset - 1); + final int next = text.codeUnitAt(offset); + final bool insidePair = prev >= 0xD800 && prev <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF; + return insidePair ? offset + 1 : offset; +} + +/// Snaps both endpoints of [value]'s selection off any UTF-16 surrogate-pair interior. +/// +/// A caret left between the two halves of an emoji's surrogate pair lets the next edit split the +/// pair into lone surrogates. On Android the text-input channel then encodes each lone half as +/// `?` (the user-visible "??" corruption), and the text painter throws +/// "string is not well-formed UTF-16". This is the app-side equivalent of the framework fix in +/// flutter/flutter#188713 (PR flutter/flutter#188719); applying it in the compose controller fixes +/// the corruption without requiring a Flutter SDK upgrade, and is scoped to this field only. +TextEditingValue snapSelectionOffSurrogatePairs(TextEditingValue value) { + final TextSelection selection = value.selection; + if (!selection.isValid) return value; + final String text = value.text; + final int base = _offsetOffSurrogatePair(text, selection.baseOffset); + final int extent = _offsetOffSurrogatePair(text, selection.extentOffset); + if (base == selection.baseOffset && extent == selection.extentOffset) return value; + return value.copyWith(selection: selection.copyWith(baseOffset: base, extentOffset: extent)); +} diff --git a/lib/helpers/ui/text_direction_helpers.dart b/lib/helpers/ui/text_direction_helpers.dart new file mode 100644 index 0000000000..1e01e7b474 --- /dev/null +++ b/lib/helpers/ui/text_direction_helpers.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; + +/// Rebuilds [builder] with the current text direction of [controller], but ONLY +/// when that direction actually flips — never on plain keystroke or selection +/// changes. +/// +/// A `ValueListenableBuilder` on the controller would rebuild +/// the child on every value change, and since the selection is part of the value, +/// that rebuild lands mid-cursor-drag and cancels the gesture (the caret "lets go" +/// after one step). Listening for direction changes only keeps the child +/// (e.g. a TextField/EditableText) stable during normal editing. +class TextDirectionBuilder extends StatefulWidget { + const TextDirectionBuilder({super.key, required this.controller, required this.builder}); + + final TextEditingController controller; + final Widget Function(BuildContext context, TextDirection direction) builder; + + @override + State createState() => _TextDirectionBuilderState(); +} + +class _TextDirectionBuilderState extends State { + late TextDirection _direction = getTextDirection(widget.controller.text); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onChanged); + } + + void _onChanged() { + final next = getTextDirection(widget.controller.text); + if (next != _direction) setState(() => _direction = next); + } + + @override + void didUpdateWidget(TextDirectionBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onChanged); + widget.controller.addListener(_onChanged); + _onChanged(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.builder(context, _direction); +} + +/// Upper bound on [_directionCache]. A conversation list renders on the order of +/// 60 tiles (a title and a subtitle each) and a conversation view on the order of +/// 100 message parts, so 512 holds a full working set with headroom while +/// bounding how many strings the cache keeps alive. +const int _directionCacheCapacity = 512; + +/// Memoizes [getTextDirection]. Safe with no invalidation: the direction is a +/// pure function of the text, so an entry cannot go stale for its own key. +/// +/// A Dart map literal is insertion-ordered, so the oldest key is `keys.first` and +/// eviction is O(1). Deliberately NOT a move-to-end LRU: promoting a key on every +/// hit costs a `remove` plus a re-insert, measured at 34 ns/hit against the 13 ns +/// scan it would replace for ordinary text — a true LRU makes the common case +/// slower than having no cache at all. Insertion-order eviction keeps the hit +/// path to a single lookup (~6-11 ns). +final Map _directionCache = {}; + +/// Clears the memo table. Tests only — it never needs invalidating in production +/// because [_detectTextDirection] is pure. +@visibleForTesting +void clearTextDirectionCache() => _directionCache.clear(); + +/// Number of memoized entries. Tests only. +@visibleForTesting +int get textDirectionCacheLength => _directionCache.length; + +/// The bound enforced on the memo table. Tests only. +@visibleForTesting +int get textDirectionCacheCapacity => _directionCacheCapacity; + +/// Whether [text] is currently memoized. Tests only — lets the eviction test +/// assert *which* key was dropped, not merely how many remain. +@visibleForTesting +bool textDirectionCacheContains(String text) => _directionCache.containsKey(text); + +/// Detects the paragraph direction of [text] from its first strongly-directional +/// character (UAX#9 "first strong" heuristic), so RTL languages (Farsi, Arabic, +/// Hebrew) render and align correctly. +/// +/// Memoized, because the message widgets call this from inside `build` — the +/// `RichText` in a message bubble, a reply bubble, the send animation and the +/// conversation tile — so it re-runs on every rebuild, not only when the text +/// changes. Detection early-exits on the first strongly-directional character, +/// which is cheap for ordinary text (~13 ns), but text made only of neutral +/// characters (digits, punctuation, an emoji-only message) has no such character +/// and is scanned to the end: ~640 ns for 200 units, ~1150 ns for an emoji-only +/// message. The memo turns that into one map lookup. +TextDirection getTextDirection(String? text) { + if (text == null || text.isEmpty) return TextDirection.ltr; + final TextDirection? cached = _directionCache[text]; + if (cached != null) return cached; + final TextDirection direction = _detectTextDirection(text); + _directionCache[text] = direction; + if (_directionCache.length > _directionCacheCapacity) { + _directionCache.remove(_directionCache.keys.first); + } + return direction; +} + +/// The uncached detection itself. +/// +/// Implemented over runes rather than intl's [Bidi.startsWithRtl], which +/// misclassifies leading emoji as LTR (their UTF-16 surrogates fall inside its +/// LTR character ranges). +TextDirection _detectTextDirection(String text) { + for (final rune in text.runes) { + // Strong RTL: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan..., + // Arabic/Hebrew presentation forms, and the historic/supplemental RTL planes. + if ((rune >= 0x0590 && rune <= 0x08FF) || + (rune >= 0xFB1D && rune <= 0xFDFF) || + (rune >= 0xFE70 && rune <= 0xFEFF) || + (rune >= 0x10800 && rune <= 0x10FFF) || + (rune >= 0x1E800 && rune <= 0x1EFFF)) { + return TextDirection.rtl; + } + // Strong LTR: Latin letters and the LTR script blocks below/above the RTL + // ranges. Everything else (digits, punctuation, emoji, symbols) is treated + // as neutral and skipped. + if ((rune >= 0x41 && rune <= 0x5A) || + (rune >= 0x61 && rune <= 0x7A) || + (rune >= 0x00C0 && rune <= 0x058F) || + (rune >= 0x0900 && rune <= 0x1FFF) || + (rune >= 0x2C00 && rune <= 0xD7FF)) { + return TextDirection.ltr; + } + } + return TextDirection.ltr; +}