From f3e671ccaf54d62fa3c5682038aab4c064195cc8 Mon Sep 17 00:00:00 2001 From: kfatehi Date: Sun, 28 Jun 2026 15:44:25 -0700 Subject: [PATCH 1/4] Add right-to-left (RTL) text support Rendering: message bubbles, reply bubbles/previews, conversation tiles, the send-animation bubble, and embedded notification/reaction text render with the correct paragraph direction. Direction via getTextDirection() (UAX#9 first-strong over runes); embedded text uses a first-strong isolate. Input: the compose and subject fields go RTL via TextDirectionBuilder, which rebuilds only when the first-strong direction flips (so caret dragging works). No per-keystroke direction-forcing, no grapheme-repair. Known issue (kept draft): intermittent emoji "??" corruption while composing, not yet reliably reproduced. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../widgets/tile/conversation_tile.dart | 2 + .../widgets/message/reply/reply_bubble.dart | 2 + .../widgets/message/send_animation.dart | 1 + .../widgets/message/text/text_bubble.dart | 2 + .../text_field/text_field_component.dart | 14 ++- lib/helpers/helpers.dart | 1 + lib/helpers/types/extensions/extensions.dart | 6 +- lib/helpers/ui/text_direction_helpers.dart | 87 +++++++++++++++++++ 8 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 lib/helpers/ui/text_direction_helpers.dart 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 f58cf49cc6..a9e78fae05 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart @@ -191,6 +191,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 c8fc7cb04e..49b83ffa30 100644 --- a/lib/app/layouts/conversation_view/widgets/message/send_animation.dart +++ b/lib/app/layouts/conversation_view/widgets/message/send_animation.dart @@ -336,6 +336,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 40b949f52e..203614941f 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 f993961206..63652dfd50 100644 --- a/lib/helpers/helpers.dart +++ b/lib/helpers/helpers.dart @@ -18,3 +18,4 @@ export 'ui/reaction_helpers.dart'; export 'ui/theme_helpers.dart'; export 'ui/dialog_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 09cdd92669..ba06cc2785 100644 --- a/lib/helpers/types/extensions/extensions.dart +++ b/lib/helpers/types/extensions/extensions.dart @@ -411,7 +411,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/text_direction_helpers.dart b/lib/helpers/ui/text_direction_helpers.dart new file mode 100644 index 0000000000..97116ce3bf --- /dev/null +++ b/lib/helpers/ui/text_direction_helpers.dart @@ -0,0 +1,87 @@ +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); +} + +/// 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. +/// +/// 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 getTextDirection(String? text) { + if (text == null) return TextDirection.ltr; + 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; +} From 19a54e64d4999f748938245f18eb68f656262ff9 Mon Sep 17 00:00:00 2001 From: kfatehi Date: Mon, 29 Jun 2026 01:34:02 -0700 Subject: [PATCH 2/4] Fix emoji ?? corruption: clamp caret off surrogate-pair interior Snap a collapsed caret (or selection endpoint) off any UTF-16 surrogate-pair interior in the compose controller (SpellCheckTextEditingController.set value, both exit paths) before committing the value, so a subsequent edit can't split an emoji into lone surrogates -- which the Android text-input channel encodes as '?' (the '??' corruption) and which crash ParagraphBuilder on paint. App-side equivalent of the framework fix in flutter/flutter#188713 (PR flutter/flutter#188719); needs no Flutter upgrade and is scoped to the compose field. Source-only per this branch's convention; logic unit-tested in the OpenBubbles port (OpenBubbles/openbubbles-app#213). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../custom_text_editing_controllers.dart | 7 +++++ lib/helpers/ui/grapheme_caret.dart | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 lib/helpers/ui/grapheme_caret.dart 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/helpers/ui/grapheme_caret.dart b/lib/helpers/ui/grapheme_caret.dart new file mode 100644 index 0000000000..22dee9a826 --- /dev/null +++ b/lib/helpers/ui/grapheme_caret.dart @@ -0,0 +1,29 @@ +import 'package:flutter/services.dart' show TextEditingValue, TextSelection; + +/// Returns [offset] moved back to the start of 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. +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)); +} From 40a31ae80c0db5027b39b7398809be405054cf59 Mon Sep 17 00:00:00 2001 From: kfatehi Date: Mon, 29 Jun 2026 03:16:12 -0700 Subject: [PATCH 3/4] Snap caret past surrogate-pair end so RTL backspace deletes the emoji The compose-field caret clamp snapped a caret that lands inside an emoji's surrogate pair to the pair start (before the emoji). In RTL a tap aiming for the spot after a trailing emoji lands mid-glyph and got yanked before it, so backspace deleted the adjacent space instead of the emoji and the emoji could not be removed. Snap to the pair end (offset + 1) instead, so the caret lands after the emoji and backspace deletes the whole emoji. It is still a boundary, so the next edit cannot split the pair and the "??" corruption stays fixed. Verified on-device (Android / Gboard, forced-RTL). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FrEin3bnyFrXaxSmL9iLQM --- lib/helpers/ui/grapheme_caret.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/helpers/ui/grapheme_caret.dart b/lib/helpers/ui/grapheme_caret.dart index 22dee9a826..05a69a7160 100644 --- a/lib/helpers/ui/grapheme_caret.dart +++ b/lib/helpers/ui/grapheme_caret.dart @@ -1,13 +1,21 @@ import 'package:flutter/services.dart' show TextEditingValue, TextSelection; -/// Returns [offset] moved back to the start of a UTF-16 surrogate pair when it falls between the +/// 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; + return insidePair ? offset + 1 : offset; } /// Snaps both endpoints of [value]'s selection off any UTF-16 surrogate-pair interior. From af766d289be0f54314ac6cd4207c19a3a80184b3 Mon Sep 17 00:00:00 2001 From: kfatehi Date: Thu, 6 Aug 2026 14:52:54 -0700 Subject: [PATCH 4/4] Memoize getTextDirection so message rebuilds stop rescanning text getTextDirection is called from inside build in text_bubble.dart, reply_bubble.dart, send_animation.dart and the conversation tile, so it re-runs on every Obx/setState rebuild rather than only when the text changes (#3049 review). Detection early-exits on the first strongly-directional character, so cost tracks how far in that character is, not message length: ordinary text is 8-12 ns and a 60-tile frame doing 120 detections is 0.8 us, about 0.005% of a 16.7 ms frame. But text with no strong character anywhere is scanned to the end -- an all-neutral 200-unit message is 683-874 ns and an emoji-only message 1008-1060 ns, 60-100x worse. Memoize on the text itself. The detection moves unchanged into a private _detectTextDirection and every call site is untouched. No invalidation anywhere: the direction is a pure function of the text, so an entry cannot go stale for its own key. After: all-neutral and emoji-only both drop to ~10 ns flat, ordinary text is unchanged within noise. On a workload of only early-exit strings the memo is neutral, and a miss costs a scan plus an insert, so all-distinct strings are a small net loss -- bounded by the 512 cap. Deliberately not an LRU. Promoting a key on every hit costs a remove plus a re-insert, measured at 34.2 ns/hit against the 13.3 ns scan it replaces, which would make the common case slower than having no cache at all. Insertion-order eviction keeps the hit path to a single lookup (5.9-10.7 ns); a Dart map literal is insertion-ordered, so keys.first is the oldest and eviction is O(1). With a 512 cap and a 120-200 string working set both policies evict the same keys anyway. Keyed on the string value rather than identity, because Message.fullText and MessagePart.fullText allocate a new String on every call. Hashing costs well under 0.1 ns/unit against the scan's ~3.75 ns/unit, so a lookup on a fresh key still beats a scan on a fresh key (42.0 vs 802.1 ns). The helper is byte-identical to the OpenBubbles copy, where it is covered by 8 tests including an eviction test that turns red under a move-to-end LRU. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01REhKAmLTibRLmoakxNp6CW --- lib/helpers/ui/text_direction_helpers.dart | 60 +++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/helpers/ui/text_direction_helpers.dart b/lib/helpers/ui/text_direction_helpers.dart index 97116ce3bf..1e01e7b474 100644 --- a/lib/helpers/ui/text_direction_helpers.dart +++ b/lib/helpers/ui/text_direction_helpers.dart @@ -53,15 +53,71 @@ class _TextDirectionBuilderState extends State { 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 getTextDirection(String? text) { - if (text == null) return TextDirection.ltr; +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.