Skip to content
Merged
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
1 change: 1 addition & 0 deletions lib/chat/custom_emoji.dart
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ class SelectableCustomEmojiView extends StatelessWidget {
softWrap: false,
overflow: TextOverflow.clip,
textAlign: TextAlign.center,
textScaler: TextScaler.noScaling,
style: TextStyle(
color: Colors.transparent,
fontSize: size * 0.9,
Expand Down
38 changes: 27 additions & 11 deletions lib/chat/message_bubble.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4323,14 +4323,33 @@ class _MessageBubbleState extends State<MessageBubble>
outgoing,
);
if (appendMeta) children.add(_metaSpan(outgoing));
final style = DefaultTextStyle.of(
context,
).style.merge(TextStyle(fontSize: effectiveFontSize, color: base));
final hasCustomEmoji = (entities ?? message.textEntities).any(
(e) =>
!e.isBlockQuote &&
e.isCustomEmoji &&
e.customEmojiId != null &&
e.offset < end &&
e.end > start,
);
final style = DefaultTextStyle.of(context).style.merge(
TextStyle(
fontSize: effectiveFontSize,
color: base,
height: hasCustomEmoji ? 1.0 : null,
),
);
return Builder(
builder: (context) => RichText(
textScaler: MediaQuery.textScalerOf(context),
maxLines: maxLines,
overflow: maxLines == null ? TextOverflow.clip : TextOverflow.fade,
textHeightBehavior: hasCustomEmoji
? const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
leadingDistribution: TextLeadingDistribution.even,
)
: null,
text: TextSpan(style: style, children: children),
selectionRegistrar: SelectionContainer.maybeOf(context),
selectionColor:
Expand Down Expand Up @@ -4597,14 +4616,11 @@ class _MessageBubbleState extends State<MessageBubble>
return [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 0.5),
child: SelectableCustomEmojiView(
id: customEmojiId,
fallbackText: segment,
size: math.max(20, fontSize * 1.15),
color: style.color ?? base,
),
child: SelectableCustomEmojiView(
id: customEmojiId,
fallbackText: segment,
size: math.max(20, (fontSize * 1.15).roundToDouble()),
color: style.color ?? base,
),
),
];
Expand Down
40 changes: 34 additions & 6 deletions lib/chat/telegram_rich_text.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math' as math;

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Expand Down Expand Up @@ -65,6 +67,7 @@ class _TelegramRichTextState extends State<TelegramRichText> {
String? _spanCacheText;
List<MessageTextEntity>? _spanCacheEntities;
TextStyle? _spanCacheBaseStyle;
TextScaler? _spanCacheTextScaler;
Color? _spanCacheLinkColor;
bool _spanCacheUnderlineLinks = false;
bool _spanCacheHashtagTap = false;
Expand Down Expand Up @@ -102,13 +105,24 @@ class _TelegramRichTextState extends State<TelegramRichText> {
_disposeRecognizers();
return _richTextWithQuoteBlocks(context, baseStyle, linkColor);
}
final hasCustomEmoji = _hasCustomEmoji();
final effectiveStyle = hasCustomEmoji
? baseStyle.copyWith(height: 1.0)
: baseStyle;
return RichText(
textScaler: MediaQuery.textScalerOf(context),
maxLines: widget.maxLines,
overflow: widget.overflow,
textHeightBehavior: hasCustomEmoji
? const TextHeightBehavior(
applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
leadingDistribution: TextLeadingDistribution.even,
)
: null,
text: TextSpan(
style: baseStyle,
children: _memoizedSpans(context, baseStyle, linkColor),
style: effectiveStyle,
children: _memoizedSpans(context, effectiveStyle, linkColor),
),
selectionRegistrar: SelectionContainer.maybeOf(context),
selectionColor:
Expand All @@ -127,6 +141,7 @@ class _TelegramRichTextState extends State<TelegramRichText> {
) {
final hashtagTap = widget.onHashtagTap != null;
final mentionTap = widget.onMentionTap != null;
final textScaler = MediaQuery.textScalerOf(context);
// A code span resolves the monospace family and the inline-code fill from
// the ambient theme, which no key below can see — leave those uncached.
final cacheable = !_display.entities.any(_isCodeEntity);
Expand All @@ -135,6 +150,7 @@ class _TelegramRichTextState extends State<TelegramRichText> {
_spanCacheText == _display.text &&
identical(_spanCacheEntities, _display.entities) &&
_spanCacheBaseStyle == baseStyle &&
_spanCacheTextScaler == textScaler &&
_spanCacheLinkColor == linkColor &&
_spanCacheUnderlineLinks == widget.underlineLinks &&
_spanCacheHashtagTap == hashtagTap &&
Expand All @@ -148,6 +164,7 @@ class _TelegramRichTextState extends State<TelegramRichText> {
_spanCacheText = _display.text;
_spanCacheEntities = _display.entities;
_spanCacheBaseStyle = baseStyle;
_spanCacheTextScaler = textScaler;
_spanCacheLinkColor = linkColor;
_spanCacheUnderlineLinks = widget.underlineLinks;
_spanCacheHashtagTap = hashtagTap;
Expand All @@ -173,6 +190,16 @@ class _TelegramRichTextState extends State<TelegramRichText> {
);
}

bool _hasCustomEmoji() {
final textLength = _display.text.length;
return _display.entities.any(
(entity) =>
entity.isCustomEmoji &&
entity.customEmojiId != null &&
_isEntityInRange(entity, textLength),
);
}

Widget _richTextWithQuoteBlocks(
BuildContext context,
TextStyle baseStyle,
Expand Down Expand Up @@ -369,16 +396,17 @@ class _TelegramRichTextState extends State<TelegramRichText> {
final customEmojiId = effectiveActive.customEmojiId;
if (customEmojiId != null) {
final ambientScaler = MediaQuery.textScalerOf(context);
final rawFontSize = style.fontSize ?? baseStyle.fontSize ?? 16;
return [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: SelectableCustomEmojiView(
id: customEmojiId,
fallbackText: segment,
size:
(style.fontSize ?? baseStyle.fontSize ?? 16) *
ambientScaler.scale(1.0) *
1.25,
size: math.max(
20,
(ambientScaler.scale(rawFontSize) * 1.15).roundToDouble(),
),
color: style.color,
),
),
Expand Down
148 changes: 148 additions & 0 deletions test/telegram_rich_text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mithka/chat/custom_emoji.dart';
import 'package:mithka/chat/message_bubble.dart';
import 'package:mithka/chat/telegram_rich_text.dart';
import 'package:mithka/tdlib/td_models.dart';
import 'package:mithka/theme/app_theme.dart';
import 'package:mithka/theme/theme_controller.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

Iterable<TextSpan> _textSpans(InlineSpan span) sync* {
if (span is! TextSpan) return;
Expand Down Expand Up @@ -155,4 +160,147 @@ void main() {
expect(copiedText, text);
await tester.pump(const Duration(milliseconds: 50));
});

testWidgets(
'MessageBubble lays out consecutive custom emoji collages flush horizontally and vertically',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final theme = ThemeController(prefs);
addTearDown(theme.dispose);

// Two rows of two custom emojis each: "😀😀\n😀😀"
// UTF-16 offsets:
// 0..2, 2..4, 4..5 ('\n'), 5..7, 7..9
const text = '😀😀\n😀😀';
final message = ChatMessage(
id: 501,
isOutgoing: false,
text: text,
date: 1000,
contentType: 'messageText',
textEntities: const [
MessageTextEntity(
offset: 0,
length: 2,
type: 'textEntityTypeCustomEmoji',
customEmojiId: 1001,
),
MessageTextEntity(
offset: 2,
length: 2,
type: 'textEntityTypeCustomEmoji',
customEmojiId: 1002,
),
MessageTextEntity(
offset: 5,
length: 2,
type: 'textEntityTypeCustomEmoji',
customEmojiId: 1003,
),
MessageTextEntity(
offset: 7,
length: 2,
type: 'textEntityTypeCustomEmoji',
customEmojiId: 1004,
),
],
);

await tester.pumpWidget(
ChangeNotifierProvider<ThemeController>.value(
value: theme,
child: MaterialApp(
theme: ThemeData(extensions: [AppColors.light]),
home: Scaffold(
body: MessageBubble(
message: message,
peerTitle: 'Collage',
isGroup: false,
),
),
),
),
);
await tester.pump();

final emojis = find.byType(SelectableCustomEmojiView);
expect(emojis, findsNWidgets(4));

final r0 = tester.getRect(emojis.at(0));
final r1 = tester.getRect(emojis.at(1));
final r2 = tester.getRect(emojis.at(2));
final r3 = tester.getRect(emojis.at(3));

// Horizontal adjacency: no gap between tile 0 and tile 1, or tile 2 and tile 3.
expect(r1.left, closeTo(r0.right, 0.01));
expect(r3.left, closeTo(r2.right, 0.01));

// Vertical adjacency: row 2 top meets row 1 bottom without extra line-height gap.
expect(r2.top, closeTo(r0.bottom, 0.01));
expect(r3.top, closeTo(r1.bottom, 0.01));

await tester.pump(const Duration(milliseconds: 50));
},
);

testWidgets(
'TelegramRichText lays out consecutive custom emoji collages flush horizontally and vertically',
(tester) async {
const text = '😀😀\n😀😀';
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: [AppColors.light]),
home: const Scaffold(
body: TelegramRichText(
text: text,
style: TextStyle(fontSize: 15, height: 1.35, color: Colors.black),
entities: [
MessageTextEntity(
offset: 0,
length: 2,
type: 'textEntityTypeCustomEmoji',
customEmojiId: 2001,
),
MessageTextEntity(
offset: 2,
length: 2,
type: 'textEntityTypeCustomEmoji',
customEmojiId: 2002,
),
MessageTextEntity(
offset: 5,
length: 2,
type: 'textEntityTypeCustomEmoji',
customEmojiId: 2003,
),
MessageTextEntity(
offset: 7,
length: 2,
type: 'textEntityTypeCustomEmoji',
customEmojiId: 2004,
),
],
),
),
),
);
await tester.pump();

final emojis = find.byType(SelectableCustomEmojiView);
expect(emojis, findsNWidgets(4));

final r0 = tester.getRect(emojis.at(0));
final r1 = tester.getRect(emojis.at(1));
final r2 = tester.getRect(emojis.at(2));
final r3 = tester.getRect(emojis.at(3));

expect(r1.left, closeTo(r0.right, 0.01));
expect(r3.left, closeTo(r2.right, 0.01));
expect(r2.top, closeTo(r0.bottom, 0.01));
expect(r3.top, closeTo(r1.bottom, 0.01));

await tester.pump(const Duration(milliseconds: 50));
},
);
}
Loading