Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/app/components/custom_text_editing_controllers.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ class _ChatTitleState extends CustomState<ChatTitle, void, ConversationTileContr
final _title = controller.chatState.title.value ?? controller.chat.getTitle();

return RichText(
textDirection: getTextDirection(_title),
text: TextSpan(
children: MessageHelper.buildEmojiText(
_title,
Expand Down Expand Up @@ -286,6 +287,7 @@ class _ChatSubtitleState extends CustomState<ChatSubtitle, void, ConversationTil
}

final richText = RichText(
textDirection: getTextDirection(_subtitle),
text: subtitleSpan,
overflow: TextOverflow.ellipsis,
maxLines: maxLines,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class _ReplyBubbleState extends State<ReplyBubble> with ThemeHelpers {
style: context.textTheme.labelLarge!.copyWith(color: context.theme.colorScheme.onSurface),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textDirection: getTextDirection(text),
),
),
),
Expand Down Expand Up @@ -224,6 +225,7 @@ class _ReplyBubbleState extends State<ReplyBubble> with ThemeHelpers {
builder: (context, snapshot) {
if (snapshot.data != null) {
return RichText(
textDirection: getTextDirection(part.fullText),
text: TextSpan(
children: snapshot.data!,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ class _SendAnimationState extends CustomState<SendAnimation, SendData, Conversat
? const EdgeInsets.only(left: 3, right: 3)
: EdgeInsets.zero,
child: RichText(
textDirection: getTextDirection(message!.fullText),
text: TextSpan(
children: buildMessageSpans(context,
MessagePart(part: 0, text: message!.text, subject: message!.subject), message!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ class _TextBubbleState extends State<TextBubble> with ThemeHelpers {
return Transform.scale(scale: value1, alignment: Alignment.center, child: child);
},
child: RichText(
textDirection: getTextDirection(part.fullText),
text: TextSpan(
children: snapshot.data!,
),
Expand All @@ -194,6 +195,7 @@ class _TextBubbleState extends State<TextBubble> 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!,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,10 @@ class TextFieldComponentState extends State<TextFieldComponent> {
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,
Expand Down Expand Up @@ -256,7 +259,7 @@ class TextFieldComponentState extends State<TextFieldComponent> {
},
contentInsertionConfiguration:
ContentInsertionConfiguration(onContentInserted: onContentCommit),
),
)),
if (!isChatCreator &&
SettingsSvc.settings.enablePrivateAPI.value &&
SettingsSvc.settings.privateSubjectLine.value &&
Expand All @@ -271,7 +274,10 @@ class TextFieldComponentState extends State<TextFieldComponent> {
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,
Expand Down Expand Up @@ -428,7 +434,7 @@ class TextFieldComponentState extends State<TextFieldComponent> {
},
contentInsertionConfiguration:
ContentInsertionConfiguration(onContentInserted: onContentCommit),
);
));
}),
],
),
Expand Down
1 change: 1 addition & 0 deletions lib/helpers/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 5 additions & 1 deletion lib/helpers/types/extensions/extensions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions lib/helpers/ui/grapheme_caret.dart
Original file line number Diff line number Diff line change
@@ -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));
}
143 changes: 143 additions & 0 deletions lib/helpers/ui/text_direction_helpers.dart
Original file line number Diff line number Diff line change
@@ -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<TextEditingValue>` 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<TextDirectionBuilder> createState() => _TextDirectionBuilderState();
}

class _TextDirectionBuilderState extends State<TextDirectionBuilder> {
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<String, TextDirection> _directionCache = <String, TextDirection>{};

/// 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;
}