feat(#380): parse PocketMesh / MeshCore One emoji reactions

Reactions sent from MeshCore One arrived as junk text: an emoji line
followed by an 8-character token such as "dyps6yf0". Those tokens are
Crockford Base32 target-message hashes, the second line of a two-line
reaction payload we did not recognise.

Receive-side only. Offband keeps sending its own r:hhhh:ii format; their
client already parses ours, so nothing about what we transmit changes.

Wire format (confirmed against a live capture, see #378):
  channel: {emoji}@[{targetSender}]\n{hash}
  direct:  {emoji}\n{hash}
  hash:    sha256(body utf8 + timestamp uint32 LE seconds)[0:5],
           Crockford Base32, 8 chars, lowercase

The body is hashed without the channel "SenderName: " prefix, which is
why the sender travels in @[...] instead.

- crockford_base32.dart: encode and normalise, 12-bit accumulator so the
  web target's 32-bit bitwise ops cannot truncate a 40-bit value
- pocketmesh_reaction.dart: hash and a parser mirroring the reference
  implementation, with a conservative leading-emoji check so a real
  message is never swallowed
- reaction_helper.dart: ReactionInfo carries a dialect; applyReaction
  picks the matching hash and, for the channel form, requires an exact
  sender-name match (a node name can carry emoji and variation
  selectors)
- pending_reactions.dart: a reaction arriving before its target is held
  and retried rather than silently dropped, bounded at 50 entries with a
  15 minute TTL and a warn-level log on expiry (closes the silent-drop
  path in #382)
- the channel dedup key now includes the reacting sender, so two people
  sending the same emoji no longer collapse into one
- notification tray summarises the foreign format as a reaction instead
  of showing the raw token

Known limitation: on channels with Smaz or Cyr2Lat enabled the wire text
differs from the text we store, so a hash computed by another client
will not match. Flagged for a decision rather than worked around.

Epic #376. Fast-follow #383 adds reactor identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix/share-icon-android
Strycher 2 months ago
parent 1ecf1ce55d
commit 0e926ad0c2

@ -17,6 +17,8 @@ import '../models/message.dart';
import '../models/offband_gps_status.dart';
import '../models/path_selection.dart';
import '../models/translation_support.dart';
import '../helpers/pending_reactions.dart';
import '../helpers/pocketmesh_reaction.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/time_anomaly.dart';
import '../helpers/cyr2lat.dart';
@ -236,9 +238,10 @@ class MeshCoreConnector extends ChangeNotifier {
int _reactionSendQueueSequence = 0;
final Set<String> _loadedConversationKeys = {};
final Map<int, Set<String>> _processedChannelReactions =
{}; // channelIndex -> Set of "targetHash_emoji"
{}; // channelIndex -> Set of "targetHash_emoji_reactingSender"
final Map<String, Set<String>> _processedContactReactions =
{}; // contactPubKeyHex -> Set of "targetHash_emoji"
final PendingReactions _pendingReactions = PendingReactions();
StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
@ -6534,8 +6537,14 @@ class MeshCoreConnector extends ChangeNotifier {
_conversations.putIfAbsent(pubKeyHex, () => []);
final messages = _conversations[pubKeyHex]!;
// Parse reaction info
final reactionInfo = Message.parseReaction(message.text);
// Parse reaction info. Our own format first, then the PocketMesh /
// MeshCore One format (#380). A room server behaves like a channel (many
// senders) while 1:1 does not, and the two forms are mutually exclusive by
// shape, so try both rather than guessing which one applies.
final reactionInfo =
Message.parseReaction(message.text) ??
_parsePocketMeshReaction(message.text, isDm: true) ??
_parsePocketMeshReaction(message.text, isDm: false);
if (reactionInfo != null) {
// Check if we've already processed this exact reaction
_processedContactReactions.putIfAbsent(pubKeyHex, () => {});
@ -6548,7 +6557,20 @@ class MeshCoreConnector extends ChangeNotifier {
if (!isDuplicate) {
// New reaction - process it
_processContactReaction(messages, reactionInfo, pubKeyHex);
final matched = _processContactReaction(
messages,
reactionInfo,
pubKeyHex,
);
if (!matched) {
// Early arrival, not junk. Hold it for the target (#382).
_pendingReactions.add(
_contactScopeKey(pubKeyHex),
reactionInfo,
pubKeyHex,
DateTime.now(),
);
}
_messageStore.saveMessages(pubKeyHex, messages);
// Mark as processed
@ -6560,11 +6582,20 @@ class MeshCoreConnector extends ChangeNotifier {
}
messages.add(message);
// A message just landed, so a reaction that arrived before it may now have
// its target (#382).
_pendingReactions.retry(
_contactScopeKey(pubKeyHex),
(info) => _processContactReaction(messages, info, pubKeyHex),
DateTime.now(),
);
_messageStore.saveMessages(pubKeyHex, messages);
notifyListeners();
}
void _processContactReaction(
String _contactScopeKey(String pubKeyHex) => 'contact:$pubKeyHex';
bool _processContactReaction(
List<Message> messages,
ReactionInfo reactionInfo,
String contactPubKeyHex,
@ -6575,7 +6606,7 @@ class MeshCoreConnector extends ChangeNotifier {
);
final isRoomServer = contact?.type == advTypeRoom;
ReactionHelper.applyReaction<Message>(
return ReactionHelper.applyReaction<Message>(
messages: messages,
reactionInfo: reactionInfo,
// Incoming reactions in 1:1: match against outgoing messages only
@ -6780,13 +6811,19 @@ class MeshCoreConnector extends ChangeNotifier {
_channelMessages.putIfAbsent(channelIndex, () => []);
final messages = _channelMessages[channelIndex]!;
// Parse reaction info
final reactionInfo = ChannelMessage.parseReaction(message.text);
// Parse reaction info. Our own format first, then the PocketMesh /
// MeshCore One format other clients send (#380).
final reactionInfo =
ChannelMessage.parseReaction(message.text) ??
_parsePocketMeshReaction(message.text, isDm: false);
if (reactionInfo != null) {
// Check if we've already processed this exact reaction
_processedChannelReactions.putIfAbsent(channelIndex, () => {});
// The reacting sender belongs in the key: without it, two people sending
// the same emoji to the same message collapse into one and the count
// never leaves 1.
final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
'${reactionInfo.targetHash}_${reactionInfo.emoji}_${message.senderName}';
final isDuplicate = _processedChannelReactions[channelIndex]!.contains(
reactionIdentifier,
@ -6794,7 +6831,16 @@ class MeshCoreConnector extends ChangeNotifier {
if (!isDuplicate) {
// New reaction - process it
_processReaction(messages, reactionInfo);
final matched = _processReaction(messages, reactionInfo);
if (!matched) {
// Early arrival, not junk. Hold it for the target (#382).
_pendingReactions.add(
_channelScopeKey(channelIndex),
reactionInfo,
message.senderName,
DateTime.now(),
);
}
// Save updated messages
_channelMessageStore.saveChannelMessages(channelIndex, messages);
@ -6849,16 +6895,33 @@ class MeshCoreConnector extends ChangeNotifier {
messages.add(processedMessage);
}
// A message just landed, so a reaction that arrived before it may now have
// its target (#382).
_pendingReactions.retry(
_channelScopeKey(channelIndex),
(info) => _processReaction(messages, info),
DateTime.now(),
);
// Save to persistent storage
_channelMessageStore.saveChannelMessages(channelIndex, messages);
return isNew;
}
void _processReaction(
String _channelScopeKey(int channelIndex) => 'channel:$channelIndex';
/// The PocketMesh / MeshCore One reaction format, wrapped for matching.
/// Receive-only: we never emit it.
ReactionInfo? _parsePocketMeshReaction(String text, {required bool isDm}) {
final parsed = PocketMeshReaction.parse(text, isDm: isDm);
return parsed == null ? null : ReactionInfo.pocketMesh(parsed);
}
bool _processReaction(
List<ChannelMessage> messages,
ReactionInfo reactionInfo,
) {
ReactionHelper.applyReaction<ChannelMessage>(
return ReactionHelper.applyReaction<ChannelMessage>(
messages: messages,
reactionInfo: reactionInfo,
shouldSkip: (_) => false,

@ -0,0 +1,56 @@
/// Crockford Base32, as used by the PocketMesh / MeshCore One reaction format.
///
/// Encode-only: a reaction hash is compared as a string and never decoded back
/// to bytes. The alphabet omits i, l, o and u to avoid visual ambiguity.
class CrockfordBase32 {
static const String alphabet = '0123456789abcdefghjkmnpqrstvwxyz';
/// Encode exactly 5 bytes (40 bits) as 8 characters, most significant first.
///
/// Accumulates at most 12 bits at a time rather than packing all 40 into one
/// int: bitwise operators are 32-bit on the web target, so a 40-bit shift
/// would silently truncate there.
static String encode5(List<int> bytes) {
if (bytes.length != 5) {
throw ArgumentError.value(
bytes.length,
'bytes',
'Crockford Base32 encode5 needs exactly 5 bytes',
);
}
final out = StringBuffer();
var buffer = 0;
var bits = 0;
for (final byte in bytes) {
buffer = (buffer << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
out.write(alphabet[(buffer >> bits) & 0x1F]);
}
}
return out.toString();
}
/// The lowercase canonical form of an 8-character hash, or null if [text] is
/// not valid Crockford Base32.
///
/// Resolves the ambiguity aliases the format defines on input (O to 0, I and
/// L to 1, either case) so a sender that emits them still matches. `u` has no
/// alias and is rejected.
static String? normalize8(String text) {
if (text.length != 8) return null;
final out = StringBuffer();
for (var i = 0; i < 8; i++) {
final lower = text[i].toLowerCase();
final resolved = switch (lower) {
'o' => '0',
'i' || 'l' => '1',
_ => lower,
};
if (!alphabet.contains(resolved)) return null;
out.write(resolved);
}
return out.toString();
}
}

@ -0,0 +1,84 @@
import '../utils/app_logger.dart';
import 'reaction_helper.dart';
class _Pending {
final String scopeKey;
final ReactionInfo info;
final String reactingSender;
final DateTime queuedAt;
const _Pending(this.scopeKey, this.info, this.reactingSender, this.queuedAt);
}
/// Reactions that arrived before the message they point at.
///
/// Out-of-order delivery is normal on a mesh, so a reaction with no local
/// target is not junk, it is early. Before this existed the connector consumed
/// such a reaction and dropped it with no badge, no message and no log, which
/// is indistinguishable from nobody having reacted (GH #382).
///
/// Entries are bounded and expire. A drop is logged at warn so it surfaces in
/// the in-app log and the file log rather than vanishing (SAFELANE 6). Whether
/// dropping is the right end state is still open; the ratio of late matches to
/// expiries in the log is the evidence for revisiting it.
class PendingReactions {
static const int maxEntries = 50;
static const Duration ttl = Duration(minutes: 15);
final List<_Pending> _entries = [];
int get length => _entries.length;
void add(
String scopeKey,
ReactionInfo info,
String reactingSender,
DateTime now,
) {
expire(now);
_entries.add(_Pending(scopeKey, info, reactingSender, now));
if (_entries.length > maxEntries) {
_drop(_entries.removeAt(0), now, 'queue full');
}
appLogger.info(
'Queued reaction ${info.emoji} from $reactingSender for unseen target '
'${info.targetHash} in $scopeKey (${_entries.length}/$maxEntries pending)',
tag: 'Reactions',
);
}
/// 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) {
expire(now);
_entries.removeWhere((entry) {
if (entry.scopeKey != scopeKey) return false;
if (!apply(entry.info)) return false;
appLogger.info(
'Late-matched reaction ${entry.info.emoji} from ${entry.reactingSender} '
'to ${entry.info.targetHash} in $scopeKey after '
'${now.difference(entry.queuedAt).inSeconds}s',
tag: 'Reactions',
);
return true;
});
}
void expire(DateTime now) {
_entries.removeWhere((entry) {
if (now.difference(entry.queuedAt) < ttl) return false;
_drop(entry, now, 'expired after ${ttl.inMinutes}m');
return true;
});
}
void _drop(_Pending entry, DateTime now, String why) {
appLogger.warn(
'Dropping unmatched reaction ${entry.info.emoji} from '
'${entry.reactingSender} for target ${entry.info.targetHash} in '
'${entry.scopeKey} ($why, age ${now.difference(entry.queuedAt).inSeconds}s). '
'The message it points at was never seen locally.',
tag: 'Reactions',
);
}
}

@ -0,0 +1,121 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'crockford_base32.dart';
/// A reaction in the PocketMesh / MeshCore One wire format.
///
/// Channel form: `{emoji}@[{targetSenderName}]\n{hash}`
/// Direct form: `{emoji}\n{hash}`
///
/// The hash identifies the target message as sha256 over the target's body
/// text (UTF-8) followed by its sender-claimed timestamp as a little-endian
/// uint32 of epoch seconds, truncated to the first 5 bytes and encoded as 8
/// Crockford Base32 characters. The channel `SenderName: ` prefix is NOT part
/// of the hashed text, which is why the sender travels in `@[...]` instead.
///
/// Confirmed against a live capture from a MeshCore One peer, see GH #378.
/// Receive-only: Offband still sends its own `r:hhhh:ii` format (GH #379).
class PocketMeshReaction {
final String emoji;
/// The target message's sender, present in the channel form only. In a direct
/// conversation the sender is implicit.
final String? targetSenderName;
/// 8 characters, lowercase canonical form.
final String targetHash;
const PocketMeshReaction({
required this.emoji,
required this.targetHash,
this.targetSenderName,
});
static String computeHash(String bodyText, int timestampSeconds) {
final body = utf8.encode(bodyText);
final input = Uint8List(body.length + 4);
input.setRange(0, body.length, body);
// Division rather than shifts: `>>` is signed 32-bit on the web target and
// would misencode any timestamp past 2038.
final seconds = timestampSeconds % 4294967296;
input[body.length] = seconds % 256;
input[body.length + 1] = (seconds ~/ 256) % 256;
input[body.length + 2] = (seconds ~/ 65536) % 256;
input[body.length + 3] = (seconds ~/ 16777216) % 256;
return CrockfordBase32.encode5(sha256.convert(input).bytes.sublist(0, 5));
}
/// Parse [text] as a reaction, or null if it is an ordinary message.
///
/// Mirrors the reference parser: the last line must be exactly 8 valid
/// Crockford Base32 characters, and the part before it must start with an
/// emoji. Both checks matter, since anything accepted here is swallowed
/// instead of being shown as a message.
static PocketMeshReaction? parse(String text, {required bool isDm}) {
final newline = text.lastIndexOf('\n');
if (newline < 0) return null;
final hash = CrockfordBase32.normalize8(text.substring(newline + 1));
if (hash == null) return null;
final head = text.substring(0, newline);
if (isDm) {
if (head.contains('@[')) return null;
return _build(emoji: head, sender: null, hash: hash);
}
final bracket = head.indexOf('@[');
if (bracket < 0) return null;
final afterBracket = head.substring(bracket + 2);
if (!afterBracket.endsWith(']')) return null;
final sender = afterBracket.substring(0, afterBracket.length - 1);
if (sender.isEmpty) return null;
return _build(
emoji: head.substring(0, bracket),
sender: sender,
hash: hash,
);
}
static PocketMeshReaction? _build({
required String emoji,
required String? sender,
required String hash,
}) {
if (emoji.isEmpty || !_startsWithEmoji(emoji)) return null;
return PocketMeshReaction(
emoji: emoji,
targetSenderName: sender,
targetHash: hash,
);
}
/// Deliberately conservative: a missed emoji only means the reaction renders
/// as text, which is the behaviour we have today, while a false positive
/// would swallow a real message.
static const List<List<int>> _emojiRanges = [
[0x1F000, 0x1FAFF],
[0x2600, 0x27BF],
[0x2B00, 0x2BFF],
[0x2190, 0x21FF],
[0x2934, 0x2935],
[0x3030, 0x3030],
[0x303D, 0x303D],
[0x3297, 0x3299],
];
static bool _startsWithEmoji(String text) {
final runes = text.runes;
if (runes.isEmpty) return false;
final first = runes.first;
for (final range in _emojiRanges) {
if (first >= range[0] && first <= range[1]) return true;
}
return false;
}
}

@ -1,10 +1,37 @@
import '../widgets/emoji_picker.dart';
import 'pocketmesh_reaction.dart';
/// Which client's reaction format a [ReactionInfo] came from. The two use
/// different target hashes, so matching has to know which one it holds.
enum ReactionDialect {
/// Offband's own `r:hhhh:ii`.
offband,
/// PocketMesh / MeshCore One, `{emoji}@[{sender}]\n{hash}`. Receive-only.
pocketMesh,
}
class ReactionInfo {
final String targetHash;
final String emoji;
final ReactionDialect dialect;
ReactionInfo({required this.targetHash, required this.emoji});
/// The target message's sender, carried by the PocketMesh channel form only.
/// When set, a candidate must match it as well as the hash.
final String? targetSenderName;
ReactionInfo({
required this.targetHash,
required this.emoji,
this.dialect = ReactionDialect.offband,
this.targetSenderName,
});
ReactionInfo.pocketMesh(PocketMeshReaction reaction)
: targetHash = reaction.targetHash,
emoji = reaction.emoji,
dialect = ReactionDialect.pocketMesh,
targetSenderName = reaction.targetSenderName;
}
class ReactionHelper {
@ -32,15 +59,27 @@ class ReactionHelper {
updateMessage,
}) {
final targetHash = reactionInfo.targetHash;
final targetSender = reactionInfo.targetSenderName;
for (int i = messages.length - 1; i >= 0; i--) {
final msg = messages[i];
if (shouldSkip(msg)) continue;
final msgHash = computeReactionHash(
getTimestampSecs(msg),
getSenderName(msg),
getMessageText(msg),
);
// Exact compare, no normalising: a node name can carry emoji and
// variation selectors (a live capture used "Strycher WM\u{1F6F0}\u{FE0F}")
// and any folding would break the match.
if (targetSender != null && getSenderName(msg) != targetSender) continue;
final msgHash = switch (reactionInfo.dialect) {
ReactionDialect.offband => computeReactionHash(
getTimestampSecs(msg),
getSenderName(msg),
getMessageText(msg),
),
ReactionDialect.pocketMesh => PocketMeshReaction.computeHash(
getMessageText(msg),
getTimestampSecs(msg),
),
};
if (msgHash == targetHash) {
final currentReactions = Map<String, int>.from(getReactions(msg));
currentReactions[reactionInfo.emoji] =

@ -5,6 +5,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../helpers/gif_helper.dart';
import '../helpers/pocketmesh_reaction.dart';
import '../helpers/reaction_helper.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_info.dart';
@ -155,6 +156,15 @@ class NotificationService {
if (reaction != null) {
return 'Reacted ${reaction.emoji}';
}
// The PocketMesh / MeshCore One format, or the tray still shows the raw
// emoji-plus-hash payload (#380). The two forms are mutually exclusive by
// shape, so trying both is unambiguous.
final foreign =
PocketMeshReaction.parse(trimmed, isDm: true) ??
PocketMeshReaction.parse(trimmed, isDm: false);
if (foreign != null) {
return 'Reacted ${foreign.emoji}';
}
// resolveGifUrl, not parseGif: the tray must summarise exactly the set the
// chat renders inline, or an allowlisted Tenor GIF shows as a raw URL. (#283)
if (GifHelper.resolveGifUrl(trimmed) != null) {

@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/crockford_base32.dart';
void main() {
group('CrockfordBase32', () {
group('encode5', () {
test('encodes 40 zero bits', () {
expect(CrockfordBase32.encode5([0, 0, 0, 0, 0]), '00000000');
});
test('encodes 40 one bits', () {
expect(CrockfordBase32.encode5([255, 255, 255, 255, 255]), 'zzzzzzzz');
});
test('walks the alphabet in order, most significant group first', () {
// 0x004432 14c7 packs the 5-bit groups 0,1,2,3,4,5,6,7.
expect(
CrockfordBase32.encode5([0x00, 0x44, 0x32, 0x14, 0xc7]),
'01234567',
);
});
test('encodes a value above 32 bits without truncating', () {
// Guards the web target, where a naive 40-bit shift would lose the top
// byte and return '0...' for this input.
expect(
CrockfordBase32.encode5([0x8f, 0x1e, 0x2d, 0x00, 0x01]),
'hwf2t001',
);
});
test('rejects anything that is not exactly 5 bytes', () {
expect(
() => CrockfordBase32.encode5([1, 2, 3, 4]),
throwsArgumentError,
);
expect(
() => CrockfordBase32.encode5([1, 2, 3, 4, 5, 6]),
throwsArgumentError,
);
});
});
group('normalize8', () {
test('passes a canonical hash through unchanged', () {
expect(CrockfordBase32.normalize8('dyps6yf0'), 'dyps6yf0');
});
test('lowercases', () {
expect(CrockfordBase32.normalize8('DYPS6YF0'), 'dyps6yf0');
});
test('resolves the ambiguity aliases in both cases', () {
expect(CrockfordBase32.normalize8('OoIiLl00'), '00111100');
});
test('rejects u, which has no alias', () {
expect(CrockfordBase32.normalize8('dypsuyf0'), isNull);
expect(CrockfordBase32.normalize8('dypsUyf0'), isNull);
});
test('rejects the wrong length', () {
expect(CrockfordBase32.normalize8('dyps6yf'), isNull);
expect(CrockfordBase32.normalize8('dyps6yf00'), isNull);
expect(CrockfordBase32.normalize8(''), isNull);
});
test('rejects non-alphabet characters', () {
expect(CrockfordBase32.normalize8('dyps6y-0'), isNull);
expect(CrockfordBase32.normalize8('dyps6y 0'), isNull);
});
test('the alphabet omits the ambiguous letters', () {
for (final c in ['i', 'l', 'o', 'u']) {
expect(
CrockfordBase32.alphabet.contains(c),
isFalse,
reason: 'alphabet must not contain $c',
);
}
expect(CrockfordBase32.alphabet.length, 32);
});
});
});
}

@ -0,0 +1,102 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/pending_reactions.dart';
import 'package:meshcore_open/helpers/reaction_helper.dart';
ReactionInfo _info(String hash) =>
ReactionInfo(targetHash: hash, emoji: '\u{1F44D}');
void main() {
group('PendingReactions', () {
final now = DateTime.utc(2026, 7, 24, 1, 43);
test('holds a reaction whose target has not arrived', () {
final pending = PendingReactions();
pending.add('channel:0', _info('dyps6yf0'), 'Node', now);
expect(pending.length, 1);
});
test('applies a held reaction once the target lands', () {
final pending = PendingReactions();
pending.add('channel:0', _info('dyps6yf0'), 'Node', now);
final applied = <String>[];
pending.retry('channel:0', (info) {
applied.add(info.targetHash);
return true;
}, now.add(const Duration(seconds: 5)));
expect(applied, ['dyps6yf0']);
expect(pending.length, 0);
});
test('keeps a reaction that still has no target', () {
final pending = PendingReactions();
pending.add('channel:0', _info('dyps6yf0'), 'Node', now);
pending.retry(
'channel:0',
(_) => false,
now.add(const Duration(seconds: 5)),
);
expect(pending.length, 1);
});
test('does not leak across scopes', () {
final pending = PendingReactions();
pending.add('channel:0', _info('dyps6yf0'), 'Node', now);
var called = false;
pending.retry('channel:1', (_) {
called = true;
return true;
}, now);
expect(called, isFalse);
expect(pending.length, 1);
});
test('drops a reaction that outlives the TTL', () {
final pending = PendingReactions();
pending.add('channel:0', _info('dyps6yf0'), 'Node', now);
pending.expire(now.add(PendingReactions.ttl));
expect(pending.length, 0);
});
test('keeps a reaction that is still inside the TTL', () {
final pending = PendingReactions();
pending.add('channel:0', _info('dyps6yf0'), 'Node', now);
pending.expire(
now.add(PendingReactions.ttl - const Duration(seconds: 1)),
);
expect(pending.length, 1);
});
test('caps the queue and evicts the oldest first', () {
final pending = PendingReactions();
for (var i = 0; i <= PendingReactions.maxEntries; i++) {
pending.add(
'channel:0',
_info(i.toString().padLeft(8, '0')),
'Node',
now.add(Duration(seconds: i)),
);
}
expect(pending.length, PendingReactions.maxEntries);
final seen = <String>[];
pending.retry('channel:0', (info) {
seen.add(info.targetHash);
return true;
}, now.add(const Duration(minutes: 1)));
expect(seen, isNot(contains('00000000')), reason: 'oldest was evicted');
expect(seen.length, PendingReactions.maxEntries);
});
});
}

@ -0,0 +1,177 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/pocketmesh_reaction.dart';
void main() {
group('PocketMeshReaction', () {
group('computeHash', () {
test('reproduces the hash from the live capture (GH #378)', () {
// Captured 2026-07-24: a MeshCore One peer reacted to this message and
// sent the hash below. If this ever fails, our hash input drifted from
// theirs and every incoming reaction will silently stop matching.
expect(
PocketMeshReaction.computeHash(
"It's a hash from MeshCore One for emojis. "
'Looking at how to render better.',
1784871761,
),
'dyps6yf0',
);
});
test('is stable for the same input', () {
final a = PocketMeshReaction.computeHash('Hello', 1704067200);
final b = PocketMeshReaction.computeHash('Hello', 1704067200);
expect(a, b);
expect(a.length, 8);
});
test('the timestamp is part of the hash', () {
expect(
PocketMeshReaction.computeHash('Hello', 1704067200),
isNot(PocketMeshReaction.computeHash('Hello', 1704067201)),
);
});
test('handles a timestamp past the 32-bit signed boundary', () {
// 2^31 + 1. A signed 32-bit shift on the web target would misencode it.
final hash = PocketMeshReaction.computeHash('Hello', 2147483649);
expect(hash.length, 8);
expect(hash, isNot(PocketMeshReaction.computeHash('Hello', 1)));
});
test('hashes the body, not the sender-prefixed wire text', () {
const body = 'Test';
expect(
PocketMeshReaction.computeHash(body, 1784871761),
isNot(PocketMeshReaction.computeHash('Someone: $body', 1784871761)),
);
});
});
group('parse, channel form', () {
test('parses the exact payload from the live capture', () {
final parsed = PocketMeshReaction.parse(
'\u{1F44D}@[Strycher WM\u{1F6F0}\u{FE0F}]\ndyps6yf0',
isDm: false,
);
expect(parsed, isNotNull);
expect(parsed!.emoji, '\u{1F44D}');
expect(parsed.targetSenderName, 'Strycher WM\u{1F6F0}\u{FE0F}');
expect(parsed.targetHash, 'dyps6yf0');
});
test('normalises an uppercase hash', () {
final parsed = PocketMeshReaction.parse(
'\u{1F44D}@[Node]\nDYPS6YF0',
isDm: false,
);
expect(parsed?.targetHash, 'dyps6yf0');
});
test('rejects a missing sender block', () {
expect(
PocketMeshReaction.parse('\u{1F44D}\ndyps6yf0', isDm: false),
isNull,
);
});
test('rejects an unterminated sender block', () {
expect(
PocketMeshReaction.parse('\u{1F44D}@[Node\ndyps6yf0', isDm: false),
isNull,
);
});
test('rejects an empty sender', () {
expect(
PocketMeshReaction.parse('\u{1F44D}@[]\ndyps6yf0', isDm: false),
isNull,
);
});
});
group('parse, direct form', () {
test('parses emoji and hash', () {
final parsed = PocketMeshReaction.parse(
'\u{1F44D}\ndyps6yf0',
isDm: true,
);
expect(parsed, isNotNull);
expect(parsed!.emoji, '\u{1F44D}');
expect(parsed.targetSenderName, isNull);
expect(parsed.targetHash, 'dyps6yf0');
});
test('rejects the channel form', () {
expect(
PocketMeshReaction.parse('\u{1F44D}@[Node]\ndyps6yf0', isDm: true),
isNull,
);
});
});
group('parse rejects ordinary messages', () {
test('no newline', () {
expect(
PocketMeshReaction.parse('\u{1F44D}dyps6yf0', isDm: true),
isNull,
);
});
test('tail is not 8 characters', () {
expect(
PocketMeshReaction.parse('\u{1F44D}\ndyps6yf', isDm: true),
isNull,
);
expect(
PocketMeshReaction.parse('\u{1F44D}\ndyps6yf00', isDm: true),
isNull,
);
});
test('tail holds a character outside the alphabet', () {
expect(
PocketMeshReaction.parse('\u{1F44D}\ndypsuyf0', isDm: true),
isNull,
);
});
test('nothing before the newline', () {
expect(PocketMeshReaction.parse('\ndyps6yf0', isDm: true), isNull);
});
test('the leading character is not an emoji', () {
// The realistic false positive: a two-line message whose last line
// happens to be eight Crockford characters.
expect(
PocketMeshReaction.parse('see below\ndyps6yf0', isDm: true),
isNull,
);
expect(PocketMeshReaction.parse('A\ndyps6yf0', isDm: true), isNull);
});
test('our own reaction format is not mistaken for theirs', () {
expect(PocketMeshReaction.parse('r:3f2a:05', isDm: true), isNull);
});
test('accepts the emoji the picker offers as quick reactions', () {
for (final emoji in [
'\u{1F44D}',
'\u{2764}\u{FE0F}',
'\u{1F602}',
'\u{1F389}',
'\u{1F44F}',
'\u{1F525}',
]) {
expect(
PocketMeshReaction.parse('$emoji\ndyps6yf0', isDm: true),
isNotNull,
reason: 'should accept $emoji',
);
}
});
});
});
}

@ -0,0 +1,142 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/pocketmesh_reaction.dart';
import 'package:meshcore_open/helpers/reaction_helper.dart';
class _Msg {
final int timestampSecs;
final String senderName;
final String text;
Map<String, int> reactions = {};
_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;
final matched = ReactionHelper.applyReaction<_Msg>(
messages: messages,
reactionInfo: info,
getTimestampSecs: (m) => m.timestampSecs,
getSenderName: (m) => m.senderName,
getMessageText: (m) => m.text,
getReactions: (m) => m.reactions,
shouldSkip: (_) => false,
updateMessage: (i, reactions) {
messages[i].reactions = reactions;
updated = reactions;
},
);
return (matched: matched, reactions: updated);
}
void main() {
// The live capture from GH #378.
const capturedText =
"It's a hash from MeshCore One for emojis. "
'Looking at how to render better.';
const capturedTs = 1784871761;
const capturedSender = 'Strycher WM\u{1F6F0}\u{FE0F}';
const capturedHash = 'dyps6yf0';
group('applyReaction dialect dispatch', () {
test('a PocketMesh reaction matches by the PocketMesh hash', () {
final messages = [_Msg(capturedTs, capturedSender, capturedText)];
final info = ReactionInfo.pocketMesh(
PocketMeshReaction.parse(
'\u{1F44D}@[$capturedSender]\n$capturedHash',
isDm: false,
)!,
);
final result = _apply(messages, info);
expect(result.matched, isTrue);
expect(result.reactions, {'\u{1F44D}': 1});
});
test('the sender must match exactly, emoji and all', () {
final messages = [
_Msg(capturedTs, 'Strycher WM', capturedText), // no satellite emoji
];
final info = ReactionInfo.pocketMesh(
PocketMeshReaction.parse(
'\u{1F44D}@[$capturedSender]\n$capturedHash',
isDm: false,
)!,
);
expect(_apply(messages, info).matched, isFalse);
});
test('the direct form matches without a sender', () {
final messages = [_Msg(capturedTs, 'whoever', capturedText)];
final info = ReactionInfo.pocketMesh(
PocketMeshReaction.parse('\u{1F44D}\n$capturedHash', isDm: true)!,
);
expect(_apply(messages, info).matched, isTrue);
});
test('reports no match when the target is absent', () {
final messages = [
_Msg(capturedTs, capturedSender, 'a different message'),
];
final info = ReactionInfo.pocketMesh(
PocketMeshReaction.parse(
'\u{1F44D}@[$capturedSender]\n$capturedHash',
isDm: false,
)!,
);
expect(_apply(messages, info).matched, isFalse);
});
test('the Offband dialect still uses the Offband hash, unchanged', () {
final messages = [_Msg(1234567890, 'Alice', 'Hello world!')];
final hash = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello world!',
);
final info = ReactionHelper.parseReaction(
'r:$hash:${ReactionHelper.emojiToIndex('\u{1F389}')}',
);
expect(info, isNotNull);
expect(info!.dialect, ReactionDialect.offband);
final result = _apply(messages, info);
expect(result.matched, isTrue);
expect(result.reactions, {'\u{1F389}': 1});
});
test('the two dialects do not match each other', () {
final messages = [_Msg(capturedTs, capturedSender, capturedText)];
// The PocketMesh hash fed in as if it were ours must not match.
final info = ReactionInfo(targetHash: capturedHash, emoji: '\u{1F44D}');
expect(info.dialect, ReactionDialect.offband);
expect(_apply(messages, info).matched, isFalse);
});
test('counts accumulate for the same emoji', () {
final messages = [_Msg(capturedTs, capturedSender, capturedText)];
final info = ReactionInfo.pocketMesh(
PocketMeshReaction.parse(
'\u{1F44D}@[$capturedSender]\n$capturedHash',
isDm: false,
)!,
);
_apply(messages, info);
final second = _apply(messages, info);
expect(second.reactions, {'\u{1F44D}': 2});
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.