From 59501d3b455808961ce7196e6f4b138ea321412f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 12 Jul 2026 16:26:22 +0200 Subject: [PATCH] implemented enlarged emoji rendering for chat messages containing up to three emojis --- lib/utils/emoji_detection.dart | 86 ++++++++++++++++++++++ lib/view/pages/talk/data/chat_message.dart | 21 +++++- pubspec.yaml | 3 + test/utils/emoji_detection_test.dart | 62 ++++++++++++++++ 4 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 lib/utils/emoji_detection.dart create mode 100644 test/utils/emoji_detection_test.dart diff --git a/lib/utils/emoji_detection.dart b/lib/utils/emoji_detection.dart new file mode 100644 index 0000000..3427087 --- /dev/null +++ b/lib/utils/emoji_detection.dart @@ -0,0 +1,86 @@ +import 'package:characters/characters.dart'; + +/// Detects chat messages that consist solely of emoji so the UI can render +/// them enlarged (à la WhatsApp/iMessage). +/// +/// Returns the font size to use for such a message, or `null` when [text] +/// contains any non-emoji, non-whitespace character (i.e. render normally). + +const int _zwj = 0x200D; +const int _vs15 = 0xFE0E; +const int _vs16 = 0xFE0F; +const int _keycap = 0x20E3; + +/// Codepoints that only ever glue an emoji sequence together and never stand +/// on their own — they don't disqualify a cluster from counting as emoji. +bool _isModifier(int r) => + r == _zwj || + r == _vs15 || + r == _vs16 || + r == _keycap || + (r >= 0x1F3FB && r <= 0x1F3FF) || // skin-tone modifiers + (r >= 0xE0020 && r <= 0xE007F); // tag characters (flag sequences) + +/// The digits, `#` and `*` that become emoji only when combined with the +/// keycap combining mark. +bool _isKeycapBase(int r) => + r == 0x23 || r == 0x2A || (r >= 0x30 && r <= 0x39); + +bool _isEmojiRune(int r) { + // Bulk of modern emoji live in the astral pictograph block: emoticons, + // transport, supplemental symbols, extended-A, regional indicators, … + if (r >= 0x1F000 && r <= 0x1FAFF) return true; + // Scattered BMP emoji ranges (misc symbols, dingbats, arrows, stars, …). + if (r >= 0x2600 && r <= 0x27BF) return true; + if (r >= 0x2300 && r <= 0x23FF) return true; + if (r >= 0x2B00 && r <= 0x2BFF) return true; + if (r >= 0x2190 && r <= 0x21FF) return true; + if (r >= 0x25A0 && r <= 0x25FF) return true; + const singles = { + 0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139, + 0x2934, 0x2935, 0x3030, 0x303D, 0x3297, 0x3299, 0x24C2, + }; + return singles.contains(r); +} + +bool _isEmojiCluster(String cluster) { + final runes = cluster.runes.toList(growable: false); + final hasKeycap = runes.contains(_keycap); + var hasEmoji = false; + for (final r in runes) { + if (_isModifier(r)) continue; + if (_isEmojiRune(r)) { + hasEmoji = true; + continue; + } + if (hasKeycap && _isKeycapBase(r)) { + hasEmoji = true; + continue; + } + return false; // a plain text character → not an emoji cluster + } + return hasEmoji; +} + +/// Maximum number of emojis a message may contain to still be enlarged; longer +/// emoji strings would turn into a wall of giant glyphs and render normally. +const int _maxEnlargedEmojis = 3; + +/// Font size for an emoji-only [text] of at most [_maxEnlargedEmojis] emojis. +/// `null` means the message is not emoji-only (or has too many emojis) and +/// should render at the normal body size. +double? standaloneEmojiFontSize(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return null; + + var count = 0; + for (final cluster in trimmed.characters) { + if (cluster.trim().isEmpty) continue; // whitespace between emojis + if (!_isEmojiCluster(cluster)) return null; + count++; + if (count > _maxEnlargedEmojis) return null; + } + + if (count == 0) return null; + return 34; +} diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index bba5ae8..6e0a857 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../model/account_data.dart'; import '../../../../model/endpoint_data.dart'; +import '../../../../utils/emoji_detection.dart'; import '../../../../utils/url_opener.dart'; import '../widgets/highlighted_linkify.dart'; @@ -28,13 +29,29 @@ class ChatMessage { } Widget getWidget({String? highlightQuery, TextStyle? style}) { - var contentWidget = HighlightedLinkify( + final emojiFontSize = standaloneEmojiFontSize(content); + final effectiveStyle = emojiFontSize == null + ? style + : (style ?? const TextStyle()).copyWith( + fontSize: emojiFontSize, + height: 1.15, + ); + + Widget contentWidget = HighlightedLinkify( text: content, onOpen: UrlOpener.onOpen, highlight: highlightQuery, - style: style, + style: effectiveStyle, ); + // Enlarged emoji glyphs otherwise sit flush against the bubble edges. + if (emojiFontSize != null) { + contentWidget = Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: contentWidget, + ); + } + if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { return ListTile( leading: const Icon(Icons.poll_outlined), diff --git a/pubspec.yaml b/pubspec.yaml index 03f333f..cf07b34 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,9 @@ dependencies: async: ^2.11.0 badges: ^3.1.2 cached_network_image: ^3.4.1 + # Grapheme-cluster iteration for emoji-only message detection + # (lib/utils/emoji_detection.dart). Already present transitively via flutter. + characters: ^1.4.0 collection: ^1.19.0 connectivity_plus: ^7.1.0 crypto: ^3.0.6 diff --git a/test/utils/emoji_detection_test.dart b/test/utils/emoji_detection_test.dart new file mode 100644 index 0000000..4c74b6c --- /dev/null +++ b/test/utils/emoji_detection_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/emoji_detection.dart'; + +void main() { + group('standaloneEmojiFontSize', () { + test('returns null for empty or whitespace-only text', () { + expect(standaloneEmojiFontSize(''), isNull); + expect(standaloneEmojiFontSize(' '), isNull); + }); + + test('returns null for plain text', () { + expect(standaloneEmojiFontSize('Hallo'), isNull); + expect(standaloneEmojiFontSize('ok'), isNull); + }); + + test('returns null when emoji is mixed with text', () { + expect(standaloneEmojiFontSize('Hallo 😀'), isNull); + expect(standaloneEmojiFontSize('😀 super'), isNull); + }); + + test('enlarges a single emoji the most', () { + expect(standaloneEmojiFontSize('😀'), 34); + expect(standaloneEmojiFontSize('👍'), 34); + }); + + test('enlarges up to three emojis at the largest size', () { + expect(standaloneEmojiFontSize('😀😀😀'), 34); + expect(standaloneEmojiFontSize('😀 😀 😀'), 34); + }); + + test('does not enlarge more than three emojis', () { + expect(standaloneEmojiFontSize('😀😀😀😀'), isNull); + expect(standaloneEmojiFontSize('😀😀😀😀😀😀'), isNull); + }); + + test('handles ZWJ sequences as a single emoji', () { + // Family emoji: multiple codepoints joined via ZWJ → one cluster. + expect(standaloneEmojiFontSize('👨‍👩‍👧‍👦'), 34); + }); + + test('handles skin-tone modified emoji as a single emoji', () { + expect(standaloneEmojiFontSize('👍🏽'), 34); + }); + + test('handles emoji with variation selectors', () { + expect(standaloneEmojiFontSize('❤️'), 34); + expect(standaloneEmojiFontSize('⭐'), 34); + }); + + test('handles keycap emoji', () { + expect(standaloneEmojiFontSize('1️⃣'), 34); + }); + + test('handles flag emoji (regional indicators)', () { + expect(standaloneEmojiFontSize('🇩🇪'), 34); + }); + + test('bare digits without keycap are not emoji', () { + expect(standaloneEmojiFontSize('123'), isNull); + }); + }); +}