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
parent
1ecf1ce55d
commit
0e926ad0c2
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue