From e452ab48058313308982fc6e9c23d472902fd4a7 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 27 Jul 2026 02:00:53 -0400 Subject: [PATCH] feat(#383): track who reacted, not just the count Reactions stored only a Map of emoji to count, so "who reacted" was unanswerable and MeshCore One's tap-to-see-who had nothing to show against. This captures the reactor for every reaction, both our own r: format and the PocketMesh / MeshCore One format. Data layer only. The tap-a-badge-to-see-who UI is a separate follow-on; this makes the data available and does not change any screen. Additive by design. A new reactionSenders field (Map>, emoji -> reactor names) sits alongside the existing count map, serialised under a new JSON key. Both maps are always written. An older build reading a newer store ignores the unknown key and still gets correct counts from reactions; it never hits the hard `value as int` cast that would fail the whole message-list load (the #355 data-loss shape). Records written before this field load with an empty sender map, so their counts survive with names simply absent. - ChannelMessage + Message: new reactionSenders field, constructor, copyWith - both stores: serialise the new key; deserialise via a shared ReactionHelper.reactionSendersFromJson that returns empty for a missing key and skips malformed entries rather than throwing - applyReaction: records the reactor and dedups per reactor per emoji. This is persistent dedup (survives restart), unlike the connector's in-memory processed-set. A pre-existing count with no sender list is incremented from its stored value, not recomputed from the partial list, so old counts are preserved. - connector: threads the reactor name into all reaction paths. Channel uses the frame sender; a room resolves the author via fourByteRoomContactKey; an outgoing reaction is attributed to self. - pending queue: retry now hands the stored reactor to its callback Tests: reactor capture, two-reactor count, same-reactor no-double-count, pre-#383 count preservation, and JSON round-trip + backward-compat + malformed-entry handling for the new field. Full suite 604 passing. Epic #376. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 68 +++++++++++++---- lib/helpers/pending_reactions.dart | 13 +++- lib/helpers/reaction_helper.dart | 54 ++++++++++++-- lib/models/channel_message.dart | 12 +++ lib/models/message.dart | 12 +++ lib/storage/channel_message_store.dart | 5 ++ lib/storage/message_store.dart | 5 ++ test/helpers/pending_reactions_test.dart | 13 +++- test/helpers/reaction_dialect_test.dart | 78 +++++++++++++++++--- test/helpers/reaction_senders_json_test.dart | 44 +++++++++++ 10 files changed, 264 insertions(+), 40 deletions(-) create mode 100644 test/helpers/reaction_senders_json_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index f8f0018..63f33e2 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3610,8 +3610,9 @@ class MeshCoreConnector extends ChangeNotifier { _channelMessages.putIfAbsent(channel.index, () => []); final messages = _channelMessages[channel.index]!; - // Process reaction locally to update the UI immediately - _processReaction(messages, reactionInfo); + // Process reaction locally to update the UI immediately. This is our own + // outgoing reaction, so the reactor is us. + _processReaction(messages, reactionInfo, _selfName ?? 'Me'); await _channelMessageStore.saveChannelMessages(channel.index, messages); // Mark this reaction as processed @@ -6551,11 +6552,23 @@ class MeshCoreConnector extends ChangeNotifier { // the key or two members sending the same emoji collapse into one; in a // true 1:1 the author prefix is empty and the key is unchanged. _processedContactReactions.putIfAbsent(pubKeyHex, () => {}); - final reactingAuthor = message.fourByteRoomContactKey + // The reactor's display name: in a room the frame's own author field + // identifies them; in a true 1:1 it is simply the contact. Falls back to + // the hex author prefix, then the pubkey, so it is never empty (#383). + final reactionContact = _contacts.cast().firstWhere( + (c) => c?.publicKeyHex == pubKeyHex, + orElse: () => null, + ); + final isRoomReaction = reactionContact?.type == advTypeRoom; + final reactingAuthorHex = message.fourByteRoomContactKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); + final reactorName = + _resolveContactSenderName(message, reactionContact, isRoomReaction) ?? + reactionContact?.name ?? + (reactingAuthorHex.isNotEmpty ? reactingAuthorHex : pubKeyHex); final reactionIdentifier = - '${reactionInfo.targetHash}_${reactionInfo.emoji}_$reactingAuthor'; + '${reactionInfo.targetHash}_${reactionInfo.emoji}_$reactingAuthorHex'; final isDuplicate = _processedContactReactions[pubKeyHex]!.contains( reactionIdentifier, @@ -6567,13 +6580,14 @@ class MeshCoreConnector extends ChangeNotifier { messages, reactionInfo, pubKeyHex, + reactorName, ); if (!matched) { // Early arrival, not junk. Hold it for the target (#382). _pendingReactions.add( _contactScopeKey(pubKeyHex), reactionInfo, - pubKeyHex, + reactorName, DateTime.now(), ); } @@ -6592,7 +6606,8 @@ class MeshCoreConnector extends ChangeNotifier { // its target (#382). _pendingReactions.retry( _contactScopeKey(pubKeyHex), - (info) => _processContactReaction(messages, info, pubKeyHex), + (info, reactingSender) => + _processContactReaction(messages, info, pubKeyHex, reactingSender), DateTime.now(), ); _messageStore.saveMessages(pubKeyHex, messages); @@ -6605,6 +6620,7 @@ class MeshCoreConnector extends ChangeNotifier { List messages, ReactionInfo reactionInfo, String contactPubKeyHex, + String reactingSender, ) { final contact = _contacts.cast().firstWhere( (c) => c?.publicKeyHex == contactPubKeyHex, @@ -6615,6 +6631,7 @@ class MeshCoreConnector extends ChangeNotifier { return ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + reactingSender: reactingSender, // Incoming reactions in 1:1: match against outgoing messages only shouldSkip: (msg) => isRoomServer != true && !msg.isOutgoing, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, @@ -6622,8 +6639,12 @@ class MeshCoreConnector extends ChangeNotifier { _resolveContactSenderName(msg, contact, isRoomServer == true), getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); }, ); } @@ -6638,6 +6659,8 @@ class MeshCoreConnector extends ChangeNotifier { ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + // Our own outgoing reaction, so the reactor is us. + reactingSender: _selfName ?? 'Me', // Outgoing reactions in 1:1: match against incoming messages shouldSkip: (msg) => !isRoomServer && msg.isOutgoing, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, @@ -6645,8 +6668,12 @@ class MeshCoreConnector extends ChangeNotifier { _resolveContactSenderName(msg, contact, isRoomServer), getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); }, ); } @@ -6836,8 +6863,12 @@ class MeshCoreConnector extends ChangeNotifier { ); if (!isDuplicate) { - // New reaction - process it - final matched = _processReaction(messages, reactionInfo); + // New reaction - process it. The reactor is the frame's sender. + final matched = _processReaction( + messages, + reactionInfo, + message.senderName, + ); if (!matched) { // Early arrival, not junk. Hold it for the target (#382). _pendingReactions.add( @@ -6905,7 +6936,8 @@ class MeshCoreConnector extends ChangeNotifier { // its target (#382). _pendingReactions.retry( _channelScopeKey(channelIndex), - (info) => _processReaction(messages, info), + (info, reactingSender) => + _processReaction(messages, info, reactingSender), DateTime.now(), ); @@ -6926,17 +6958,23 @@ class MeshCoreConnector extends ChangeNotifier { bool _processReaction( List messages, ReactionInfo reactionInfo, + String reactingSender, ) { return ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + reactingSender: reactingSender, shouldSkip: (_) => false, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, getSenderName: (msg) => msg.senderName, getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); notifyListeners(); }, ); diff --git a/lib/helpers/pending_reactions.dart b/lib/helpers/pending_reactions.dart index 43d3870..098a841 100644 --- a/lib/helpers/pending_reactions.dart +++ b/lib/helpers/pending_reactions.dart @@ -47,13 +47,18 @@ class PendingReactions { ); } - /// Re-attempt every live entry for [scopeKey]. [apply] reports whether the - /// reaction found its target; matched entries are removed, the rest stay. - void retry(String scopeKey, bool Function(ReactionInfo) apply, DateTime now) { + /// Re-attempt every live entry for [scopeKey]. [apply] receives the queued + /// reaction and the name of whoever sent it, and reports whether it found its + /// target; matched entries are removed, the rest stay. + void retry( + String scopeKey, + bool Function(ReactionInfo info, String reactingSender) apply, + DateTime now, + ) { expire(now); _entries.removeWhere((entry) { if (entry.scopeKey != scopeKey) return false; - if (!apply(entry.info)) return false; + if (!apply(entry.info, entry.reactingSender)) return false; appLogger.info( 'Late-matched reaction ${entry.info.emoji} from ${entry.reactingSender} ' 'to ${entry.info.targetHash} in $scopeKey after ' diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 15ecb45..d236397 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -35,6 +35,23 @@ class ReactionInfo { } class ReactionHelper { + /// Deserialize the additive `reactionSenders` map from stored JSON (#383). + /// + /// Returns an empty map for records written before the field existed, so an + /// old store loads cleanly (its counts still come from the `reactions` key). + /// Malformed or wrongly-typed entries are skipped rather than thrown, because + /// a single bad entry must never fail the whole message-list load. + static Map> reactionSendersFromJson(Object? raw) { + if (raw is! Map) return {}; + final result = >{}; + raw.forEach((key, value) { + if (key is String && value is List) { + result[key] = value.whereType().toList(); + } + }); + return result; + } + /// Apply a reaction to a list of messages by matching the reaction hash. /// /// [messages] - the message list to search @@ -43,23 +60,40 @@ class ReactionHelper { /// [getSenderName] - extract sender name for hash (null for 1:1 implicit) /// [getMessageText] - extract message text /// [getReactions] - extract current reactions map + /// [getReactionSenders] - extract current emoji->reactor-names map (#383) + /// [reactingSender] - the name of whoever sent this reaction /// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions) - /// [updateMessage] - callback to update the message at index with new reactions + /// [updateMessage] - callback to update the message at index with the new + /// count map and the new sender map /// /// Returns whether a match was found. + /// + /// [reactionSenders] is the persistent per-reactor record. A given reactor is + /// counted once per emoji: if their name is already in the list, the reaction + /// is a no-op on both maps (this survives restart, unlike the connector's + /// in-memory dedup set). Counts recorded before #383 have no sender list, so + /// the count is still incremented from its stored value rather than being + /// recomputed from the (partial) sender list, which would lose those. static bool applyReaction({ required List messages, required ReactionInfo reactionInfo, + required String reactingSender, required int Function(T) getTimestampSecs, required String? Function(T) getSenderName, required String Function(T) getMessageText, required Map Function(T) getReactions, + required Map> Function(T) getReactionSenders, required bool Function(T) shouldSkip, - required void Function(int index, Map newReactions) + required void Function( + int index, + Map newReactions, + Map> newSenders, + ) updateMessage, }) { final targetHash = reactionInfo.targetHash; final targetSender = reactionInfo.targetSenderName; + final emoji = reactionInfo.emoji; for (int i = messages.length - 1; i >= 0; i--) { final msg = messages[i]; if (shouldSkip(msg)) continue; @@ -81,10 +115,20 @@ class ReactionHelper { ), }; if (msgHash == targetHash) { + final senders = >{ + for (final e in getReactionSenders(msg).entries) + e.key: List.from(e.value), + }; + final list = senders.putIfAbsent(emoji, () => []); + if (list.contains(reactingSender)) { + // Already recorded this reactor for this emoji; matched, no change. + return true; + } + list.add(reactingSender); + final currentReactions = Map.from(getReactions(msg)); - currentReactions[reactionInfo.emoji] = - (currentReactions[reactionInfo.emoji] ?? 0) + 1; - updateMessage(i, currentReactions); + currentReactions[emoji] = (currentReactions[emoji] ?? 0) + 1; + updateMessage(i, currentReactions, senders); return true; } } diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 886bab3..7ec8cf0 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -51,6 +51,14 @@ class ChannelMessage { final String? replyToText; final Map reactions; + /// Emoji to the list of sender names who reacted with it (#383). + /// + /// Additive over [reactions]: the count map is still written and read so an + /// older build degrades to "counts work, names missing" rather than failing + /// to load. Reactions recorded before this field existed appear in + /// [reactions] with no matching entry here. + final Map> reactionSenders; + /// Local wall-clock time this message's frame arrived, set at ingest. /// Null for outgoing messages and records stored before #285 — never /// fabricated. [timestamp] is the SENDER's claimed time; this is ours. @@ -80,11 +88,13 @@ class ChannelMessage { this.replyToSenderName, this.replyToText, Map? reactions, + Map>? reactionSenders, this.rxTime, }) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}', reactions = reactions ?? {}, + reactionSenders = reactionSenders ?? {}, pathBytes = pathBytes ?? Uint8List(0), pathVariants = _mergePathVariants( pathBytes ?? Uint8List(0), @@ -129,6 +139,7 @@ class ChannelMessage { MessageTranslationStatus? translationStatus, Object? translationModelId = _unset, Map? reactions, + Map>? reactionSenders, DateTime? rxTime, }) { return ChannelMessage( @@ -163,6 +174,7 @@ class ChannelMessage { replyToSenderName: replyToSenderName ?? this.replyToSenderName, replyToText: replyToText ?? this.replyToText, reactions: reactions ?? this.reactions, + reactionSenders: reactionSenders ?? this.reactionSenders, rxTime: rxTime ?? this.rxTime, ); } diff --git a/lib/models/message.dart b/lib/models/message.dart index e91fb56..37e0964 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -31,6 +31,14 @@ class Message { final int? pathLength; final Uint8List pathBytes; final Map reactions; + + /// Emoji to the list of sender names who reacted with it (#383). + /// + /// Additive over [reactions]: the count map is still written and read so an + /// older build degrades to "counts work, names missing" rather than failing + /// to load. Reactions recorded before this field existed appear in + /// [reactions] with no matching entry here. + final Map> reactionSenders; final Map reactionStatuses; final Uint8List fourByteRoomContactKey; @@ -62,6 +70,7 @@ class Message { Uint8List? pathBytes, Uint8List? fourByteRoomContactKey, Map? reactions, + Map>? reactionSenders, Map? reactionStatuses, this.rxTime, }) : messageId = @@ -70,6 +79,7 @@ class Message { pathBytes = pathBytes ?? Uint8List(0), fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), reactions = reactions ?? {}, + reactionSenders = reactionSenders ?? {}, reactionStatuses = reactionStatuses ?? {}; String get senderKeyHex => pubKeyToHex(senderKey); @@ -91,6 +101,7 @@ class Message { MessageTranslationStatus? translationStatus, Object? translationModelId = _unset, Map? reactions, + Map>? reactionSenders, Map? reactionStatuses, Uint8List? fourByteRoomContactKey, DateTime? rxTime, @@ -125,6 +136,7 @@ class Message { pathLength: pathLength ?? this.pathLength, pathBytes: pathBytes ?? this.pathBytes, reactions: reactions ?? this.reactions, + reactionSenders: reactionSenders ?? this.reactionSenders, reactionStatuses: reactionStatuses ?? this.reactionStatuses, fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey, diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index b80016f..8077d23 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -4,6 +4,7 @@ import 'package:meshcore_open/utils/app_logger.dart'; import '../models/channel_message.dart'; import '../models/translation_support.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; import 'drift/blob_store.dart'; @@ -267,6 +268,7 @@ class ChannelMessageStore { 'replyToSenderName': msg.replyToSenderName, 'replyToText': msg.replyToText, 'reactions': msg.reactions, + 'reactionSenders': msg.reactionSenders, }; } @@ -317,6 +319,9 @@ class ChannelMessageStore { (key, value) => MapEntry(key, value as int), ) ?? {}, + reactionSenders: ReactionHelper.reactionSendersFromJson( + json['reactionSenders'], + ), ); } diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 37b0575..4ad4c2b 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/message.dart'; import '../models/translation_support.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; import '../utils/app_logger.dart'; import 'drift/blob_store.dart'; @@ -168,6 +169,7 @@ class MessageStore { ? base64Encode(msg.pathBytes) : null, 'reactions': msg.reactions, + 'reactionSenders': msg.reactionSenders, 'reactionStatuses': msg.reactionStatuses.map( (key, value) => MapEntry(key, value.index), ), @@ -216,6 +218,9 @@ class MessageStore { (key, value) => MapEntry(key, value as int), ) ?? {}, + reactionSenders: ReactionHelper.reactionSendersFromJson( + json['reactionSenders'], + ), reactionStatuses: (json['reactionStatuses'] as Map?)?.map( (key, value) => MapEntry(key, MessageStatus.values[value as int]), diff --git a/test/helpers/pending_reactions_test.dart b/test/helpers/pending_reactions_test.dart index c3da6ef..4f57340 100644 --- a/test/helpers/pending_reactions_test.dart +++ b/test/helpers/pending_reactions_test.dart @@ -20,12 +20,17 @@ void main() { pending.add('channel:0', _info('dyps6yf0'), 'Node', now); final applied = []; - pending.retry('channel:0', (info) { + final reactors = []; + pending.retry('channel:0', (info, reactingSender) { applied.add(info.targetHash); + reactors.add(reactingSender); return true; }, now.add(const Duration(seconds: 5))); expect(applied, ['dyps6yf0']); + expect(reactors, [ + 'Node', + ], reason: 'the queued reactor is passed through'); expect(pending.length, 0); }); @@ -35,7 +40,7 @@ void main() { pending.retry( 'channel:0', - (_) => false, + (_, _) => false, now.add(const Duration(seconds: 5)), ); @@ -47,7 +52,7 @@ void main() { pending.add('channel:0', _info('dyps6yf0'), 'Node', now); var called = false; - pending.retry('channel:1', (_) { + pending.retry('channel:1', (_, _) { called = true; return true; }, now); @@ -90,7 +95,7 @@ void main() { expect(pending.length, PendingReactions.maxEntries); final seen = []; - pending.retry('channel:0', (info) { + pending.retry('channel:0', (info, _) { seen.add(info.targetHash); return true; }, now.add(const Duration(minutes: 1))); diff --git a/test/helpers/reaction_dialect_test.dart b/test/helpers/reaction_dialect_test.dart index 06aa5e1..5e53a5c 100644 --- a/test/helpers/reaction_dialect_test.dart +++ b/test/helpers/reaction_dialect_test.dart @@ -7,31 +7,43 @@ class _Msg { final String senderName; final String text; Map reactions = {}; + Map> reactionSenders = {}; _Msg(this.timestampSecs, this.senderName, this.text); } /// Runs applyReaction over [messages] and reports the match plus the resulting -/// reaction map of whichever message was updated. -({bool matched, Map? reactions}) _apply( - List<_Msg> messages, - ReactionInfo info, -) { - Map? updated; +/// count and sender maps of whichever message was updated. +({ + bool matched, + Map? reactions, + Map>? senders, +}) +_apply(List<_Msg> messages, ReactionInfo info, {String reactingSender = 'R'}) { + Map? updatedReactions; + Map>? updatedSenders; final matched = ReactionHelper.applyReaction<_Msg>( messages: messages, reactionInfo: info, + reactingSender: reactingSender, getTimestampSecs: (m) => m.timestampSecs, getSenderName: (m) => m.senderName, getMessageText: (m) => m.text, getReactions: (m) => m.reactions, + getReactionSenders: (m) => m.reactionSenders, shouldSkip: (_) => false, - updateMessage: (i, reactions) { + updateMessage: (i, reactions, senders) { messages[i].reactions = reactions; - updated = reactions; + messages[i].reactionSenders = senders; + updatedReactions = reactions; + updatedSenders = senders; }, ); - return (matched: matched, reactions: updated); + return ( + matched: matched, + reactions: updatedReactions, + senders: updatedSenders, + ); } void main() { @@ -124,7 +136,7 @@ void main() { expect(_apply(messages, info).matched, isFalse); }); - test('counts accumulate for the same emoji', () { + test('two different reactors count as 2 and both names are kept', () { final messages = [_Msg(capturedTs, capturedSender, capturedText)]; final info = ReactionInfo.pocketMesh( PocketMeshReaction.parse( @@ -133,10 +145,52 @@ void main() { )!, ); - _apply(messages, info); - final second = _apply(messages, info); + _apply(messages, info, reactingSender: 'Alice'); + final second = _apply(messages, info, reactingSender: 'Bob'); expect(second.reactions, {'\u{1F44D}': 2}); + expect(second.senders, { + '\u{1F44D}': ['Alice', 'Bob'], + }); + }); + + test('the same reactor with the same emoji does not double-count', () { + final messages = [_Msg(capturedTs, capturedSender, capturedText)]; + final info = ReactionInfo.pocketMesh( + PocketMeshReaction.parse( + '\u{1F44D}@[$capturedSender]\n$capturedHash', + isDm: false, + )!, + ); + + final first = _apply(messages, info, reactingSender: 'Alice'); + expect(first.reactions, {'\u{1F44D}': 1}); + + // Same reactor again: still matched, but no change to count or senders. + final again = _apply(messages, info, reactingSender: 'Alice'); + expect(again.matched, isTrue); + expect(messages.single.reactions, {'\u{1F44D}': 1}); + expect(messages.single.reactionSenders, { + '\u{1F44D}': ['Alice'], + }); + }); + + test('a pre-#383 count with no sender list is preserved, not reset', () { + final messages = [_Msg(capturedTs, capturedSender, capturedText)]; + // Simulate a message loaded from an old store: a count, no sender names. + messages.single.reactions = {'\u{1F44D}': 3}; + + final result = _apply(messages, _pm(capturedSender, capturedHash)); + + // The old three are kept and the new reactor adds one. + expect(result.reactions, {'\u{1F44D}': 4}); + expect(result.senders, { + '\u{1F44D}': ['R'], + }); }); }); } + +ReactionInfo _pm(String sender, String hash) => ReactionInfo.pocketMesh( + PocketMeshReaction.parse('\u{1F44D}@[$sender]\n$hash', isDm: false)!, +); diff --git a/test/helpers/reaction_senders_json_test.dart b/test/helpers/reaction_senders_json_test.dart new file mode 100644 index 0000000..78ef03e --- /dev/null +++ b/test/helpers/reaction_senders_json_test.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/reaction_helper.dart'; + +void main() { + group('ReactionHelper.reactionSendersFromJson (#383)', () { + test('round-trips a real sender map through JSON', () { + final original = >{ + '\u{1F44D}': ['Alice', 'Bob'], + '\u{2764}\u{FE0F}': ['Carol'], + }; + final decoded = ReactionHelper.reactionSendersFromJson( + jsonDecode(jsonEncode(original)), + ); + expect(decoded, original); + }); + + test('an old store with no reactionSenders key degrades to empty', () { + // This is the backward-compat guarantee: a record written before #383 + // has no such key, so the field is absent from the decoded JSON. + expect(ReactionHelper.reactionSendersFromJson(null), isEmpty); + }); + + test('a non-map value is ignored rather than thrown', () { + // A single bad entry must never fail the whole message-list load (#355). + expect(ReactionHelper.reactionSendersFromJson('garbage'), isEmpty); + expect(ReactionHelper.reactionSendersFromJson(42), isEmpty); + expect(ReactionHelper.reactionSendersFromJson(['a', 'b']), isEmpty); + }); + + test('malformed entries within a map are skipped, valid ones kept', () { + final decoded = ReactionHelper.reactionSendersFromJson({ + '\u{1F44D}': ['Alice', 42, null, 'Bob'], // non-strings dropped + '\u{1F525}': 'not a list', // whole entry dropped + '\u{1F389}': ['Dave'], + }); + expect(decoded, { + '\u{1F44D}': ['Alice', 'Bob'], + '\u{1F389}': ['Dave'], + }); + }); + }); +}