From 98c5aac4c8692172ff525890dd73617d4ec2e918 Mon Sep 17 00:00:00 2001 From: TiaraBasori Date: Fri, 16 Oct 2026 20:20:36 +0800 Subject: [PATCH] fix(chat): remove inter-tile spacing in custom emoji collages --- lib/chat/custom_emoji.dart | 1 + lib/chat/message_bubble.dart | 38 +++++--- lib/chat/telegram_rich_text.dart | 40 ++++++-- test/telegram_rich_text_test.dart | 148 ++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 17 deletions(-) diff --git a/lib/chat/custom_emoji.dart b/lib/chat/custom_emoji.dart index a8ae2c46b..bd4b66281 100644 --- a/lib/chat/custom_emoji.dart +++ b/lib/chat/custom_emoji.dart @@ -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, diff --git a/lib/chat/message_bubble.dart b/lib/chat/message_bubble.dart index fc5fb79d6..8d208b2ff 100644 --- a/lib/chat/message_bubble.dart +++ b/lib/chat/message_bubble.dart @@ -4323,14 +4323,33 @@ class _MessageBubbleState extends State 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: @@ -4597,14 +4616,11 @@ class _MessageBubbleState extends State 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, ), ), ]; diff --git a/lib/chat/telegram_rich_text.dart b/lib/chat/telegram_rich_text.dart index 0ce30650d..3c9ec12f6 100644 --- a/lib/chat/telegram_rich_text.dart +++ b/lib/chat/telegram_rich_text.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -65,6 +67,7 @@ class _TelegramRichTextState extends State { String? _spanCacheText; List? _spanCacheEntities; TextStyle? _spanCacheBaseStyle; + TextScaler? _spanCacheTextScaler; Color? _spanCacheLinkColor; bool _spanCacheUnderlineLinks = false; bool _spanCacheHashtagTap = false; @@ -102,13 +105,24 @@ class _TelegramRichTextState extends State { _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: @@ -127,6 +141,7 @@ class _TelegramRichTextState extends State { ) { 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); @@ -135,6 +150,7 @@ class _TelegramRichTextState extends State { _spanCacheText == _display.text && identical(_spanCacheEntities, _display.entities) && _spanCacheBaseStyle == baseStyle && + _spanCacheTextScaler == textScaler && _spanCacheLinkColor == linkColor && _spanCacheUnderlineLinks == widget.underlineLinks && _spanCacheHashtagTap == hashtagTap && @@ -148,6 +164,7 @@ class _TelegramRichTextState extends State { _spanCacheText = _display.text; _spanCacheEntities = _display.entities; _spanCacheBaseStyle = baseStyle; + _spanCacheTextScaler = textScaler; _spanCacheLinkColor = linkColor; _spanCacheUnderlineLinks = widget.underlineLinks; _spanCacheHashtagTap = hashtagTap; @@ -173,6 +190,16 @@ class _TelegramRichTextState extends State { ); } + 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, @@ -369,16 +396,17 @@ class _TelegramRichTextState extends State { 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, ), ), diff --git a/test/telegram_rich_text_test.dart b/test/telegram_rich_text_test.dart index 26668c043..c8d1bbde7 100644 --- a/test/telegram_rich_text_test.dart +++ b/test/telegram_rich_text_test.dart @@ -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 _textSpans(InlineSpan span) sync* { if (span is! TextSpan) return; @@ -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.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)); + }, + ); }