implemented dynamic quick reactions by surfacing emojis from message content in the chat options dialog

This commit is contained in:
2026-07-12 16:42:30 +02:00
parent 59501d3b45
commit 3f44e9302f
3 changed files with 133 additions and 12 deletions
+31 -1
View File
@@ -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<String> extractEmojis(String text) {
final counts = <String, int>{};
final firstSeen = <String, int>{};
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;
}
@@ -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 = <String>['👍', '👎', '😆', '❤️', '👀'];
const _commonReactions = <String>['👍', '👎', '❤️', '😆', '😮', '😢'];
/// 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<String> _messageReactionsFor(GetChatResponseObject bubbleData) {
final covered = _commonReactions.map(emojiSkinToneNeutral).toSet();
final result = <String>[];
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<String> reactions;
final List<String> 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<void> _showEmojiPicker(BuildContext rowContext) async {
final emoji = await showEmojiPicker(rowContext, title: 'Reagieren');
if (emoji != null && mounted) await _react(emoji);
+48
View File
@@ -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('👍🏽'),
);
});
});
}