From 90ce46392a04ebf04c237e03916727f35b6e975f Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 28 Jan 2026 23:21:04 -0700 Subject: [PATCH] feat: optimize reaction message format to reduce airtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce reaction payload from ~44 bytes to 9 bytes (5x smaller) - Use 4-char hex hash (timestamp + sender + first 5 chars) for message ID - Use 2-char hex emoji index instead of multi-byte UTF-8 emoji - Format: r:HASH:INDEX (e.g., r:a1b2:00) - For 1:1 chats, sender is implicit (null) for shorter hash - Prevent users from reacting to their own messages - Add room server reaction support with sender identification - Make emoji lists public in EmojiPicker for shared indexing - Add ๐Ÿ’ช and ๐Ÿš€ emojis to picker - Add comprehensive unit tests for reaction helpers - Update minor dependencies --- lib/connector/meshcore_connector.dart | 112 +++-- lib/helpers/reaction_helper.dart | 85 ++-- lib/screens/channel_chat_screen.dart | 27 +- lib/screens/chat_screen.dart | 39 +- lib/widgets/emoji_picker.dart | 20 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 80 +++- test/reaction_helper_test.dart | 404 ++++++++++++++++++ 8 files changed, 639 insertions(+), 130 deletions(-) create mode 100644 test/reaction_helper_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 6f22c5e2..1bab1301 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -67,9 +67,9 @@ class MeshCoreConnector extends ChangeNotifier { final Map> _channelMessages = {}; final Set _loadedConversationKeys = {}; final Map> _processedChannelReactions = - {}; // channelIndex -> Set of "reactionKey_emoji" + {}; // channelIndex -> Set of "targetHash_emoji" final Map> _processedContactReactions = - {}; // contactPubKeyHex -> Set of "reactionKey_emoji" + {}; // contactPubKeyHex -> Set of "targetHash_emoji" StreamSubscription>? _scanSubscription; StreamSubscription? _connectionSubscription; @@ -1288,15 +1288,9 @@ class MeshCoreConnector extends ChangeNotifier { if (reactionInfo != null) { // Check if we've already processed this reaction _processedChannelReactions.putIfAbsent(channel.index, () => {}); - final reactionKey = reactionInfo.reactionKey; - final reactionIdentifier = reactionKey != null - ? '${reactionKey}_${reactionInfo.emoji}' - : null; + final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}'; - if (reactionIdentifier != null && - _processedChannelReactions[channel.index]!.contains( - reactionIdentifier, - )) { + if (_processedChannelReactions[channel.index]!.contains(reactionIdentifier)) { // Already processed, don't process again return; } @@ -1310,9 +1304,7 @@ class MeshCoreConnector extends ChangeNotifier { await _channelMessageStore.saveChannelMessages(channel.index, messages); // Mark this reaction as processed - if (reactionIdentifier != null) { - _processedChannelReactions[channel.index]!.add(reactionIdentifier); - } + _processedChannelReactions[channel.index]!.add(reactionIdentifier); notifyListeners(); @@ -2688,26 +2680,20 @@ class MeshCoreConnector extends ChangeNotifier { // Parse reaction info final reactionInfo = Message.parseReaction(message.text); if (reactionInfo != null) { - // Check if we've already processed this exact reaction using lightweight key + // Check if we've already processed this exact reaction _processedContactReactions.putIfAbsent(pubKeyHex, () => {}); - final reactionKey = reactionInfo.reactionKey; - final reactionIdentifier = reactionKey != null - ? '${reactionKey}_${reactionInfo.emoji}' - : null; + final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}'; final isDuplicate = - reactionIdentifier != null && _processedContactReactions[pubKeyHex]!.contains(reactionIdentifier); if (!isDuplicate) { // New reaction - process it - _processContactReaction(messages, reactionInfo); + _processContactReaction(messages, reactionInfo, pubKeyHex); _messageStore.saveMessages(pubKeyHex, messages); // Mark as processed - if (reactionIdentifier != null) { - _processedContactReactions[pubKeyHex]!.add(reactionIdentifier); - } + _processedContactReactions[pubKeyHex]!.add(reactionIdentifier); notifyListeners(); } @@ -2722,15 +2708,51 @@ class MeshCoreConnector extends ChangeNotifier { void _processContactReaction( List messages, ReactionInfo reactionInfo, + String contactPubKeyHex, ) { - // Find target message by messageId - for (int i = 0; i < messages.length; i++) { - if (messages[i].messageId == reactionInfo.targetMessageId) { - final currentReactions = Map.from(messages[i].reactions); + // Find target message by computing hash and comparing + final targetHash = reactionInfo.targetHash; + final contact = _contacts.cast().firstWhere( + (c) => c?.publicKeyHex == contactPubKeyHex, + orElse: () => null, + ); + final isRoomServer = contact?.type == advTypeRoom; + + for (int i = messages.length - 1; i >= 0; i--) { + final msg = messages[i]; + + // For 1:1 chats: contact reacts to my outgoing messages only + // For room servers: any message can be reacted to (multi-user) + if (!isRoomServer && !msg.isOutgoing) continue; + + final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000; + + // For room servers, include sender name (resolve from fourByteRoomContactKey) + // For 1:1 chats, sender is implicit (null) + String? senderName; + if (isRoomServer && !msg.isOutgoing) { + // Resolve sender from the message's fourByteRoomContactKey + final senderContact = _contacts.cast().firstWhere( + (c) => c != null && _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey), + orElse: () => null, + ); + senderName = senderContact?.name; + } else if (isRoomServer && msg.isOutgoing) { + senderName = selfName; + } + // For 1:1, senderName stays null + + final msgHash = ReactionHelper.computeReactionHash( + timestampSecs, + senderName, + msg.text, + ); + if (msgHash == targetHash) { + final currentReactions = Map.from(msg.reactions); currentReactions[reactionInfo.emoji] = (currentReactions[reactionInfo.emoji] ?? 0) + 1; - messages[i] = messages[i].copyWith(reactions: currentReactions); + messages[i] = msg.copyWith(reactions: currentReactions); break; } } @@ -2881,18 +2903,12 @@ class MeshCoreConnector extends ChangeNotifier { // Parse reaction info final reactionInfo = ChannelMessage.parseReaction(message.text); if (reactionInfo != null) { - // Check if we've already processed this exact reaction using lightweight key + // Check if we've already processed this exact reaction _processedChannelReactions.putIfAbsent(channelIndex, () => {}); - final reactionKey = reactionInfo.reactionKey; - final reactionIdentifier = reactionKey != null - ? '${reactionKey}_${reactionInfo.emoji}' - : null; + final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}'; final isDuplicate = - reactionIdentifier != null && - _processedChannelReactions[channelIndex]!.contains( - reactionIdentifier, - ); + _processedChannelReactions[channelIndex]!.contains(reactionIdentifier); if (!isDuplicate) { // New reaction - process it @@ -2901,9 +2917,7 @@ class MeshCoreConnector extends ChangeNotifier { _channelMessageStore.saveChannelMessages(channelIndex, messages); // Mark as processed - if (reactionIdentifier != null) { - _processedChannelReactions[channelIndex]!.add(reactionIdentifier); - } + _processedChannelReactions[channelIndex]!.add(reactionIdentifier); } return false; // Don't add reaction as a visible message } @@ -2999,14 +3013,22 @@ class MeshCoreConnector extends ChangeNotifier { List messages, ReactionInfo reactionInfo, ) { - // Find target message by messageId - for (int i = 0; i < messages.length; i++) { - if (messages[i].messageId == reactionInfo.targetMessageId) { - final currentReactions = Map.from(messages[i].reactions); + // Find target message by computing hash and comparing + final targetHash = reactionInfo.targetHash; + for (int i = messages.length - 1; i >= 0; i--) { + final msg = messages[i]; + final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000; + final msgHash = ReactionHelper.computeReactionHash( + timestampSecs, + msg.senderName, + msg.text, + ); + if (msgHash == targetHash) { + final currentReactions = Map.from(msg.reactions); currentReactions[reactionInfo.emoji] = (currentReactions[reactionInfo.emoji] ?? 0) + 1; - messages[i] = messages[i].copyWith(reactions: currentReactions); + messages[i] = msg.copyWith(reactions: currentReactions); notifyListeners(); break; } diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 004904bb..b75a9fd5 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -1,53 +1,70 @@ +import '../widgets/emoji_picker.dart'; + class ReactionInfo { - final String targetMessageId; + final String targetHash; final String emoji; - final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix ReactionInfo({ - required this.targetMessageId, + required this.targetHash, required this.emoji, - this.reactionKey, }); } class ReactionHelper { - /// Parse reaction format: r:[messageId]:[emoji] - /// Supports both old format (full messageId) and new format (timestamp_senderPrefix) + static List? _cachedEmojis; + + /// Combined list of all reaction emojis in fixed order. + /// Order must stay stable for index compatibility. + static List get reactionEmojis { + return _cachedEmojis ??= [ + ...EmojiPicker.quickEmojis, + ...EmojiPicker.smileys, + ...EmojiPicker.gestures, + ...EmojiPicker.hearts, + ...EmojiPicker.objects, + ]; + } + + /// Convert emoji to 2-char hex index. Returns null if emoji not in list. + static String? emojiToIndex(String emoji) { + final idx = reactionEmojis.indexOf(emoji); + if (idx < 0) return null; + return idx.toRadixString(16).padLeft(2, '0'); + } + + /// Convert 2-char hex index to emoji. Returns null if invalid index. + static String? indexToEmoji(String hexIndex) { + final idx = int.tryParse(hexIndex, radix: 16); + if (idx == null || idx < 0 || idx >= reactionEmojis.length) return null; + return reactionEmojis[idx]; + } + + /// Compute a 4-char hex hash for a message reaction. + /// Hash input: timestampSeconds + [senderName] + first 5 chars of text + /// For 1:1 chats, senderName can be null (sender is implicit). + static String computeReactionHash(int timestampSeconds, String? senderName, String text) { + final first5 = text.length >= 5 ? text.substring(0, 5) : text; + final input = senderName != null + ? '$timestampSeconds$senderName$first5' + : '$timestampSeconds$first5'; + // Use hashCode and take lower 16 bits, format as 4 hex chars + final hash = input.hashCode & 0xFFFF; + return hash.toRadixString(16).padLeft(4, '0'); + } + + /// Parse reaction format: r:HASH:INDEX (where INDEX is 2-char hex emoji index) + /// Returns null if text is not a valid reaction format static ReactionInfo? parseReaction(String text) { - final regex = RegExp(r'^r:([^:]+):(.+)$'); + final regex = RegExp(r'^r:([0-9a-f]{4}):([0-9a-f]{2})$'); final match = regex.firstMatch(text); if (match == null) return null; - final targetId = match.group(1)!; - final emoji = match.group(2)!; - - // Extract reaction key for deduplication - // If targetId is in new format (timestamp_senderPrefix), use it directly - // Otherwise, extract timestamp from old format (timestamp_nameHash_textHash) - String? reactionKey; - if (targetId.contains('_')) { - final parts = targetId.split('_'); - if (parts.length >= 2) { - // New format: timestamp_senderPrefix, or old format with at least timestamp - reactionKey = '${parts[0]}_${parts[1]}'; - } - } + final emoji = indexToEmoji(match.group(2)!); + if (emoji == null) return null; return ReactionInfo( - targetMessageId: targetId, + targetHash: match.group(1)!, emoji: emoji, - reactionKey: reactionKey, ); } - - /// Generate a lightweight reaction key for a message - /// Format: r:[timestamp]_[senderPrefix]:[emoji] - static String buildReactionText(String timestamp, String senderPrefix, String emoji) { - return 'r:${timestamp}_$senderPrefix:$emoji'; - } - - /// Extract sender prefix from public key hex (first 8 chars) - static String getSenderPrefix(String senderKeyHex) { - return senderKeyHex.substring(0, 8); - } } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index f45ed34b..083a60b7 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -11,6 +11,7 @@ import '../connector/meshcore_connector.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/link_handler.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/utf8_length_limiter.dart'; import '../l10n/l10n.dart'; import '../models/channel.dart'; @@ -877,14 +878,16 @@ class _ChannelChatScreenState extends State { _setReplyingTo(message); }, ), - ListTile( - leading: const Icon(Icons.add_reaction_outlined), - title: Text(context.l10n.chat_addReaction), - onTap: () { - Navigator.pop(sheetContext); - _showEmojiPicker(message); - }, - ), + // Can't react to your own messages + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.add_reaction_outlined), + title: Text(context.l10n.chat_addReaction), + onTap: () { + Navigator.pop(sheetContext); + _showEmojiPicker(message); + }, + ), ListTile( leading: const Icon(Icons.copy), title: Text(context.l10n.common_copy), @@ -926,9 +929,11 @@ class _ChannelChatScreenState extends State { void _sendReaction(ChannelMessage message, String emoji) { final connector = context.read(); - // Send reaction with full messageId to find target, but parser will extract - // lightweight reactionKey (timestamp_senderPrefix) for deduplication - final reactionText = 'r:${message.messageId}:$emoji'; + final emojiIndex = ReactionHelper.emojiToIndex(emoji); + if (emojiIndex == null) return; // Unknown emoji, skip + final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000; + final hash = ReactionHelper.computeReactionHash(timestampSecs, message.senderName, message.text); + final reactionText = 'r:$hash:$emojiIndex'; connector.sendChannelMessage(widget.channel, reactionText); } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index efc35378..cf343818 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/chat_scroll_controller.dart'; import '../helpers/link_handler.dart'; import '../helpers/utf8_length_limiter.dart'; @@ -850,14 +851,16 @@ class _ChatScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - leading: const Icon(Icons.add_reaction_outlined), - title: Text(context.l10n.chat_addReaction), - onTap: () { - Navigator.pop(sheetContext); - _showEmojiPicker(message); - }, - ), + // Can't react to your own messages + if (!message.isOutgoing) + ListTile( + leading: const Icon(Icons.add_reaction_outlined), + title: Text(context.l10n.chat_addReaction), + onTap: () { + Navigator.pop(sheetContext); + _showEmojiPicker(message, contact); + }, + ), ListTile( leading: const Icon(Icons.copy), title: Text(context.l10n.common_copy), @@ -931,25 +934,29 @@ class _ChatScreenState extends State { ); } - void _showEmojiPicker(Message message) { + void _showEmojiPicker(Message message, Contact senderContact) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => EmojiPicker( onEmojiSelected: (emoji) { - _sendReaction(message, emoji); + _sendReaction(message, senderContact, emoji); }, ), ); } - void _sendReaction(Message message, String emoji) { + void _sendReaction(Message message, Contact senderContact, String emoji) { final connector = context.read(); - // Send reaction with messageId if available, otherwise use lightweight format - // Parser will extract reactionKey (timestamp_senderPrefix) for deduplication - final messageId = message.messageId ?? - '${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}'; - final reactionText = 'r:$messageId:$emoji'; + final emojiIndex = ReactionHelper.emojiToIndex(emoji); + if (emojiIndex == null) return; // Unknown emoji, skip + final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000; + + // For room servers, include sender name (like channels) since multiple users + // For 1:1 chats, sender is implicit (null) + final senderName = widget.contact.type == advTypeRoom ? senderContact.name : null; + final hash = ReactionHelper.computeReactionHash(timestampSecs, senderName, message.text); + final reactionText = 'r:$hash:$emojiIndex'; connector.sendMessage(widget.contact, reactionText); } } diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 1a2ffa31..7345eff2 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -12,32 +12,32 @@ class EmojiPicker extends StatelessWidget { static const List quickEmojis = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐ŸŽ‰', '๐Ÿ‘', '๐Ÿ”ฅ']; - static const List _smileys = [ + static const List smileys = [ '๐Ÿ˜€', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜', '๐Ÿ˜…', '๐Ÿ˜‚', '๐Ÿคฃ', '๐Ÿ˜Š', '๐Ÿ˜‡', '๐Ÿ™‚', '๐Ÿ™ƒ', '๐Ÿ˜‰', '๐Ÿ˜Œ', '๐Ÿ˜', '๐Ÿฅฐ', '๐Ÿ˜˜', '๐Ÿ˜—', '๐Ÿ˜™', '๐Ÿ˜š', '๐Ÿ˜‹', '๐Ÿ˜›', '๐Ÿ˜', '๐Ÿ˜œ', '๐Ÿคช', '๐Ÿคจ', '๐Ÿง', '๐Ÿค“', '๐Ÿ˜Ž', '๐Ÿฅธ', '๐Ÿคฉ', '๐Ÿฅณ', '๐Ÿ˜', '๐Ÿ˜’', '๐Ÿ˜ž', '๐Ÿ˜”', '๐Ÿ˜Ÿ', '๐Ÿ˜•', '๐Ÿ™', '๐Ÿ˜ฃ', '๐Ÿ˜–', '๐Ÿ˜ซ', '๐Ÿ˜ฉ', '๐Ÿฅบ', '๐Ÿ˜ข', '๐Ÿ˜ญ', '๐Ÿ˜ค', '๐Ÿ˜ ', '๐Ÿ˜ก', '๐Ÿคฌ', '๐Ÿคฏ', '๐Ÿ˜ณ', '๐Ÿฅต', '๐Ÿฅถ', '๐Ÿ˜ฑ', '๐Ÿ˜จ', '๐Ÿ˜ฐ', '๐Ÿ˜ฅ', '๐Ÿ˜“', '๐Ÿค—', '๐Ÿค”', '๐Ÿคญ', '๐Ÿคซ', '๐Ÿคฅ', '๐Ÿ˜ถ', ]; - static const List _gestures = [ + static const List gestures = [ '๐Ÿ‘', '๐Ÿ‘Ž', '๐Ÿ‘Š', 'โœŠ', '๐Ÿค›', '๐Ÿคœ', '๐Ÿคž', 'โœŒ๏ธ', '๐ŸคŸ', '๐Ÿค˜', '๐Ÿ‘Œ', '๐ŸคŒ', '๐Ÿค', '๐Ÿ‘ˆ', '๐Ÿ‘‰', '๐Ÿ‘†', - '๐Ÿ‘‡', 'โ˜๏ธ', '๐Ÿ‘‹', '๐Ÿคš', '๐Ÿ–๏ธ', 'โœ‹', '๐Ÿ––', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿ‘', '๐Ÿคฒ', '๐Ÿค', '๐Ÿ™', 'โœ๏ธ', '๐Ÿ’…', '๐Ÿคณ', + '๐Ÿ‘‡', 'โ˜๏ธ', '๐Ÿ‘‹', '๐Ÿคš', '๐Ÿ–๏ธ', 'โœ‹', '๐Ÿ––', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿ‘', '๐Ÿคฒ', '๐Ÿค', '๐Ÿ™', 'โœ๏ธ', '๐Ÿ’…', '๐Ÿคณ', '๐Ÿ’ช', ]; - static const List _hearts = [ + static const List hearts = [ 'โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', '๐Ÿ–ค', '๐Ÿค', '๐ŸคŽ', '๐Ÿ’”', 'โค๏ธโ€๐Ÿ”ฅ', 'โค๏ธโ€๐Ÿฉน', '๐Ÿ’•', '๐Ÿ’ž', '๐Ÿ’“', '๐Ÿ’—', '๐Ÿ’–', '๐Ÿ’˜', '๐Ÿ’', '๐Ÿ’Ÿ', '๐Ÿ’Œ', '๐Ÿ’ข', '๐Ÿ’ฅ', '๐Ÿ’ซ', '๐Ÿ’ฆ', '๐Ÿ’จ', '๐Ÿ•ณ๏ธ', '๐Ÿ’ฌ', '๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ', '๐Ÿ—จ๏ธ', '๐Ÿ—ฏ๏ธ', '๐Ÿ’ญ', ]; - static const List _objects = [ + static const List objects = [ '๐ŸŽ‰', '๐ŸŽŠ', '๐ŸŽˆ', '๐ŸŽ', '๐ŸŽ€', '๐Ÿช…', '๐Ÿช†', '๐Ÿ†', '๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰', 'โšฝ', 'โšพ', '๐ŸฅŽ', '๐Ÿ€', '๐Ÿ', '๐Ÿˆ', '๐Ÿ‰', '๐ŸŽพ', '๐Ÿฅ', '๐ŸŽณ', '๐Ÿ', '๐Ÿ‘', '๐Ÿ’', '๐Ÿฅ', '๐Ÿ“', '๐Ÿธ', '๐ŸฅŠ', '๐Ÿฅ‹', '๐Ÿฅ…', 'โ›ณ', '๐Ÿ”ฅ', - 'โญ', '๐ŸŒŸ', 'โœจ', 'โšก', '๐Ÿ’ก', '๐Ÿ”ฆ', '๐Ÿฎ', '๐Ÿช”', '๐Ÿ“ฑ', '๐Ÿ’ป', 'โŒš', '๐Ÿ“ท', '๐Ÿ“บ', '๐Ÿ“ป', '๐ŸŽต', '๐ŸŽถ', + 'โญ', '๐ŸŒŸ', 'โœจ', 'โšก', '๐Ÿ’ก', '๐Ÿ”ฆ', '๐Ÿฎ', '๐Ÿช”', '๐Ÿ“ฑ', '๐Ÿ’ป', 'โŒš', '๐Ÿ“ท', '๐Ÿ“บ', '๐Ÿ“ป', '๐ŸŽต', '๐ŸŽถ', '๐Ÿš€', ]; Map> _emojiCategories(AppLocalizations l10n) { return { - l10n.emojiCategorySmileys: _smileys, - l10n.emojiCategoryGestures: _gestures, - l10n.emojiCategoryHearts: _hearts, - l10n.emojiCategoryObjects: _objects, + l10n.emojiCategorySmileys: smileys, + l10n.emojiCategoryGestures: gestures, + l10n.emojiCategoryHearts: hearts, + l10n.emojiCategoryObjects: objects, }; } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 31428df1..b4a41dd1 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,7 +9,6 @@ import flutter_blue_plus_darwin import flutter_local_notifications import mobile_scanner import package_info_plus -import path_provider_foundation import shared_preferences_foundation import sqflite_darwin import url_launcher_macos @@ -20,7 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 2e2b7466..1e275d4f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -157,10 +165,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" file: dependency: transitive description: @@ -234,10 +242,10 @@ packages: dependency: transitive description: name: flutter_blue_plus_winrt - sha256: "0c87ca5bdf1a110d42847edeca8fbb11a9701738dc8526aefbb2a115bea29aef" + sha256: "34be2d8e23d5881b46accebb0e71025f7d52869d72ea98b5082c20764e06aa80" url: "https://pub.dev" source: hosted - version: "0.0.10" + version: "0.0.16" flutter_cache_manager: dependency: "direct main" description: @@ -325,6 +333,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" http: dependency: "direct main" description: @@ -369,10 +393,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" latlong2: dependency: "direct main" description: @@ -437,6 +461,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -477,6 +509,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.11" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -485,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e" + url: "https://pub.dev" + source: hosted + version: "9.2.5" octo_image: dependency: transitive description: @@ -537,10 +585,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -629,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" qr: dependency: transitive description: @@ -665,10 +721,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: @@ -987,5 +1043,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/test/reaction_helper_test.dart b/test/reaction_helper_test.dart new file mode 100644 index 00000000..d2c70b5b --- /dev/null +++ b/test/reaction_helper_test.dart @@ -0,0 +1,404 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/reaction_helper.dart'; +import 'package:meshcore_open/widgets/emoji_picker.dart'; + +void main() { + group('ReactionHelper', () { + group('reactionEmojis', () { + test('should contain all emoji categories', () { + final emojis = ReactionHelper.reactionEmojis; + + // Should contain quickEmojis + for (final emoji in EmojiPicker.quickEmojis) { + expect(emojis.contains(emoji), isTrue, reason: 'Missing quick emoji: $emoji'); + } + + // Should contain smileys + for (final emoji in EmojiPicker.smileys) { + expect(emojis.contains(emoji), isTrue, reason: 'Missing smiley: $emoji'); + } + + // Should contain gestures + for (final emoji in EmojiPicker.gestures) { + expect(emojis.contains(emoji), isTrue, reason: 'Missing gesture: $emoji'); + } + + // Should contain hearts + for (final emoji in EmojiPicker.hearts) { + expect(emojis.contains(emoji), isTrue, reason: 'Missing heart: $emoji'); + } + + // Should contain objects + for (final emoji in EmojiPicker.objects) { + expect(emojis.contains(emoji), isTrue, reason: 'Missing object: $emoji'); + } + }); + + test('should fit in 1 byte (max 256 emojis)', () { + expect(ReactionHelper.reactionEmojis.length, lessThanOrEqualTo(256)); + }); + }); + + group('emojiToIndex', () { + test('should return 2-char hex for valid emoji', () { + // First emoji (thumbs up) should be index 0 + expect(ReactionHelper.emojiToIndex('๐Ÿ‘'), equals('00')); + + // Second emoji (heart) should be index 1 + expect(ReactionHelper.emojiToIndex('โค๏ธ'), equals('01')); + }); + + test('should return null for unknown emoji', () { + expect(ReactionHelper.emojiToIndex('๐Ÿฆ„'), isNull); // Not in list + expect(ReactionHelper.emojiToIndex('invalid'), isNull); + expect(ReactionHelper.emojiToIndex(''), isNull); + }); + + test('should return lowercase hex', () { + final index = ReactionHelper.emojiToIndex('๐Ÿ‘'); + expect(index, matches(RegExp(r'^[0-9a-f]{2}$'))); + }); + }); + + group('indexToEmoji', () { + test('should return emoji for valid index', () { + expect(ReactionHelper.indexToEmoji('00'), equals('๐Ÿ‘')); + expect(ReactionHelper.indexToEmoji('01'), equals('โค๏ธ')); + }); + + test('should return null for invalid index', () { + expect(ReactionHelper.indexToEmoji('ff'), isNull); // Index 255, out of range + expect(ReactionHelper.indexToEmoji('zz'), isNull); // Invalid hex + expect(ReactionHelper.indexToEmoji(''), isNull); // Empty string + // Note: indexToEmoji parses any valid hex; length validation is done by parseReaction's regex + }); + + test('should handle case insensitivity', () { + // Both uppercase and lowercase should work + expect(ReactionHelper.indexToEmoji('0a'), isNotNull); + expect(ReactionHelper.indexToEmoji('0A'), isNotNull); + }); + }); + + group('emoji round-trip', () { + test('all emojis should round-trip correctly', () { + for (int i = 0; i < ReactionHelper.reactionEmojis.length; i++) { + final emoji = ReactionHelper.reactionEmojis[i]; + final index = ReactionHelper.emojiToIndex(emoji); + expect(index, isNotNull, reason: 'emojiToIndex failed for $emoji'); + + final decoded = ReactionHelper.indexToEmoji(index!); + expect(decoded, equals(emoji), reason: 'Round-trip failed for $emoji (index $index)'); + } + }); + }); + + group('computeReactionHash', () { + test('should return 4-char hex hash', () { + final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello world'); + expect(hash, matches(RegExp(r'^[0-9a-f]{4}$'))); + }); + + test('should be deterministic', () { + final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); + final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); + expect(hash1, equals(hash2)); + }); + + test('should differ for different inputs', () { + final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); + final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Bob', 'Hello'); + final hash3 = ReactionHelper.computeReactionHash(1234567891, 'Alice', 'Hello'); + final hash4 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'World'); + + expect(hash1, isNot(equals(hash2))); // Different sender + expect(hash1, isNot(equals(hash3))); // Different timestamp + expect(hash1, isNot(equals(hash4))); // Different text + }); + + test('should use first 5 chars of text', () { + final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello world'); + final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello there'); + expect(hash1, equals(hash2)); // Same first 5 chars + }); + + test('should handle short text', () { + final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hi'); + expect(hash, matches(RegExp(r'^[0-9a-f]{4}$'))); + }); + + test('should handle empty text', () { + final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', ''); + expect(hash, matches(RegExp(r'^[0-9a-f]{4}$'))); + }); + }); + + group('computeReactionHash with null sender (1:1 chats)', () { + test('should return 4-char hex hash', () { + final hash = ReactionHelper.computeReactionHash(1234567890, null, 'Hello world'); + expect(hash, matches(RegExp(r'^[0-9a-f]{4}$'))); + }); + + test('should be deterministic', () { + final hash1 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello'); + final hash2 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello'); + expect(hash1, equals(hash2)); + }); + + test('should differ for different inputs', () { + final hash1 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello'); + final hash2 = ReactionHelper.computeReactionHash(1234567891, null, 'Hello'); + final hash3 = ReactionHelper.computeReactionHash(1234567890, null, 'World'); + + expect(hash1, isNot(equals(hash2))); // Different timestamp + expect(hash1, isNot(equals(hash3))); // Different text + }); + + test('should differ from hash with sender name', () { + // Null sender hash doesn't include sender, so should differ + final nullSenderHash = ReactionHelper.computeReactionHash(1234567890, null, 'Hello'); + final withSenderHash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); + expect(nullSenderHash, isNot(equals(withSenderHash))); + }); + + test('1:1 chat flow: sender and receiver compute same hash', () { + // Alice sends "Hello" at timestamp 1234567890 + // Bob receives it and wants to react + // Bob computes hash the same way Alice's app will match it + const timestamp = 1234567890; + const messageText = 'Hello there!'; + + // Bob (sender of reaction) computes hash with null sender + final bobHash = ReactionHelper.computeReactionHash(timestamp, null, messageText); + + // Alice (receiver of reaction) computes hash for her outgoing message + final aliceHash = ReactionHelper.computeReactionHash(timestamp, null, messageText); + + expect(bobHash, equals(aliceHash)); + }); + }); + + group('parseReaction', () { + test('should parse valid reaction format', () { + final info = ReactionHelper.parseReaction('r:a1b2:00'); + expect(info, isNotNull); + expect(info!.targetHash, equals('a1b2')); + expect(info.emoji, equals('๐Ÿ‘')); + }); + + test('should return null for invalid format', () { + expect(ReactionHelper.parseReaction('invalid'), isNull); + expect(ReactionHelper.parseReaction('r:abc:00'), isNull); // Hash too short + expect(ReactionHelper.parseReaction('r:abcde:00'), isNull); // Hash too long + expect(ReactionHelper.parseReaction('r:a1b2:0'), isNull); // Index too short + expect(ReactionHelper.parseReaction('r:a1b2:000'), isNull); // Index too long + expect(ReactionHelper.parseReaction('R:a1b2:00'), isNull); // Uppercase R + expect(ReactionHelper.parseReaction('r:A1B2:00'), isNull); // Uppercase hash + expect(ReactionHelper.parseReaction(''), isNull); + }); + + test('should return null for invalid emoji index', () { + // Index ff (255) is likely out of range + expect(ReactionHelper.parseReaction('r:a1b2:ff'), isNull); + }); + + test('should decode emoji correctly', () { + // Encode thumbs up and verify decode + final index = ReactionHelper.emojiToIndex('๐Ÿ‘'); + final info = ReactionHelper.parseReaction('r:dead:$index'); + expect(info, isNotNull); + expect(info!.emoji, equals('๐Ÿ‘')); + }); + }); + + group('full reaction flow', () { + test('should encode and decode reaction correctly', () { + // Simulate sending a reaction + const timestamp = 1234567890; + const senderName = 'Alice'; + const messageText = 'Hello world!'; + const emoji = '๐ŸŽ‰'; + + // Compute hash (sender side) + final hash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); + + // Encode emoji (sender side) + final emojiIndex = ReactionHelper.emojiToIndex(emoji); + expect(emojiIndex, isNotNull); + + // Build reaction text (sender side) + final reactionText = 'r:$hash:$emojiIndex'; + + // Parse reaction (receiver side) + final info = ReactionHelper.parseReaction(reactionText); + expect(info, isNotNull); + expect(info!.targetHash, equals(hash)); + expect(info.emoji, equals(emoji)); + + // Verify receiver can match the hash + final receiverHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); + expect(receiverHash, equals(info.targetHash)); + }); + + test('reaction text should be 9 bytes', () { + final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); + final index = ReactionHelper.emojiToIndex('๐Ÿ‘')!; + final reactionText = 'r:$hash:$index'; + + // r: (2) + hash (4) + : (1) + index (2) = 9 bytes + expect(reactionText.length, equals(9)); + }); + + test('1:1 chat: Bob reacts to Alice message', () { + // Alice sends "Hello" to Bob at timestamp 1234567890 + const timestamp = 1234567890; + const aliceName = 'Alice'; + const messageText = 'Hello'; + const emoji = '๐Ÿ‘'; + + // On Bob's device: message.isOutgoing = false, so senderName = contact.name = Alice + final bobSideHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText); + final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; + final reactionText = 'r:$bobSideHash:$emojiIndex'; + + // Alice receives the reaction + final info = ReactionHelper.parseReaction(reactionText); + expect(info, isNotNull); + + // On Alice's device: message.isOutgoing = true, so senderName = selfName = Alice + final aliceSideHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText); + + // Hashes should match! + expect(info!.targetHash, equals(aliceSideHash)); + expect(info.emoji, equals(emoji)); + }); + + test('1:1 chat: Alice reacts to Bob message', () { + // Bob sends "Hi there" to Alice at timestamp 9876543210 + const timestamp = 9876543210; + const bobName = 'Bob'; + const messageText = 'Hi there'; + const emoji = 'โค๏ธ'; + + // On Alice's device: message.isOutgoing = false, so senderName = contact.name = Bob + final aliceSideHash = ReactionHelper.computeReactionHash(timestamp, bobName, messageText); + final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; + final reactionText = 'r:$aliceSideHash:$emojiIndex'; + + // Bob receives the reaction + final info = ReactionHelper.parseReaction(reactionText); + expect(info, isNotNull); + + // On Bob's device: message.isOutgoing = true, so senderName = selfName = Bob + final bobSideHash = ReactionHelper.computeReactionHash(timestamp, bobName, messageText); + + // Hashes should match! + expect(info!.targetHash, equals(bobSideHash)); + expect(info.emoji, equals(emoji)); + }); + + test('room server: user reacts to message from another user', () { + // In a room server, Charlie sends "Hello room" at timestamp 1111111111 + // Alice wants to react to it + const timestamp = 1111111111; + const charlieName = 'Charlie'; + const messageText = 'Hello room'; + const emoji = '๐ŸŽ‰'; + + // Alice computes hash including sender name (room servers are multi-user) + final aliceHash = ReactionHelper.computeReactionHash(timestamp, charlieName, messageText); + final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; + final reactionText = 'r:$aliceHash:$emojiIndex'; + + // Verify format + expect(reactionText.length, equals(9)); + expect(reactionText, matches(RegExp(r'^r:[0-9a-f]{4}:[0-9a-f]{2}$'))); + + // Bob (another user in the room) receives the reaction + final info = ReactionHelper.parseReaction(reactionText); + expect(info, isNotNull); + + // Bob computes hash for Charlie's message the same way + final bobHash = ReactionHelper.computeReactionHash(timestamp, charlieName, messageText); + + // Hashes should match! + expect(info!.targetHash, equals(bobHash)); + expect(info.emoji, equals(emoji)); + }); + + test('room server: hash differs from 1:1 hash for same message content', () { + // Same timestamp and text, but room server includes sender name + const timestamp = 1234567890; + const senderName = 'Dave'; + const messageText = 'Hello'; + + // Room server hash (with sender name) + final roomHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); + + // 1:1 hash (without sender name) + final directHash = ReactionHelper.computeReactionHash(timestamp, null, messageText); + + // They should be different! + expect(roomHash, isNot(equals(directHash))); + }); + + test('room server: different senders produce different hashes', () { + // Two users send the exact same message at the same time in a room + const timestamp = 1234567890; + const messageText = 'Hello'; + + final aliceHash = ReactionHelper.computeReactionHash(timestamp, 'Alice', messageText); + final bobHash = ReactionHelper.computeReactionHash(timestamp, 'Bob', messageText); + + // Different senders = different hashes (even with same content) + expect(aliceHash, isNot(equals(bobHash))); + }); + + test('room server: self message reaction works', () { + // Alice sends "My message" at timestamp 2222222222 + // Bob wants to react to it + const timestamp = 2222222222; + const aliceName = 'Alice'; + const messageText = 'My message'; + const emoji = '๐Ÿ‘'; + + // Bob computes hash for Alice's message + final bobHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText); + final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; + final reactionText = 'r:$bobHash:$emojiIndex'; + + // Alice receives the reaction and matches against her outgoing message + final info = ReactionHelper.parseReaction(reactionText); + expect(info, isNotNull); + + // Alice computes hash using her selfName + final aliceHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText); + + // Hashes should match! + expect(info!.targetHash, equals(aliceHash)); + }); + + test('channel: same logic as room server', () { + // Channel messages also use sender name in hash + const timestamp = 3333333333; + const senderName = 'Eve'; + const messageText = 'Channel msg'; + const emoji = '๐Ÿ”ฅ'; + + // Compute hash with sender name + final hash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); + final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; + final reactionText = 'r:$hash:$emojiIndex'; + + // Parse and verify + final info = ReactionHelper.parseReaction(reactionText); + expect(info, isNotNull); + expect(info!.emoji, equals(emoji)); + + // Another user computes the same hash + final otherUserHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); + expect(info.targetHash, equals(otherUserHash)); + }); + }); + }); +}