diff --git a/lib/src/event.dart b/lib/src/event.dart index d5be1222..f74747b6 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -32,6 +32,7 @@ abstract class RelationshipTypes { static const String reply = 'm.in_reply_to'; static const String edit = 'm.replace'; static const String reaction = 'm.annotation'; + static const String thread = 'm.thread'; } /// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event. @@ -45,6 +46,7 @@ class Event extends MatrixEvent { @Deprecated( 'Use eventSender instead or senderFromMemoryOrFallback for a synchronous alternative') User get sender => senderFromMemoryOrFallback; + User get senderFromMemoryOrFallback => room.unsafeGetUserFromMemoryOrFallback(senderId); @@ -72,6 +74,7 @@ class Event extends MatrixEvent { : null; MatrixEvent? _originalSource; + MatrixEvent? get originalSource => _originalSource; Event({ @@ -789,6 +792,14 @@ class Event extends MatrixEvent { if (content.tryGet>('m.relates_to') == null) { return null; } + if (content['m.relates_to'].containsKey('rel_type')) { + if (content + .tryGet>('m.relates_to') + ?.tryGet('rel_type') == + RelationshipTypes.thread) { + return RelationshipTypes.thread; + } + } if (content['m.relates_to'].containsKey('m.in_reply_to')) { return RelationshipTypes.reply; } diff --git a/lib/src/room.dart b/lib/src/room.dart index d64fcd20..407d13b0 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -633,10 +633,16 @@ class Room { String? editEventId, bool parseMarkdown = true, bool parseCommands = true, - String msgtype = MessageTypes.Text}) { + String msgtype = MessageTypes.Text, + String? threadRootEventId, + String? threadLastEventId}) { if (parseCommands) { return client.parseAndRunCommand(this, message, - inReplyTo: inReplyTo, editEventId: editEventId, txid: txid); + inReplyTo: inReplyTo, + editEventId: editEventId, + txid: txid, + threadRootEventId: threadRootEventId, + threadLastEventId: threadLastEventId); } final event = { 'msgtype': msgtype, @@ -654,7 +660,11 @@ class Room { } } return sendEvent(event, - txid: txid, inReplyTo: inReplyTo, editEventId: editEventId); + txid: txid, + inReplyTo: inReplyTo, + editEventId: editEventId, + threadRootEventId: threadRootEventId, + threadLastEventId: threadLastEventId); } /// Sends a reaction to an event with an [eventId] and the content [key] into a room. @@ -702,6 +712,8 @@ class Room { int? shrinkImageMaxDimension, MatrixImageFile? thumbnail, Map? extraContent, + String? threadRootEventId, + String? threadLastEventId, }) async { txid ??= client.generateUniqueTransactionId(); sendingFilePlaceholders[txid] = file; @@ -895,6 +907,8 @@ class Room { txid: txid, inReplyTo: inReplyTo, editEventId: editEventId, + threadRootEventId: threadRootEventId, + threadLastEventId: threadLastEventId, ); sendingFilePlaceholders.remove(txid); sendingFileThumbnails.remove(txid); @@ -972,6 +986,8 @@ class Room { String? txid, Event? inReplyTo, String? editEventId, + String? threadRootEventId, + String? threadLastEventId, }) async { // Create new transaction id final String messageID; @@ -1010,6 +1026,25 @@ class Room { }, }; } + + if (threadRootEventId != null) { + content['m.relates_to'] = { + 'event_id': threadRootEventId, + 'rel_type': RelationshipTypes.thread, + 'is_falling_back': inReplyTo == null, + if (inReplyTo != null) ...{ + 'm.in_reply_to': { + 'event_id': inReplyTo.eventId, + }, + } else ...{ + if (threadLastEventId != null) + 'm.in_reply_to': { + 'event_id': threadLastEventId, + }, + } + }; + } + 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 470821bc..ab33df88 100644 --- a/lib/src/utils/commands_extension.dart +++ b/lib/src/utils/commands_extension.dart @@ -30,14 +30,23 @@ extension CommandsClientExtension on Client { /// Parse and execute a string, `msg` is the input. Optionally `inReplyTo` is the event being /// replied to and `editEventId` is the eventId of the event being replied to - Future parseAndRunCommand(Room room, String msg, - {Event? inReplyTo, String? editEventId, String? txid}) async { + Future parseAndRunCommand( + Room room, + String msg, { + Event? inReplyTo, + String? editEventId, + String? txid, + String? threadRootEventId, + String? threadLastEventId, + }) async { final args = CommandArgs( inReplyTo: inReplyTo, editEventId: editEventId, msg: '', room: room, txid: txid, + threadRootEventId: threadRootEventId, + threadLastEventId: threadLastEventId, ); if (!msg.startsWith('/')) { final sendCommand = commands['send']; @@ -86,6 +95,8 @@ extension CommandsClientExtension on Client { editEventId: args.editEventId, parseCommands: false, txid: args.txid, + threadRootEventId: args.threadRootEventId, + threadLastEventId: args.threadLastEventId, ); }); addCommand('me', (CommandArgs args) async { @@ -96,6 +107,8 @@ extension CommandsClientExtension on Client { msgtype: MessageTypes.Emote, parseCommands: false, txid: args.txid, + threadRootEventId: args.threadRootEventId, + threadLastEventId: args.threadLastEventId, ); }); addCommand('dm', (CommandArgs args) async { @@ -119,6 +132,8 @@ extension CommandsClientExtension on Client { parseMarkdown: false, parseCommands: false, txid: args.txid, + threadRootEventId: args.threadRootEventId, + threadLastEventId: args.threadLastEventId, ); }); addCommand('html', (CommandArgs args) async { @@ -270,11 +285,15 @@ class CommandArgs { Event? inReplyTo; Room room; String? txid; + String? threadRootEventId; + String? threadLastEventId; CommandArgs( {required this.msg, this.editEventId, this.inReplyTo, required this.room, - this.txid}); + this.txid, + this.threadRootEventId, + this.threadLastEventId}); } diff --git a/test/commands_test.dart b/test/commands_test.dart index f6575ec6..ba4ac670 100644 --- a/test/commands_test.dart +++ b/test/commands_test.dart @@ -17,6 +17,7 @@ */ import 'dart:convert'; +import 'dart:typed_data'; import 'package:olm/olm.dart' as olm; import 'package:test/test.dart'; @@ -164,6 +165,105 @@ void main() { }); }); + test('thread', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent( + 'thread', + threadRootEventId: '\$parent_event', + threadLastEventId: '\$parent_event', + ); + final sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': 'thread', + 'm.relates_to': { + 'rel_type': 'm.thread', + 'event_id': '\$parent_event', + 'is_falling_back': true, + 'm.in_reply_to': {'event_id': '\$parent_event'} + }, + }); + }); + + test('thread_image', () async { + FakeMatrixApi.calledEndpoints.clear(); + final testImage = MatrixFile(bytes: Uint8List(0), name: 'file.jpeg'); + await room.sendFileEvent( + testImage, + threadRootEventId: '\$parent_event', + threadLastEventId: '\$parent_event', + ); + final sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.image', + 'body': 'file.jpeg', + 'filename': 'file.jpeg', + 'url': 'mxc://example.com/AQwafuaFswefuhsfAFAgsw', + 'info': { + 'mimetype': 'image/jpeg', + 'size': 0, + }, + 'm.relates_to': { + 'rel_type': 'm.thread', + 'event_id': '\$parent_event', + 'is_falling_back': true, + 'm.in_reply_to': {'event_id': '\$parent_event'} + }, + }); + }); + + test('thread_reply', () 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: client.userID!, + room: room, + ), + threadRootEventId: '\$parent_event', + threadLastEventId: '\$parent_event'); + final sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': '> <@test:fakeServer.notExisting> reply\n\nreply', + '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_different_event_ids', () async { + FakeMatrixApi.calledEndpoints.clear(); + await room.sendTextEvent( + 'thread', + threadRootEventId: '\$parent_event', + threadLastEventId: '\$last_event', + ); + final sent = getLastMessagePayload(); + expect(sent, { + 'msgtype': 'm.text', + 'body': 'thread', + 'm.relates_to': { + 'rel_type': 'm.thread', + 'event_id': '\$parent_event', + 'is_falling_back': true, + 'm.in_reply_to': {'event_id': '\$last_event'} + }, + }); + }); + test('join', () async { FakeMatrixApi.calledEndpoints.clear(); await room.sendTextEvent('/join !newroom:example.com');