feat(#383): track who reacted, not just the count

Reactions stored only a Map<String,int> 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<String,List<String>>,
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 <noreply@anthropic.com>
pull/419/head
Strycher 2 months ago
parent 646dc79d86
commit e452ab4805

@ -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<Contact?>().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<Message> messages,
ReactionInfo reactionInfo,
String contactPubKeyHex,
String reactingSender,
) {
final contact = _contacts.cast<Contact?>().firstWhere(
(c) => c?.publicKeyHex == contactPubKeyHex,
@ -6615,6 +6631,7 @@ class MeshCoreConnector extends ChangeNotifier {
return ReactionHelper.applyReaction<Message>(
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<Message>(
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<ChannelMessage> messages,
ReactionInfo reactionInfo,
String reactingSender,
) {
return ReactionHelper.applyReaction<ChannelMessage>(
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();
},
);

@ -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 '

@ -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<String, List<String>> reactionSendersFromJson(Object? raw) {
if (raw is! Map) return {};
final result = <String, List<String>>{};
raw.forEach((key, value) {
if (key is String && value is List) {
result[key] = value.whereType<String>().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<T>({
required List<T> messages,
required ReactionInfo reactionInfo,
required String reactingSender,
required int Function(T) getTimestampSecs,
required String? Function(T) getSenderName,
required String Function(T) getMessageText,
required Map<String, int> Function(T) getReactions,
required Map<String, List<String>> Function(T) getReactionSenders,
required bool Function(T) shouldSkip,
required void Function(int index, Map<String, int> newReactions)
required void Function(
int index,
Map<String, int> newReactions,
Map<String, List<String>> 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 = <String, List<String>>{
for (final e in getReactionSenders(msg).entries)
e.key: List<String>.from(e.value),
};
final list = senders.putIfAbsent(emoji, () => <String>[]);
if (list.contains(reactingSender)) {
// Already recorded this reactor for this emoji; matched, no change.
return true;
}
list.add(reactingSender);
final currentReactions = Map<String, int>.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;
}
}

@ -51,6 +51,14 @@ class ChannelMessage {
final String? replyToText;
final Map<String, int> 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<String, List<String>> 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<String, int>? reactions,
Map<String, List<String>>? 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<String, int>? reactions,
Map<String, List<String>>? 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,
);
}

@ -31,6 +31,14 @@ class Message {
final int? pathLength;
final Uint8List pathBytes;
final Map<String, int> 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<String, List<String>> reactionSenders;
final Map<String, MessageStatus> reactionStatuses;
final Uint8List fourByteRoomContactKey;
@ -62,6 +70,7 @@ class Message {
Uint8List? pathBytes,
Uint8List? fourByteRoomContactKey,
Map<String, int>? reactions,
Map<String, List<String>>? reactionSenders,
Map<String, MessageStatus>? 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<String, int>? reactions,
Map<String, List<String>>? reactionSenders,
Map<String, MessageStatus>? 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,

@ -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'],
),
);
}

@ -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<String, dynamic>?)?.map(
(key, value) => MapEntry(key, MessageStatus.values[value as int]),

@ -20,12 +20,17 @@ void main() {
pending.add('channel:0', _info('dyps6yf0'), 'Node', now);
final applied = <String>[];
pending.retry('channel:0', (info) {
final reactors = <String>[];
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 = <String>[];
pending.retry('channel:0', (info) {
pending.retry('channel:0', (info, _) {
seen.add(info.targetHash);
return true;
}, now.add(const Duration(minutes: 1)));

@ -7,31 +7,43 @@ class _Msg {
final String senderName;
final String text;
Map<String, int> reactions = {};
Map<String, List<String>> 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<String, int>? reactions}) _apply(
List<_Msg> messages,
ReactionInfo info,
) {
Map<String, int>? updated;
/// count and sender maps of whichever message was updated.
({
bool matched,
Map<String, int>? reactions,
Map<String, List<String>>? senders,
})
_apply(List<_Msg> messages, ReactionInfo info, {String reactingSender = 'R'}) {
Map<String, int>? updatedReactions;
Map<String, List<String>>? 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)!,
);

@ -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 = <String, List<String>>{
'\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'],
});
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.