implemented dynamic quick reactions by surfacing emojis from message content in the chat options dialog
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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('👍🏽'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user