diff --git a/lib/utils/emoji_detection.dart b/lib/utils/emoji_detection.dart index 3427087..19bc4e6 100644 --- a/lib/utils/emoji_detection.dart +++ b/lib/utils/emoji_detection.dart @@ -10,6 +10,8 @@ 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. @@ -18,7 +20,7 @@ bool _isModifier(int r) => r == _vs15 || r == _vs16 || r == _keycap || - (r >= 0x1F3FB && r <= 0x1F3FF) || // skin-tone modifiers + (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 @@ -84,3 +86,31 @@ double? standaloneEmojiFontSize(String text) { 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; +} diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index c679790..0999dfd 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -10,6 +10,7 @@ import '../../../../routing/app_routes.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../utils/clipboard_helper.dart'; +import '../../../../utils/emoji_detection.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; @@ -20,7 +21,28 @@ import '../../../../widget/emoji_picker_dialog.dart'; import '../../../../widget/emoji_text.dart'; import '../data/open_direct_chat.dart'; -const _commonReactions = ['๐Ÿ‘', '๐Ÿ‘Ž', '๐Ÿ˜†', 'โค๏ธ', '๐Ÿ‘€']; +const _commonReactions = ['๐Ÿ‘', '๐Ÿ‘Ž', 'โค๏ธ', '๐Ÿ˜†', '๐Ÿ˜ฎ', '๐Ÿ˜ข']; + +/// Upper bound on how many of a message's own emojis we surface as reactions, +/// so an emoji-heavy message doesn't blow up the reaction row. +const _maxMessageReactions = 3; + +/// The emojis contained in [bubbleData] itself, offered as extra reactions +/// next to the standard set (most-frequent first). +/// +/// Deduped skin-tone-neutrally so a tone variant of an emoji already in the +/// standard set (e.g. ๐Ÿ‘๐Ÿป next to ๐Ÿ‘) isn't offered a second time, while a +/// message's own tone choice is preserved for emojis not in the set. +List _messageReactionsFor(GetChatResponseObject bubbleData) { + final covered = _commonReactions.map(emojiSkinToneNeutral).toSet(); + final result = []; + for (final emoji in extractEmojis(bubbleData.message)) { + if (!covered.add(emojiSkinToneNeutral(emoji))) continue; + result.add(emoji); + if (result.length >= _maxMessageReactions) break; + } + return result; +} RichObjectString? _attachedFile(GetChatResponseObject bubbleData) { final file = bubbleData.messageParameters?['file']; @@ -57,6 +79,8 @@ void showChatMessageOptionsDialog( _ReactionsRow( chatToken: chatData.token, messageId: bubbleData.id, + reactions: _commonReactions, + messageReactions: _messageReactionsFor(bubbleData), onRefetch: onRefetch, sheetContext: sheetCtx, ), @@ -170,12 +194,16 @@ void showChatMessageOptionsDialog( class _ReactionsRow extends StatefulWidget { final String chatToken; final int messageId; + final List reactions; + final List messageReactions; final void Function({bool renew}) onRefetch; final BuildContext sheetContext; const _ReactionsRow({ required this.chatToken, required this.messageId, + required this.reactions, + required this.messageReactions, required this.onRefetch, required this.sheetContext, }); @@ -220,18 +248,16 @@ class _ReactionsRowState extends State<_ReactionsRow> { children: [ Wrap( alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - ..._commonReactions.map( - (emoji) => TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40), - ), - onPressed: busy ? null : () => _react(emoji), - child: EmojiText(emoji, size: EmojiText.sizeLarge), + ...widget.reactions.map((emoji) => _emojiButton(emoji, busy)), + if (widget.messageReactions.isNotEmpty) ...[ + _groupDivider(context), + ...widget.messageReactions.map( + (emoji) => _emojiButton(emoji, busy), ), - ), + ], + _groupDivider(context), IconButton( onPressed: busy ? null : () => _showEmojiPicker(context), style: IconButton.styleFrom( @@ -263,6 +289,23 @@ class _ReactionsRowState extends State<_ReactionsRow> { }, ); + Widget _emojiButton(String emoji, bool busy) => TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + onPressed: busy ? null : () => _react(emoji), + child: EmojiText(emoji, size: EmojiText.sizeLarge), + ); + + Widget _groupDivider(BuildContext context) => Container( + width: 1, + height: 24, + margin: const EdgeInsets.symmetric(horizontal: 6), + color: Theme.of(context).dividerColor, + ); + Future _showEmojiPicker(BuildContext rowContext) async { final emoji = await showEmojiPicker(rowContext, title: 'Reagieren'); if (emoji != null && mounted) await _react(emoji); diff --git a/test/utils/emoji_detection_test.dart b/test/utils/emoji_detection_test.dart index 4c74b6c..d4e5b5b 100644 --- a/test/utils/emoji_detection_test.dart +++ b/test/utils/emoji_detection_test.dart @@ -59,4 +59,52 @@ void main() { expect(standaloneEmojiFontSize('123'), isNull); }); }); + + group('extractEmojis', () { + test('returns empty list for plain text', () { + expect(extractEmojis('Hallo Welt'), isEmpty); + expect(extractEmojis(''), isEmpty); + }); + + test('extracts emojis from mixed text, ties keep first appearance', () { + expect(extractEmojis('Super ๐Ÿ‘ gemacht ๐ŸŽ‰'), ['๐Ÿ‘', '๐ŸŽ‰']); + }); + + test('sorts by frequency, most-frequent first', () { + // ๐ŸŽ‰ appears first but ๐Ÿ‘ occurs more often โ†’ ๐Ÿ‘ wins. + expect(extractEmojis('๐ŸŽ‰ ๐Ÿ‘ super ๐Ÿ‘'), ['๐Ÿ‘', '๐ŸŽ‰']); + }); + + test('dedupes repeated emojis, most-frequent first', () { + expect(extractEmojis('๐Ÿ˜€ ๐Ÿ˜€ ๐Ÿš€ ๐Ÿ˜€'), ['๐Ÿ˜€', '๐Ÿš€']); + }); + + test('extracts composite emojis as single entries', () { + expect(extractEmojis('Familie ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ und ๐Ÿ‘๐Ÿฝ'), ['๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', '๐Ÿ‘๐Ÿฝ']); + }); + + test('extracts flag emojis', () { + expect(extractEmojis('Hallo ๐Ÿ‡ฉ๐Ÿ‡ช'), ['๐Ÿ‡ฉ๐Ÿ‡ช']); + }); + }); + + group('emojiSkinToneNeutral', () { + test('strips skin-tone modifiers', () { + expect(emojiSkinToneNeutral('๐Ÿ‘๐Ÿป'), '๐Ÿ‘'); + expect(emojiSkinToneNeutral('๐Ÿ‘๐Ÿฟ'), '๐Ÿ‘'); + }); + + test('leaves emojis without skin tone unchanged', () { + expect(emojiSkinToneNeutral('๐Ÿ‘'), '๐Ÿ‘'); + expect(emojiSkinToneNeutral('โค๏ธ'), 'โค๏ธ'); + expect(emojiSkinToneNeutral('๐ŸŽ‰'), '๐ŸŽ‰'); + }); + + test('tone variants compare equal after normalization', () { + expect( + emojiSkinToneNeutral('๐Ÿ‘๐Ÿป'), + emojiSkinToneNeutral('๐Ÿ‘๐Ÿฝ'), + ); + }); + }); }