mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-04 15:03:00 +10:00
feat: optimize reaction message format to reduce airtime
- 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
This commit is contained in:
@@ -67,9 +67,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
final Map<int, List<ChannelMessage>> _channelMessages = {};
|
||||||
final Set<String> _loadedConversationKeys = {};
|
final Set<String> _loadedConversationKeys = {};
|
||||||
final Map<int, Set<String>> _processedChannelReactions =
|
final Map<int, Set<String>> _processedChannelReactions =
|
||||||
{}; // channelIndex -> Set of "reactionKey_emoji"
|
{}; // channelIndex -> Set of "targetHash_emoji"
|
||||||
final Map<String, Set<String>> _processedContactReactions =
|
final Map<String, Set<String>> _processedContactReactions =
|
||||||
{}; // contactPubKeyHex -> Set of "reactionKey_emoji"
|
{}; // contactPubKeyHex -> Set of "targetHash_emoji"
|
||||||
|
|
||||||
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||||
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
|
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
|
||||||
@@ -1288,15 +1288,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
if (reactionInfo != null) {
|
if (reactionInfo != null) {
|
||||||
// Check if we've already processed this reaction
|
// Check if we've already processed this reaction
|
||||||
_processedChannelReactions.putIfAbsent(channel.index, () => {});
|
_processedChannelReactions.putIfAbsent(channel.index, () => {});
|
||||||
final reactionKey = reactionInfo.reactionKey;
|
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||||
final reactionIdentifier = reactionKey != null
|
|
||||||
? '${reactionKey}_${reactionInfo.emoji}'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (reactionIdentifier != null &&
|
if (_processedChannelReactions[channel.index]!.contains(reactionIdentifier)) {
|
||||||
_processedChannelReactions[channel.index]!.contains(
|
|
||||||
reactionIdentifier,
|
|
||||||
)) {
|
|
||||||
// Already processed, don't process again
|
// Already processed, don't process again
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1310,9 +1304,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
await _channelMessageStore.saveChannelMessages(channel.index, messages);
|
await _channelMessageStore.saveChannelMessages(channel.index, messages);
|
||||||
|
|
||||||
// Mark this reaction as processed
|
// Mark this reaction as processed
|
||||||
if (reactionIdentifier != null) {
|
_processedChannelReactions[channel.index]!.add(reactionIdentifier);
|
||||||
_processedChannelReactions[channel.index]!.add(reactionIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
@@ -2688,26 +2680,20 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
// Parse reaction info
|
// Parse reaction info
|
||||||
final reactionInfo = Message.parseReaction(message.text);
|
final reactionInfo = Message.parseReaction(message.text);
|
||||||
if (reactionInfo != null) {
|
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, () => {});
|
_processedContactReactions.putIfAbsent(pubKeyHex, () => {});
|
||||||
final reactionKey = reactionInfo.reactionKey;
|
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||||
final reactionIdentifier = reactionKey != null
|
|
||||||
? '${reactionKey}_${reactionInfo.emoji}'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final isDuplicate =
|
final isDuplicate =
|
||||||
reactionIdentifier != null &&
|
|
||||||
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
|
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
|
||||||
|
|
||||||
if (!isDuplicate) {
|
if (!isDuplicate) {
|
||||||
// New reaction - process it
|
// New reaction - process it
|
||||||
_processContactReaction(messages, reactionInfo);
|
_processContactReaction(messages, reactionInfo, pubKeyHex);
|
||||||
_messageStore.saveMessages(pubKeyHex, messages);
|
_messageStore.saveMessages(pubKeyHex, messages);
|
||||||
|
|
||||||
// Mark as processed
|
// Mark as processed
|
||||||
if (reactionIdentifier != null) {
|
_processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
|
||||||
_processedContactReactions[pubKeyHex]!.add(reactionIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -2722,15 +2708,51 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
void _processContactReaction(
|
void _processContactReaction(
|
||||||
List<Message> messages,
|
List<Message> messages,
|
||||||
ReactionInfo reactionInfo,
|
ReactionInfo reactionInfo,
|
||||||
|
String contactPubKeyHex,
|
||||||
) {
|
) {
|
||||||
// Find target message by messageId
|
// Find target message by computing hash and comparing
|
||||||
for (int i = 0; i < messages.length; i++) {
|
final targetHash = reactionInfo.targetHash;
|
||||||
if (messages[i].messageId == reactionInfo.targetMessageId) {
|
final contact = _contacts.cast<Contact?>().firstWhere(
|
||||||
final currentReactions = Map<String, int>.from(messages[i].reactions);
|
(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<Contact?>().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<String, int>.from(msg.reactions);
|
||||||
currentReactions[reactionInfo.emoji] =
|
currentReactions[reactionInfo.emoji] =
|
||||||
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
||||||
|
|
||||||
messages[i] = messages[i].copyWith(reactions: currentReactions);
|
messages[i] = msg.copyWith(reactions: currentReactions);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2881,18 +2903,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
// Parse reaction info
|
// Parse reaction info
|
||||||
final reactionInfo = ChannelMessage.parseReaction(message.text);
|
final reactionInfo = ChannelMessage.parseReaction(message.text);
|
||||||
if (reactionInfo != null) {
|
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, () => {});
|
_processedChannelReactions.putIfAbsent(channelIndex, () => {});
|
||||||
final reactionKey = reactionInfo.reactionKey;
|
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}';
|
||||||
final reactionIdentifier = reactionKey != null
|
|
||||||
? '${reactionKey}_${reactionInfo.emoji}'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final isDuplicate =
|
final isDuplicate =
|
||||||
reactionIdentifier != null &&
|
_processedChannelReactions[channelIndex]!.contains(reactionIdentifier);
|
||||||
_processedChannelReactions[channelIndex]!.contains(
|
|
||||||
reactionIdentifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isDuplicate) {
|
if (!isDuplicate) {
|
||||||
// New reaction - process it
|
// New reaction - process it
|
||||||
@@ -2901,9 +2917,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_channelMessageStore.saveChannelMessages(channelIndex, messages);
|
_channelMessageStore.saveChannelMessages(channelIndex, messages);
|
||||||
|
|
||||||
// Mark as processed
|
// Mark as processed
|
||||||
if (reactionIdentifier != null) {
|
_processedChannelReactions[channelIndex]!.add(reactionIdentifier);
|
||||||
_processedChannelReactions[channelIndex]!.add(reactionIdentifier);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false; // Don't add reaction as a visible message
|
return false; // Don't add reaction as a visible message
|
||||||
}
|
}
|
||||||
@@ -2999,14 +3013,22 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
List<ChannelMessage> messages,
|
List<ChannelMessage> messages,
|
||||||
ReactionInfo reactionInfo,
|
ReactionInfo reactionInfo,
|
||||||
) {
|
) {
|
||||||
// Find target message by messageId
|
// Find target message by computing hash and comparing
|
||||||
for (int i = 0; i < messages.length; i++) {
|
final targetHash = reactionInfo.targetHash;
|
||||||
if (messages[i].messageId == reactionInfo.targetMessageId) {
|
for (int i = messages.length - 1; i >= 0; i--) {
|
||||||
final currentReactions = Map<String, int>.from(messages[i].reactions);
|
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<String, int>.from(msg.reactions);
|
||||||
currentReactions[reactionInfo.emoji] =
|
currentReactions[reactionInfo.emoji] =
|
||||||
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
|
||||||
|
|
||||||
messages[i] = messages[i].copyWith(reactions: currentReactions);
|
messages[i] = msg.copyWith(reactions: currentReactions);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,70 @@
|
|||||||
|
import '../widgets/emoji_picker.dart';
|
||||||
|
|
||||||
class ReactionInfo {
|
class ReactionInfo {
|
||||||
final String targetMessageId;
|
final String targetHash;
|
||||||
final String emoji;
|
final String emoji;
|
||||||
final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix
|
|
||||||
|
|
||||||
ReactionInfo({
|
ReactionInfo({
|
||||||
required this.targetMessageId,
|
required this.targetHash,
|
||||||
required this.emoji,
|
required this.emoji,
|
||||||
this.reactionKey,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReactionHelper {
|
class ReactionHelper {
|
||||||
/// Parse reaction format: r:[messageId]:[emoji]
|
static List<String>? _cachedEmojis;
|
||||||
/// Supports both old format (full messageId) and new format (timestamp_senderPrefix)
|
|
||||||
|
/// Combined list of all reaction emojis in fixed order.
|
||||||
|
/// Order must stay stable for index compatibility.
|
||||||
|
static List<String> 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) {
|
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);
|
final match = regex.firstMatch(text);
|
||||||
if (match == null) return null;
|
if (match == null) return null;
|
||||||
|
|
||||||
final targetId = match.group(1)!;
|
final emoji = indexToEmoji(match.group(2)!);
|
||||||
final emoji = match.group(2)!;
|
if (emoji == null) return null;
|
||||||
|
|
||||||
// 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]}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReactionInfo(
|
return ReactionInfo(
|
||||||
targetMessageId: targetId,
|
targetHash: match.group(1)!,
|
||||||
emoji: emoji,
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../connector/meshcore_connector.dart';
|
|||||||
import '../helpers/chat_scroll_controller.dart';
|
import '../helpers/chat_scroll_controller.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
import '../helpers/link_handler.dart';
|
import '../helpers/link_handler.dart';
|
||||||
|
import '../helpers/reaction_helper.dart';
|
||||||
import '../helpers/utf8_length_limiter.dart';
|
import '../helpers/utf8_length_limiter.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
@@ -877,14 +878,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
_setReplyingTo(message);
|
_setReplyingTo(message);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
// Can't react to your own messages
|
||||||
leading: const Icon(Icons.add_reaction_outlined),
|
if (!message.isOutgoing)
|
||||||
title: Text(context.l10n.chat_addReaction),
|
ListTile(
|
||||||
onTap: () {
|
leading: const Icon(Icons.add_reaction_outlined),
|
||||||
Navigator.pop(sheetContext);
|
title: Text(context.l10n.chat_addReaction),
|
||||||
_showEmojiPicker(message);
|
onTap: () {
|
||||||
},
|
Navigator.pop(sheetContext);
|
||||||
),
|
_showEmojiPicker(message);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: Text(context.l10n.common_copy),
|
title: Text(context.l10n.common_copy),
|
||||||
@@ -926,9 +929,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
|
|
||||||
void _sendReaction(ChannelMessage message, String emoji) {
|
void _sendReaction(ChannelMessage message, String emoji) {
|
||||||
final connector = context.read<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
// Send reaction with full messageId to find target, but parser will extract
|
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
||||||
// lightweight reactionKey (timestamp_senderPrefix) for deduplication
|
if (emojiIndex == null) return; // Unknown emoji, skip
|
||||||
final reactionText = 'r:${message.messageId}:$emoji';
|
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);
|
connector.sendChannelMessage(widget.channel, reactionText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import '../helpers/reaction_helper.dart';
|
||||||
import '../helpers/chat_scroll_controller.dart';
|
import '../helpers/chat_scroll_controller.dart';
|
||||||
import '../helpers/link_handler.dart';
|
import '../helpers/link_handler.dart';
|
||||||
import '../helpers/utf8_length_limiter.dart';
|
import '../helpers/utf8_length_limiter.dart';
|
||||||
@@ -850,14 +851,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
// Can't react to your own messages
|
||||||
leading: const Icon(Icons.add_reaction_outlined),
|
if (!message.isOutgoing)
|
||||||
title: Text(context.l10n.chat_addReaction),
|
ListTile(
|
||||||
onTap: () {
|
leading: const Icon(Icons.add_reaction_outlined),
|
||||||
Navigator.pop(sheetContext);
|
title: Text(context.l10n.chat_addReaction),
|
||||||
_showEmojiPicker(message);
|
onTap: () {
|
||||||
},
|
Navigator.pop(sheetContext);
|
||||||
),
|
_showEmojiPicker(message, contact);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copy),
|
leading: const Icon(Icons.copy),
|
||||||
title: Text(context.l10n.common_copy),
|
title: Text(context.l10n.common_copy),
|
||||||
@@ -931,25 +934,29 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showEmojiPicker(Message message) {
|
void _showEmojiPicker(Message message, Contact senderContact) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (context) => EmojiPicker(
|
builder: (context) => EmojiPicker(
|
||||||
onEmojiSelected: (emoji) {
|
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<MeshCoreConnector>();
|
final connector = context.read<MeshCoreConnector>();
|
||||||
// Send reaction with messageId if available, otherwise use lightweight format
|
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
||||||
// Parser will extract reactionKey (timestamp_senderPrefix) for deduplication
|
if (emojiIndex == null) return; // Unknown emoji, skip
|
||||||
final messageId = message.messageId ??
|
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
||||||
'${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}';
|
|
||||||
final reactionText = 'r:$messageId:$emoji';
|
// 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);
|
connector.sendMessage(widget.contact, reactionText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,32 +12,32 @@ class EmojiPicker extends StatelessWidget {
|
|||||||
|
|
||||||
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
|
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
|
||||||
|
|
||||||
static const List<String> _smileys = [
|
static const List<String> smileys = [
|
||||||
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘',
|
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘',
|
||||||
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏',
|
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏',
|
||||||
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
|
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
|
||||||
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
|
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
|
||||||
];
|
];
|
||||||
static const List<String> _gestures = [
|
static const List<String> gestures = [
|
||||||
'👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆',
|
'👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆',
|
||||||
'👇', '☝️', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳',
|
'👇', '☝️', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪',
|
||||||
];
|
];
|
||||||
static const List<String> _hearts = [
|
static const List<String> hearts = [
|
||||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️🔥', '❤️🩹', '💕', '💞', '💓', '💗',
|
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️🔥', '❤️🩹', '💕', '💞', '💓', '💗',
|
||||||
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️🗨️', '🗨️', '🗯️', '💭',
|
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️🗨️', '🗨️', '🗯️', '💭',
|
||||||
];
|
];
|
||||||
static const List<String> _objects = [
|
static const List<String> objects = [
|
||||||
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '⚽', '⚾', '🥎', '🏀', '🏐',
|
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '⚽', '⚾', '🥎', '🏀', '🏐',
|
||||||
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '⛳', '🔥',
|
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '⛳', '🔥',
|
||||||
'⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔', '📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶',
|
'⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔', '📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶', '🚀',
|
||||||
];
|
];
|
||||||
|
|
||||||
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
|
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
|
||||||
return {
|
return {
|
||||||
l10n.emojiCategorySmileys: _smileys,
|
l10n.emojiCategorySmileys: smileys,
|
||||||
l10n.emojiCategoryGestures: _gestures,
|
l10n.emojiCategoryGestures: gestures,
|
||||||
l10n.emojiCategoryHearts: _hearts,
|
l10n.emojiCategoryHearts: hearts,
|
||||||
l10n.emojiCategoryObjects: _objects,
|
l10n.emojiCategoryObjects: objects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import flutter_blue_plus_darwin
|
|||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import mobile_scanner
|
import mobile_scanner
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import path_provider_foundation
|
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
@@ -20,7 +19,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
+68
-12
@@ -97,6 +97,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -157,10 +165,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.1.5"
|
||||||
file:
|
file:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -234,10 +242,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_blue_plus_winrt
|
name: flutter_blue_plus_winrt
|
||||||
sha256: "0c87ca5bdf1a110d42847edeca8fbb11a9701738dc8526aefbb2a115bea29aef"
|
sha256: "34be2d8e23d5881b46accebb0e71025f7d52869d72ea98b5082c20764e06aa80"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.10"
|
version: "0.0.16"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -325,6 +333,22 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -369,10 +393,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.9.0"
|
version: "4.10.0"
|
||||||
latlong2:
|
latlong2:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -437,6 +461,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.2"
|
version: "2.6.2"
|
||||||
|
logging:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -477,6 +509,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.11"
|
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:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -485,6 +525,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
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:
|
octo_image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -537,10 +585,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.1"
|
version: "2.6.0"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -629,6 +677,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
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:
|
qr:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -665,10 +721,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
|
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.18"
|
version: "2.4.20"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -987,5 +1043,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.0 <4.0.0"
|
dart: ">=3.10.3 <4.0.0"
|
||||||
flutter: ">=3.38.0"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user