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; const int _skinToneStart = 0x1F3FB; const int _skinToneEnd = 0x1F3FF; /// 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 >= _skinToneStart && r <= _skinToneEnd) || // 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; } /// [emoji] with any Fitzpatrick skin-tone modifier stripped, so tone variants /// of the same emoji compare equal (👍🏻 → 👍). Used to dedupe reactions that /// only differ in skin tone. String emojiSkinToneNeutral(String emoji) => String.fromCharCodes( emoji.runes.where((r) => r < _skinToneStart || r > _skinToneEnd), ); /// Distinct emojis contained in [text], most-frequent first with ties broken /// by first appearance. Used to offer a message's own emojis as quick /// reactions. List extractEmojis(String text) { final counts = {}; final firstSeen = {}; var order = 0; for (final cluster in text.characters) { if (!_isEmojiCluster(cluster)) continue; counts.update(cluster, (value) => value + 1, ifAbsent: () => 1); firstSeen.putIfAbsent(cluster, () => order++); } final emojis = counts.keys.toList(); emojis.sort((a, b) { final byCount = counts[b]!.compareTo(counts[a]!); return byCount != 0 ? byCount : firstSeen[a]!.compareTo(firstSeen[b]!); }); return emojis; }