diff --git a/lib/matrix_api/model/event_types.dart b/lib/matrix_api/model/event_types.dart index 4de7608..25f4585 100644 --- a/lib/matrix_api/model/event_types.dart +++ b/lib/matrix_api/model/event_types.dart @@ -19,6 +19,7 @@ abstract class EventTypes { static const String Message = 'm.room.message'; static const String Sticker = 'm.sticker'; + static const String Reaction = 'm.reaction'; static const String Redaction = 'm.room.redaction'; static const String RoomAliases = 'm.room.aliases'; static const String RoomCanonicalAlias = 'm.room.canonical_alias'; diff --git a/lib/matrix_api/model/message_types.dart b/lib/matrix_api/model/message_types.dart index db478bd..90fe2d0 100644 --- a/lib/matrix_api/model/message_types.dart +++ b/lib/matrix_api/model/message_types.dart @@ -25,7 +25,6 @@ abstract class MessageTypes { static const String Audio = 'm.audio'; static const String File = 'm.file'; static const String Location = 'm.location'; - static const String Reply = 'm.relates_to'; static const String Sticker = 'm.sticker'; static const String BadEncrypted = 'm.bad.encrypted'; static const String None = 'm.none'; diff --git a/lib/src/event.dart b/lib/src/event.dart index 47ca3d0..e0e3d5d 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -28,6 +28,12 @@ import './room.dart'; import 'utils/matrix_localizations.dart'; import './database/database.dart' show DbRoomState, DbEvent; +abstract class RelationshipTypes { + static const String Reply = 'm.in_reply_to'; + static const String Edit = 'm.replace'; + static const String Reaction = 'm.annotation'; +} + /// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event. class Event extends MatrixEvent { User get sender => room.getUserByMXIDSync(senderId ?? '@unknown'); @@ -212,10 +218,7 @@ class Event extends MatrixEvent { unsigned: unsigned, room: room); - String get messageType => (content['m.relates_to'] is Map && - content['m.relates_to']['m.in_reply_to'] != null) - ? MessageTypes.Reply - : content['msgtype'] ?? MessageTypes.Text; + String get messageType => content['msgtype'] ?? MessageTypes.Text; void setRedactionEvent(Event redactedBecause) { unsigned = { @@ -328,20 +331,10 @@ class Event extends MatrixEvent { Future redact({String reason, String txid}) => room.redactEvent(eventId, reason: reason, txid: txid); - /// Whether this event is in reply to another event. - bool get isReply => - content['m.relates_to'] is Map && - content['m.relates_to']['m.in_reply_to'] is Map && - content['m.relates_to']['m.in_reply_to']['event_id'] is String && - (content['m.relates_to']['m.in_reply_to']['event_id'] as String) - .isNotEmpty; - /// Searches for the reply event in the given timeline. Future getReplyEvent(Timeline timeline) async { - if (!isReply) return null; - final String replyEventId = - content['m.relates_to']['m.in_reply_to']['event_id']; - return await timeline.getEventById(replyEventId); + if (relationshipType != RelationshipTypes.Reply) return null; + return await timeline.getEventById(relationshipEventId); } /// If this event is encrypted and the decryption was not successful because @@ -634,7 +627,6 @@ class Event extends MatrixEvent { case MessageTypes.Text: case MessageTypes.Notice: case MessageTypes.None: - case MessageTypes.Reply: localizedBody = body; break; } @@ -663,9 +655,61 @@ class Event extends MatrixEvent { static const Set textOnlyMessageTypes = { MessageTypes.Text, - MessageTypes.Reply, MessageTypes.Notice, MessageTypes.Emote, MessageTypes.None, }; + + /// returns if this event matches the passed event or transaction id + bool matchesEventOrTransactionId(String search) { + if (search == null) { + return false; + } + if (eventId == search) { + return true; + } + return unsigned != null && unsigned['transaction_id'] == search; + } + + /// Get the relationship type of an event. `null` if there is none + String get relationshipType { + if (content == null || !(content['m.relates_to'] is Map)) { + return null; + } + if (content['m.relates_to'].containsKey('rel_type')) { + return content['m.relates_to']['rel_type']; + } + if (content['m.relates_to'].containsKey('m.in_reply_to')) { + return RelationshipTypes.Reply; + } + return null; + } + + /// Get the event ID that this relationship will reference. `null` if there is none + String get relationshipEventId { + if (content == null || !(content['m.relates_to'] is Map)) { + return null; + } + if (content['m.relates_to'].containsKey('event_id')) { + return content['m.relates_to']['event_id']; + } + if (content['m.relates_to']['m.in_reply_to'] is Map && + content['m.relates_to']['m.in_reply_to'].containsKey('event_id')) { + return content['m.relates_to']['m.in_reply_to']['event_id']; + } + return null; + } + + /// Get wether this event has aggregated events from a certain [type] + /// To be able to do that you need to pass a [timeline] + bool hasAggregatedEvents(Timeline timeline, String type) => + timeline.aggregatedEvents.containsKey(eventId) && + timeline.aggregatedEvents[eventId].containsKey(type); + + /// Get all the aggregated event objects for a given [type]. To be able to do this + /// you have to pass a [timeline] + Set aggregatedEvents(Timeline timeline, String type) => + hasAggregatedEvents(timeline, type) + ? timeline.aggregatedEvents[eventId][type] + : {}; } diff --git a/lib/src/room.dart b/lib/src/room.dart index 0113123..be97100 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -104,7 +104,9 @@ class Room { /// Flag if the room is partial, meaning not all state events have been loaded yet bool partial = true; - /// Load all the missing state events for the room from the database. If the room has already been loaded, this does nothing. + /// Post-loads the room. + /// This load all the missing state events for the room from the database + /// If the room has already been loaded, this does nothing. Future postLoad() async { if (!partial || client.database == null) { return; @@ -500,6 +502,7 @@ class Room { Future sendTextEvent(String message, {String txid, Event inReplyTo, + String editEventId, bool parseMarkdown = true, Map> emotePacks}) { final event = { @@ -518,7 +521,20 @@ class Room { event['formatted_body'] = html; } } - return sendEvent(event, txid: txid, inReplyTo: inReplyTo); + return sendEvent(event, + txid: txid, inReplyTo: inReplyTo, editEventId: editEventId); + } + + /// Sends a reaction to an event with an [eventId] and the content [key] into a room. + /// Returns the event ID generated by the server for this reaction. + Future sendReaction(String eventId, String key, {String txid}) { + return sendEvent({ + 'm.relates_to': { + 'rel_type': RelationshipTypes.Reaction, + 'event_id': eventId, + 'key': key, + }, + }, type: EventTypes.Reaction, txid: txid); } /// Sends a [file] to this room after uploading it. Returns the mxc uri of @@ -529,6 +545,7 @@ class Room { MatrixFile file, { String txid, Event inReplyTo, + String editEventId, bool waitUntilSent = false, MatrixImageFile thumbnail, }) async { @@ -605,6 +622,7 @@ class Room { content, txid: txid, inReplyTo: inReplyTo, + editEventId: editEventId, ); if (waitUntilSent) { await sendResponse; @@ -615,7 +633,7 @@ class Room { /// Sends an event to this room with this json as a content. Returns the /// event ID generated from the server. Future sendEvent(Map content, - {String type, String txid, Event inReplyTo}) async { + {String type, String txid, Event inReplyTo, String editEventId}) async { type = type ?? EventTypes.Message; final sendType = (encrypted && client.encryptionEnabled) ? EventTypes.Encrypted : type; @@ -645,6 +663,20 @@ class Room { }, }; } + if (editEventId != null) { + final newContent = Map.from(content); + content['m.new_content'] = newContent; + content['m.relates_to'] = { + 'event_id': editEventId, + 'rel_type': RelationshipTypes.Edit, + }; + if (content['body'] is String) { + content['body'] = '* ' + content['body']; + } + if (content['formatted_body'] is String) { + content['formatted_body'] = '* ' + content['formatted_body']; + } + } final sortOrder = newSortOrder; // Display a *sending* event and store it. diff --git a/lib/src/timeline.dart b/lib/src/timeline.dart index 967b830..5f3bc08 100644 --- a/lib/src/timeline.dart +++ b/lib/src/timeline.dart @@ -35,6 +35,9 @@ class Timeline { final Room room; List events = []; + /// Map of event ID to map of type to set of aggregated events + Map>> aggregatedEvents = {}; + final onTimelineUpdateCallback onUpdate; final onTimelineInsertCallback onInsert; @@ -66,7 +69,10 @@ class Timeline { await room.requestHistory( historyCount: historyCount, onHistoryReceived: () { - if (room.prev_batch.isEmpty || room.prev_batch == null) events = []; + if (room.prev_batch.isEmpty || room.prev_batch == null) { + events.clear(); + aggregatedEvents.clear(); + } }, ); await Future.delayed(const Duration(seconds: 2)); @@ -82,9 +88,17 @@ class Timeline { // to be received via the onEvent stream, it is unneeded to call sortAndUpdate roomSub ??= room.client.onRoomUpdate.stream .where((r) => r.id == room.id && r.limitedTimeline == true) - .listen((r) => events.clear()); + .listen((r) { + events.clear(); + aggregatedEvents.clear(); + }); sessionIdReceivedSub ??= room.onSessionKeyReceived.stream.listen(_sessionKeyReceived); + + // we want to populate our aggregated events + for (final e in events) { + addAggregatedEvent(e); + } } /// Don't forget to call this before you dismiss this object! @@ -151,6 +165,47 @@ class Timeline { return i; } + void _removeEventFromSet(Set eventSet, Event event) { + eventSet.removeWhere((e) => + e.matchesEventOrTransactionId(event.eventId) || + (event.unsigned != null && + e.matchesEventOrTransactionId(event.unsigned['transaction_id']))); + } + + void addAggregatedEvent(Event event) { + // we want to add an event to the aggregation tree + if (event.relationshipType == null || event.relationshipEventId == null) { + return; // nothing to do + } + if (!aggregatedEvents.containsKey(event.relationshipEventId)) { + aggregatedEvents[event.relationshipEventId] = >{}; + } + if (!aggregatedEvents[event.relationshipEventId] + .containsKey(event.relationshipType)) { + aggregatedEvents[event.relationshipEventId] + [event.relationshipType] = {}; + } + // remove a potential old event + _removeEventFromSet( + aggregatedEvents[event.relationshipEventId][event.relationshipType], + event); + // add the new one + aggregatedEvents[event.relationshipEventId][event.relationshipType] + .add(event); + } + + void removeAggregatedEvent(Event event) { + aggregatedEvents.remove(event.eventId); + if (event.unsigned != null) { + aggregatedEvents.remove(event.unsigned['transaction_id']); + } + for (final types in aggregatedEvents.values) { + for (final events in types.values) { + _removeEventFromSet(events, event); + } + } + } + void _handleEventUpdate(EventUpdate eventUpdate) async { try { if (eventUpdate.roomID != room.id) return; @@ -161,12 +216,16 @@ class Timeline { if (eventUpdate.eventType == EventTypes.Redaction) { final eventId = _findEvent(event_id: eventUpdate.content['redacts']); if (eventId != null) { + removeAggregatedEvent(events[eventId]); events[eventId].setRedactionEvent(Event.fromJson( eventUpdate.content, room, eventUpdate.sortOrder)); } } else if (status == -2) { var i = _findEvent(event_id: eventUpdate.content['event_id']); - if (i < events.length) events.removeAt(i); + if (i < events.length) { + removeAggregatedEvent(events[i]); + events.removeAt(i); + } } else { var i = _findEvent( event_id: eventUpdate.content['event_id'], @@ -186,6 +245,7 @@ class Timeline { if (status < oldStatus && !(status == -1 && oldStatus == 0)) { events[i].status = oldStatus; } + addAggregatedEvent(events[i]); } else { var newEvent = Event.fromJson( eventUpdate.content, room, eventUpdate.sortOrder); @@ -196,6 +256,7 @@ class Timeline { -1) return; events.insert(0, newEvent); + addAggregatedEvent(newEvent); if (onInsert != null) onInsert(0); } } diff --git a/test/event_test.dart b/test/event_test.dart index 035b55b..a151c4c 100644 --- a/test/event_test.dart +++ b/test/event_test.dart @@ -67,7 +67,7 @@ void main() { expect(event.formattedText, formatted_body); expect(event.body, body); expect(event.type, EventTypes.Message); - expect(event.isReply, true); + expect(event.relationshipType, RelationshipTypes.Reply); jsonObj['state_key'] = ''; var state = Event.fromJson(jsonObj, null); expect(state.eventId, id); @@ -160,7 +160,43 @@ void main() { 'event_id': '1234', }; event = Event.fromJson(jsonObj, null); - expect(event.messageType, MessageTypes.Reply); + expect(event.messageType, MessageTypes.Text); + expect(event.relationshipType, RelationshipTypes.Reply); + expect(event.relationshipEventId, '1234'); + }); + + test('relationship types', () async { + Event event; + + jsonObj['content'] = { + 'msgtype': 'm.text', + 'text': 'beep', + }; + event = Event.fromJson(jsonObj, null); + expect(event.relationshipType, null); + expect(event.relationshipEventId, null); + + jsonObj['content']['m.relates_to'] = { + 'rel_type': 'm.replace', + 'event_id': 'abc', + }; + event = Event.fromJson(jsonObj, null); + expect(event.relationshipType, RelationshipTypes.Edit); + expect(event.relationshipEventId, 'abc'); + + jsonObj['content']['m.relates_to']['rel_type'] = 'm.annotation'; + event = Event.fromJson(jsonObj, null); + expect(event.relationshipType, RelationshipTypes.Reaction); + expect(event.relationshipEventId, 'abc'); + + jsonObj['content']['m.relates_to'] = { + 'm.in_reply_to': { + 'event_id': 'def', + }, + }; + event = Event.fromJson(jsonObj, null); + expect(event.relationshipType, RelationshipTypes.Reply); + expect(event.relationshipEventId, 'def'); }); test('redact', () async { @@ -790,5 +826,56 @@ void main() { }, room); expect(event.getLocalizedBody(FakeMatrixLocalizations()), null); }); + + test('aggregations', () { + var event = Event.fromJson({ + 'content': { + 'body': 'blah', + 'msgtype': 'm.text', + }, + 'event_id': '\$source', + }, null); + var edit1 = Event.fromJson({ + 'content': { + 'body': 'blah', + 'msgtype': 'm.text', + 'm.relates_to': { + 'event_id': '\$source', + 'rel_type': RelationshipTypes.Edit, + }, + }, + 'event_id': '\$edit1', + }, null); + var edit2 = Event.fromJson({ + 'content': { + 'body': 'blah', + 'msgtype': 'm.text', + 'm.relates_to': { + 'event_id': '\$source', + 'rel_type': RelationshipTypes.Edit, + }, + }, + 'event_id': '\$edit2', + }, null); + var room = Room(client: client); + var timeline = Timeline(events: [event, edit1, edit2], room: room); + expect(event.hasAggregatedEvents(timeline, RelationshipTypes.Edit), true); + expect(event.aggregatedEvents(timeline, RelationshipTypes.Edit), + {edit1, edit2}); + expect(event.aggregatedEvents(timeline, RelationshipTypes.Reaction), + {}); + expect(event.hasAggregatedEvents(timeline, RelationshipTypes.Reaction), + false); + + timeline.removeAggregatedEvent(edit2); + expect(event.aggregatedEvents(timeline, RelationshipTypes.Edit), {edit1}); + timeline.addAggregatedEvent(edit2); + expect(event.aggregatedEvents(timeline, RelationshipTypes.Edit), + {edit1, edit2}); + + timeline.removeAggregatedEvent(event); + expect( + event.aggregatedEvents(timeline, RelationshipTypes.Edit), {}); + }); }); } diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index 6bbd87e..760dfcf 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -2010,6 +2010,10 @@ class FakeMatrixApi extends MockClient { (var reqI) => { 'event_id': '\$event${FakeMatrixApi.eventCounter++}', }, + '/client/r0/rooms/!localpart%3Aserver.abc/send/m.reaction/testtxid': + (var reqI) => { + 'event_id': '\$event${FakeMatrixApi.eventCounter++}', + }, '/client/r0/rooms/!localpart%3Aexample.com/typing/%40alice%3Aexample.com': (var req) => {}, '/client/r0/rooms/!1234%3Aexample.com/send/m.room.message/1234': diff --git a/test/room_test.dart b/test/room_test.dart index b459db5..5bad302 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -27,7 +27,9 @@ import 'package:famedlysdk/src/database/database.dart' import 'package:test/test.dart'; import 'fake_client.dart'; +import 'fake_matrix_api.dart'; +import 'dart:convert'; import 'dart:typed_data'; void main() { @@ -349,9 +351,87 @@ void main() { }); test('sendEvent', () async { + FakeMatrixApi.calledEndpoints.clear(); final dynamic resp = await room.sendTextEvent('Hello world', 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', + 'msgtype': 'm.text', + }); + }); + + test('send edit', () async { + FakeMatrixApi.calledEndpoints.clear(); + final dynamic resp = await room.sendTextEvent('Hello world', + txid: 'testtxid', editEventId: '\$otherEvent'); + 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', + 'msgtype': 'm.text', + 'm.new_content': { + 'body': 'Hello world', + 'msgtype': 'm.text', + }, + 'm.relates_to': { + 'event_id': '\$otherEvent', + 'rel_type': 'm.replace', + }, + }); + }); + + test('send reply', () async { + var event = Event.fromJson({ + 'event_id': '\$replyEvent', + 'content': { + 'body': 'Blah', + 'msgtype': 'm.text', + }, + 'type': 'm.room.message', + 'sender': '@alice:example.org', + }, room); + FakeMatrixApi.calledEndpoints.clear(); + final dynamic resp = await room.sendTextEvent('Hello world', + txid: 'testtxid', inReplyTo: event); + 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:example.org> Blah\n\nHello world', + 'msgtype': 'm.text', + 'format': 'org.matrix.custom.html', + 'formatted_body': + '
In reply to @alice:example.org
Blah
Hello world', + 'm.relates_to': { + 'm.in_reply_to': { + 'event_id': '\$replyEvent', + }, + }, + }); + }); + + test('send reaction', () async { + FakeMatrixApi.calledEndpoints.clear(); + final dynamic resp = + await room.sendReaction('\$otherEvent', '🦊', txid: 'testtxid'); + expect(resp.startsWith('\$event'), true); + final entry = FakeMatrixApi.calledEndpoints.entries + .firstWhere((p) => p.key.contains('/send/m.reaction/')); + final content = json.decode(entry.value.first); + expect(content, { + 'm.relates_to': { + 'event_id': '\$otherEvent', + 'rel_type': 'm.annotation', + 'key': '🦊', + }, + }); }); // Not working because there is no real file to test it...