From 62c6d4d648032f4e771497601545338bae23983c Mon Sep 17 00:00:00 2001 From: Khalil Date: Thu, 7 May 2026 03:12:42 +0300 Subject: [PATCH] fix: always include m.mentions on outgoing messages --- lib/src/room.dart | 86 ++++++++----- lib/src/utils/commands_extension.dart | 8 ++ lib/src/utils/pushrule_evaluator.dart | 21 +++- test/commands_test.dart | 64 +++++++++- test/pushevaluator_test.dart | 127 +++++++++++++++++++ test/room_test.dart | 172 +++++++++++++++++++++++++- 6 files changed, 436 insertions(+), 42 deletions(-) diff --git a/lib/src/room.dart b/lib/src/room.dart index 4a1e8d071..749699b05 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -42,6 +42,11 @@ const String messageSendingStatusKey = const String fileSendingStatusKey = 'com.famedly.famedlysdk.file_sending_status'; +// Matches full MXIDs (@user:server) and short forms (@room, @Name, @[Display Name]). +final RegExp _mentionCandidateRegex = RegExp( + r'(@[^\s:@]+:(?:[^\s]+\.\w+|[\d\.]+|\[[a-fA-F0-9:]+\])(?::\d+)?|@(?:\[[^\]:]+\]|\w+)(?:#\w+)?)', +); + /// Represents a Matrix room. class Room { /// The full qualified Matrix ID for the room in the format '!localid:server.abc'. @@ -710,6 +715,43 @@ class Room { .firstWhereOrNull((u) => u.mentionFragments.contains(mention)) ?.id; + bool _hasMentionLeftBoundary(String message, int start) => + start == 0 || message[start - 1].trim().isEmpty; + + Map _mentionsMetadataForMessage( + String message, { + Event? inReplyTo, + }) { + final potentialMentions = []; + var hasRoomMention = false; + + for (final match in _mentionCandidateRegex.allMatches(message)) { + if (!_hasMentionLeftBoundary(message, match.start)) continue; + + final mention = match[1]!; + if (mention == '@room') { + hasRoomMention = true; + continue; + } + + final userId = mention.isValidMatrixId ? mention : getMention(mention); + if (userId != null) { + potentialMentions.add(userId); + } + } + + // https://spec.matrix.org/v1.7/client-server-api/#mentioning-the-replied-to-user + if (inReplyTo != null) potentialMentions.add(inReplyTo.senderId); + + final userIds = potentialMentions.toSet().toList() + ..remove(client.userID); // We should never mention ourself. + + return { + if (hasRoomMention) 'room': true, + if (userIds.isNotEmpty) 'user_ids': userIds, + }; + } + /// Sends a normal text message to this room. Returns the event ID generated /// by the server for this message. Future sendTextEvent( @@ -740,6 +782,7 @@ class Room { txid: txid, threadRootEventId: threadRootEventId, threadLastEventId: threadLastEventId, + addMentions: addMentions, stdout: commandStdout, ); } @@ -749,37 +792,8 @@ class Room { }; if (addMentions) { - var potentialMentions = message - .split('@') - .map( - (text) => text.startsWith('[') - ? '@${text.split(']').first}]' - : '@${text.split(RegExp(r'\s+')).first}', - ) - .toList() - ..removeAt(0); - - final hasRoomMention = potentialMentions.remove('@room'); - - potentialMentions = potentialMentions - .map( - (mention) => - mention.isValidMatrixId ? mention : getMention(mention), - ) - .nonNulls - .toSet() // Deduplicate - .toList() - ..remove(client.userID); // We should never mention ourself. - - // https://spec.matrix.org/v1.7/client-server-api/#mentioning-the-replied-to-user - if (inReplyTo != null) potentialMentions.add(inReplyTo.senderId); - - if (hasRoomMention || potentialMentions.isNotEmpty) { - event['m.mentions'] = { - if (hasRoomMention) 'room': true, - if (potentialMentions.isNotEmpty) 'user_ids': potentialMentions, - }; - } + event['m.mentions'] = + _mentionsMetadataForMessage(message, inReplyTo: inReplyTo); } if (parseMarkdown) { @@ -1151,6 +1165,14 @@ class Room { } } + void _ensureMentionsMetadata(Map content, String type) { + if (type != EventTypes.Message || content['body'] is! String) { + return; + } + // Include empty metadata to opt out of legacy body-scanning mentions. + content['m.mentions'] ??= {}; + } + /// Sends an event to this room with this json as a content. Returns the /// event ID generated from the server. /// It uses list of completer to make sure events are sending in a row. @@ -1230,6 +1252,8 @@ class Room { }; } + _ensureMentionsMetadata(content, type); + if (editEventId != null) { final newContent = content.copy(); content['m.new_content'] = newContent; diff --git a/lib/src/utils/commands_extension.dart b/lib/src/utils/commands_extension.dart index fb3015477..914a082b6 100644 --- a/lib/src/utils/commands_extension.dart +++ b/lib/src/utils/commands_extension.dart @@ -44,6 +44,7 @@ extension CommandsClientExtension on Client { /// - `txid`: an optional transaction ID /// - `threadRootEventId`: an optional root event ID of a thread the command is supposed to run on /// - `threadLastEventId`: an optional most recent event ID of a thread the command is supposed to run on + /// - `addMentions`: whether text commands should resolve mention metadata /// - `stdout`: an optional [StringBuffer] the command can write output to. This is meant as tiny implementation of https://en.wikipedia.org/wiki/Standard_streams in order to process advanced command output to the matrix client. See [DefaultCommandOutput] for a rough idea. Future parseAndRunCommand( Room? room, @@ -53,6 +54,7 @@ extension CommandsClientExtension on Client { String? txid, String? threadRootEventId, String? threadLastEventId, + bool addMentions = true, StringBuffer? stdout, }) async { final args = CommandArgs( @@ -64,6 +66,7 @@ extension CommandsClientExtension on Client { txid: txid, threadRootEventId: threadRootEventId, threadLastEventId: threadLastEventId, + addMentions: addMentions, ); if (!msg.startsWith('/')) { final sendCommand = commands['send']; @@ -118,6 +121,7 @@ extension CommandsClientExtension on Client { txid: args.txid, threadRootEventId: args.threadRootEventId, threadLastEventId: args.threadLastEventId, + addMentions: args.addMentions, ); }); addCommand('me', (args, stdout) async { @@ -134,6 +138,7 @@ extension CommandsClientExtension on Client { txid: args.txid, threadRootEventId: args.threadRootEventId, threadLastEventId: args.threadLastEventId, + addMentions: args.addMentions, ); }); addCommand('dm', (args, stdout) async { @@ -182,6 +187,7 @@ extension CommandsClientExtension on Client { txid: args.txid, threadRootEventId: args.threadRootEventId, threadLastEventId: args.threadLastEventId, + addMentions: args.addMentions, ); }); addCommand('html', (args, stdout) async { @@ -490,6 +496,7 @@ class CommandArgs { String? txid; String? threadRootEventId; String? threadLastEventId; + bool addMentions; CommandArgs({ required this.msg, @@ -500,6 +507,7 @@ class CommandArgs { this.txid, this.threadRootEventId, this.threadLastEventId, + this.addMentions = true, }); } diff --git a/lib/src/utils/pushrule_evaluator.dart b/lib/src/utils/pushrule_evaluator.dart index 24313c61a..d38fbb28f 100644 --- a/lib/src/utils/pushrule_evaluator.dart +++ b/lib/src/utils/pushrule_evaluator.dart @@ -38,6 +38,14 @@ enum PushRuleConditions { } } +// Legacy default mention rules from pre-v1.17 specs. If m.mentions is present, +// they should not fire, but custom user content rules are unaffected. +const Set _legacyMentionRuleIds = { + '.m.rule.contains_user_name', + '.m.rule.contains_display_name', + '.m.rule.roomnotif', +}; + class EvaluatedPushRuleAction { // if this message should be highlighted. bool highlight = false; @@ -220,6 +228,8 @@ class _MemberCountCondition { } class _OptimizedRules { + final String ruleId; + final bool defaultRule; List<_PatternCondition> patterns = []; List<_EventPropertyCondition> eventProperties = []; List<_MemberCountCondition> memberCounts = []; @@ -227,7 +237,9 @@ class _OptimizedRules { bool matchDisplayname = false; EvaluatedPushRuleAction actions = EvaluatedPushRuleAction(); - _OptimizedRules.fromRule(PushRule rule) { + _OptimizedRules.fromRule(PushRule rule) + : ruleId = rule.ruleId, + defaultRule = rule.default$ { if (!rule.enabled) return; for (final condition in rule.conditions ?? []) { @@ -260,6 +272,9 @@ class _OptimizedRules { actions = EvaluatedPushRuleAction.fromActions(rule.actions); } + bool get isLegacyMentionRule => + defaultRule && _legacyMentionRuleIds.contains(ruleId); + EvaluatedPushRuleAction? match( Map flattenedEventJson, String? displayName, @@ -388,11 +403,13 @@ class PushruleEvaluator { final displayName = event.room .unsafeGetUserFromMemoryOrFallback(event.room.client.userID!) .displayName; + final hasMentionsMetadata = event.content.containsKey('m.mentions'); final flattenedEventJson = _flattenJson(event.toJson(), {}, ''); // ensure roomid is present flattenedEventJson['room_id'] = event.room.id; for (final o in _override) { + if (hasMentionsMetadata && o.isLegacyMentionRule) continue; final actions = o.match(flattenedEventJson, displayName, memberCount, event.room); if (actions != null) { @@ -411,6 +428,7 @@ class PushruleEvaluator { } for (final o in _content_rules) { + if (hasMentionsMetadata && o.isLegacyMentionRule) continue; final actions = o.match(flattenedEventJson, displayName, memberCount, event.room); if (actions != null) { @@ -419,6 +437,7 @@ class PushruleEvaluator { } for (final o in _underride) { + if (hasMentionsMetadata && o.isLegacyMentionRule) continue; final actions = o.match(flattenedEventJson, displayName, memberCount, event.room); if (actions != null) { diff --git a/test/commands_test.dart b/test/commands_test.dart index 6efc34e6a..24d0f1191 100644 --- a/test/commands_test.dart +++ b/test/commands_test.dart @@ -85,6 +85,7 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': 'Hello World', + 'm.mentions': {}, }); FakeMatrixApi.calledEndpoints.clear(); @@ -93,6 +94,7 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': 'Beep Boop', + 'm.mentions': {}, }); FakeMatrixApi.calledEndpoints.clear(); @@ -101,6 +103,7 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': 'Beep *Boop*', + 'm.mentions': {}, 'format': 'org.matrix.custom.html', 'formatted_body': 'Beep Boop', }); @@ -111,6 +114,7 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': '/send Hello World', + 'm.mentions': {}, }); }); @@ -121,6 +125,7 @@ void main() { expect(sent, { 'msgtype': 'm.emote', 'body': 'heya', + 'm.mentions': {}, }); }); @@ -131,6 +136,7 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': '*floof*', + 'm.mentions': {}, }); }); @@ -141,6 +147,7 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': 'yay', + 'm.mentions': {}, 'format': 'org.matrix.custom.html', 'formatted_body': 'yay', }); @@ -185,6 +192,7 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': 'thread', + 'm.mentions': {}, 'm.relates_to': { 'rel_type': 'm.thread', 'event_id': '\$parent_event', @@ -206,6 +214,7 @@ void main() { expect(sent, { 'msgtype': 'm.image', 'body': 'file.jpeg', + 'm.mentions': {}, 'filename': 'file.jpeg', 'url': 'mxc://example.com/AQwafuaFswefuhsfAFAgsw', 'info': { @@ -243,12 +252,47 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': '> <@test:fakeServer.notExisting> reply\n\nreply', + 'm.mentions': {}, + 'format': 'org.matrix.custom.html', + 'formatted_body': + '
In reply to @test:fakeServer.notExisting
reply
reply', + 'm.relates_to': { + 'rel_type': 'm.thread', + 'event_id': '\$parent_event', + 'is_falling_back': false, + 'm.in_reply_to': {'event_id': '\$parent_event'}, + }, + }); + }); + + test('thread_reply mentions replied-to user', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent( + 'reply', + inReplyTo: Event( + eventId: '\$parent_event', + type: 'm.room.message', + content: { + 'msgtype': 'm.text', + 'body': 'reply', + }, + originServerTs: DateTime.now(), + senderId: '@alice:example.org', + room: room, + ), + threadRootEventId: '\$parent_event', + threadLastEventId: '\$parent_event', + ); + final sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': '> <@alice:example.org> reply\n\nreply', 'm.mentions': { - 'user_ids': ['@test:fakeServer.notExisting'], + 'user_ids': ['@alice:example.org'], }, 'format': 'org.matrix.custom.html', 'formatted_body': - '
In reply to @test:fakeServer.notExisting
reply
reply', + '
In reply to @alice:example.org
reply
reply', 'm.relates_to': { 'rel_type': 'm.thread', 'event_id': '\$parent_event', @@ -269,6 +313,7 @@ void main() { expect(sent, { 'msgtype': 'm.text', 'body': 'thread', + 'm.mentions': {}, 'm.relates_to': { 'rel_type': 'm.thread', 'event_id': '\$parent_event', @@ -521,21 +566,30 @@ void main() { FakeMatrixApi.calledEndpoints.clear(); await room.sendTextEvent('/googly'); final sent = getLastMessagePayload(); - expect(sent, CuteEventContent.googlyEyes); + expect(sent, { + ...CuteEventContent.googlyEyes, + 'm.mentions': {}, + }); }); test('cute events - hug', () async { FakeMatrixApi.calledEndpoints.clear(); await room.sendTextEvent('/hug'); final sent = getLastMessagePayload(); - expect(sent, CuteEventContent.hug); + expect(sent, { + ...CuteEventContent.hug, + 'm.mentions': {}, + }); }); test('cute events - hug', () async { FakeMatrixApi.calledEndpoints.clear(); await room.sendTextEvent('/cuddle'); final sent = getLastMessagePayload(); - expect(sent, CuteEventContent.cuddle); + expect(sent, { + ...CuteEventContent.cuddle, + 'm.mentions': {}, + }); }); test('client - clearcache', () async { diff --git a/test/pushevaluator_test.dart b/test/pushevaluator_test.dart index f465a148d..8348d532e 100644 --- a/test/pushevaluator_test.dart +++ b/test/pushevaluator_test.dart @@ -372,6 +372,133 @@ void main() { _testNotMatch(ruleset, event); }); + test('m.mentions disables default legacy mention rules', () async { + final event = Event.fromJson(jsonObj, room); + event.content['body'] = 'Hello deepbluev7 @room Nico'; + (event.room.states[EventTypes.RoomMember] ??= {})[client.userID!] = + Event.fromJson( + { + 'type': EventTypes.RoomMember, + 'sender': senderID, + 'state_key': client.userID, + 'content': {'displayname': 'Nico', 'membership': 'join'}, + 'room_id': room.id, + 'origin_server_ts': 5, + }, + room, + ); + + EvaluatedPushRuleAction match(PushRuleSet ruleset) => + PushruleEvaluator.fromRuleset(ruleset).match(event); + + PushRule legacyRule(String ruleId, List? conditions) => + PushRule( + ruleId: ruleId, + default$: true, + enabled: true, + actions: [ + 'notify', + {'set_tweak': 'highlight', 'value': true}, + ], + conditions: conditions, + pattern: + ruleId == '.m.rule.contains_user_name' ? 'deepbluev7' : null, + ); + + final legacyContentRuleset = PushRuleSet( + content: [ + legacyRule('.m.rule.contains_user_name', null), + ], + ); + final legacyDisplayNameRuleset = PushRuleSet( + underride: [ + legacyRule( + '.m.rule.contains_display_name', + [PushCondition(kind: 'contains_display_name')], + ), + ], + ); + final legacyRoomRuleset = PushRuleSet( + override: [ + legacyRule( + '.m.rule.roomnotif', + [ + PushCondition( + kind: 'event_match', + key: 'content.body', + pattern: '@room', + ), + ], + ), + ], + ); + + expect(match(legacyContentRuleset).notify, true); + expect(match(legacyDisplayNameRuleset).notify, true); + expect(match(legacyRoomRuleset).notify, true); + + event.content['m.mentions'] = {}; + + expect(match(legacyContentRuleset).notify, false); + expect(match(legacyDisplayNameRuleset).notify, false); + expect(match(legacyRoomRuleset).notify, false); + + final customContentRuleset = PushRuleSet( + content: [ + PushRule( + ruleId: 'custom.contains_user_name', + default$: false, + enabled: true, + actions: ['notify'], + pattern: 'deepbluev7', + ), + ], + ); + expect(match(customContentRuleset).notify, true); + + event.content['m.mentions'] = { + 'user_ids': [client.userID], + }; + final modernMentionRuleset = PushRuleSet( + override: [ + PushRule( + ruleId: '.m.rule.is_user_mention', + default$: true, + enabled: true, + actions: ['notify'], + conditions: [ + PushCondition( + kind: 'event_property_contains', + key: r'content.m\.mentions.user_ids', + value: client.userID, + ), + ], + ), + ], + ); + expect(match(modernMentionRuleset).notify, true); + + event.content['m.mentions'] = {'room': true}; + final modernRoomMentionRuleset = PushRuleSet( + override: [ + PushRule( + ruleId: '.m.rule.is_room_mention', + default$: true, + enabled: true, + actions: ['notify'], + conditions: [ + PushCondition( + kind: 'event_property_is', + key: r'content.m\.mentions.room', + value: true, + ), + ], + ), + ], + ); + expect(match(modernRoomMentionRuleset).notify, true); + }); + test('member_count rule', () async { final event = Event.fromJson(jsonObj, room); (event.room.states[EventTypes.RoomMember] ??= {})[client.userID!] = diff --git a/test/room_test.dart b/test/room_test.dart index c2a38e32e..deffe6083 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -1122,17 +1122,60 @@ void main() { expect(resp?.startsWith('\$event'), true); }); - test('sendEvent', () async { + test('sendEvent with message body backfills empty mentions metadata', + () async { + FakeMatrixApi.calledEndpoints.clear(); + final dynamic resp = await room.sendEvent( + {'msgtype': 'm.image', 'body': 'alice.png'}, + txid: 'testtxid', + displayPendingEvent: false, + ); + expect(resp?.startsWith('\$event'), true); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.room.message/')); + final content = json.decode(entry.value.first); + expect(content, { + 'body': 'alice.png', + 'msgtype': 'm.image', + 'm.mentions': {}, + }); + }); + + test('sendEvent preserves existing mentions metadata', () async { + FakeMatrixApi.calledEndpoints.clear(); + final dynamic resp = await room.sendEvent( + { + 'msgtype': 'm.file', + 'body': 'report.pdf', + 'm.mentions': { + 'user_ids': ['@alice:matrix.org'], + }, + }, + txid: 'testtxid', + displayPendingEvent: false, + ); + expect(resp?.startsWith('\$event'), true); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.room.message/')); + final content = json.decode(entry.value.first); + expect(content['m.mentions'], { + 'user_ids': ['@alice:matrix.org'], + }); + }); + + test('sendTextEvent with bare localpart includes empty mentions metadata', + () async { FakeMatrixApi.calledEndpoints.clear(); final dynamic resp = - await room.sendTextEvent('Hello world', txid: 'testtxid'); + await room.sendTextEvent('alice fixed this', txid: 'testtxid'); expect(resp?.startsWith('\$event'), true); final entry = FakeMatrixApi.calledEndpoints.entries .firstWhere((p) => p.key.contains('/send/m.room.message/')); final content = json.decode(entry.value.first); expect(content, { - 'body': 'Hello world', + 'body': 'alice fixed this', 'msgtype': 'm.text', + 'm.mentions': {}, }); }); @@ -1153,19 +1196,102 @@ void main() { test('sendEvent with user mention', () async { FakeMatrixApi.calledEndpoints.clear(); final resp = await room.sendTextEvent( - 'Hello world @[Alice Margatroid]', + 'Hello world @[Alice Margatroid] ${matrix.userID}', + addMentions: true, + txid: 'testtxid', + ); + expect(resp?.startsWith('\$event'), true); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.room.message/')); + final content = json.decode(entry.value.first); + expect(content['m.mentions'], { + 'user_ids': ['@alice:matrix.org'], + }); + }); + + test('sendTextEvent detects mentions with trailing punctuation', () async { + FakeMatrixApi.calledEndpoints.clear(); + final resp = await room.sendTextEvent( + 'Hello @room. @alice:matrix.org, ${matrix.userID}!', addMentions: true, + parseCommands: false, txid: 'testtxid', + displayPendingEvent: false, ); expect(resp?.startsWith('\$event'), true); final entry = FakeMatrixApi.calledEndpoints.entries .firstWhere((p) => p.key.contains('/send/m.room.message/')); final content = json.decode(entry.value.first); expect(content['m.mentions'], { + 'room': true, 'user_ids': ['@alice:matrix.org'], }); }); + test('mention candidate detection uses start or whitespace boundaries', + () async { + // Helper: send a message and return the m.mentions value. + Future> mentions(String body) async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent( + body, + addMentions: true, + parseCommands: false, + displayPendingEvent: false, + txid: 'testtxid', + ); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.room.message/')); + return (json.decode(entry.value.first)['m.mentions'] + as Map) + .cast(); + } + + // Detected: full MXID, display-name form, room mention, after whitespace + expect( + await mentions('@alice:matrix.org'), + { + 'user_ids': ['@alice:matrix.org'], + }, + ); + expect( + await mentions('hello @alice:matrix.org'), + { + 'user_ids': ['@alice:matrix.org'], + }, + ); + expect( + await mentions('@[Alice Margatroid]'), + { + 'user_ids': ['@alice:matrix.org'], + }, + ); + expect(await mentions('@room'), {'room': true}); + + // Rejected: email-like, double @, invalid form + expect(await mentions('foo@alice:matrix.org'), {}); + expect(await mentions('@@alice:matrix.org'), {}); + expect(await mentions('@ alice:matrix.org'), {}); + }); + + test( + 'sendTextEvent with disabled mention parsing has empty mentions metadata', + () async { + FakeMatrixApi.calledEndpoints.clear(); + final resp = await room.sendTextEvent( + 'Hello @[Alice Margatroid] @room', + addMentions: false, + parseCommands: false, + txid: 'testtxid', + displayPendingEvent: false, + ); + expect(resp?.startsWith('\$event'), true); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.room.message/')); + final content = json.decode(entry.value.first); + expect(content['m.mentions'], {}); + }); + test('send edit', () async { FakeMatrixApi.calledEndpoints.clear(); final dynamic resp = await room.sendTextEvent( @@ -1180,9 +1306,11 @@ void main() { expect(content, { 'body': '* Hello world', 'msgtype': 'm.text', + 'm.mentions': {}, 'm.new_content': { 'body': 'Hello world', 'msgtype': 'm.text', + 'm.mentions': {}, }, 'm.relates_to': { 'event_id': '\$otherEvent', @@ -1395,6 +1523,32 @@ void main() { }); }); + test('send reply with inline mention aggregates both sources', () async { + final bobEvent = Event.fromJson( + { + 'event_id': '\$bobEvent', + 'content': {'body': 'Hey', 'msgtype': 'm.text'}, + 'type': 'm.room.message', + 'sender': '@bob:example.com', + }, + room, + ); + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent( + 'Hello @[Alice Margatroid]', + parseCommands: false, + txid: 'testtxid', + displayPendingEvent: false, + inReplyTo: bobEvent, + ); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.room.message/')); + final content = json.decode(entry.value.first); + expect(content['m.mentions'], { + 'user_ids': unorderedEquals(['@alice:matrix.org', '@bob:example.com']), + }); + }); + test('send reaction', () async { FakeMatrixApi.calledEndpoints.clear(); final dynamic resp = @@ -1428,13 +1582,21 @@ void main() { 'msgtype': 'm.location', 'body': body, 'geo_uri': geoUri, + 'm.mentions': {}, }); }); test('sendFileEvent', () async { + FakeMatrixApi.calledEndpoints.clear(); var testFile = MatrixFile(bytes: Uint8List(0), name: 'file.jpeg'); final resp = await room.sendFileEvent(testFile, txid: 'testtxid'); - expect(resp.toString(), '\$event12'); + expect(resp, isNotNull); + expect( + FakeMatrixApi.calledEndpoints.keys.any( + (key) => key.contains('/send/m.room.message/testtxid'), + ), + true, + ); expect( await room.client.database.getFile( Uri(