Merge branch 'soru/event-aggregation' into 'master'

Properly imlement event aggregations

See merge request famedly/famedlysdk!399
This commit is contained in:
Christian Pauly 2020-07-27 07:39:48 +00:00
commit 1cd430ab3b
8 changed files with 335 additions and 27 deletions

View file

@ -19,6 +19,7 @@
abstract class EventTypes { abstract class EventTypes {
static const String Message = 'm.room.message'; static const String Message = 'm.room.message';
static const String Sticker = 'm.sticker'; static const String Sticker = 'm.sticker';
static const String Reaction = 'm.reaction';
static const String Redaction = 'm.room.redaction'; static const String Redaction = 'm.room.redaction';
static const String RoomAliases = 'm.room.aliases'; static const String RoomAliases = 'm.room.aliases';
static const String RoomCanonicalAlias = 'm.room.canonical_alias'; static const String RoomCanonicalAlias = 'm.room.canonical_alias';

View file

@ -25,7 +25,6 @@ abstract class MessageTypes {
static const String Audio = 'm.audio'; static const String Audio = 'm.audio';
static const String File = 'm.file'; static const String File = 'm.file';
static const String Location = 'm.location'; static const String Location = 'm.location';
static const String Reply = 'm.relates_to';
static const String Sticker = 'm.sticker'; static const String Sticker = 'm.sticker';
static const String BadEncrypted = 'm.bad.encrypted'; static const String BadEncrypted = 'm.bad.encrypted';
static const String None = 'm.none'; static const String None = 'm.none';

View file

@ -28,6 +28,12 @@ import './room.dart';
import 'utils/matrix_localizations.dart'; import 'utils/matrix_localizations.dart';
import './database/database.dart' show DbRoomState, DbEvent; 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. /// 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 { class Event extends MatrixEvent {
User get sender => room.getUserByMXIDSync(senderId ?? '@unknown'); User get sender => room.getUserByMXIDSync(senderId ?? '@unknown');
@ -212,10 +218,7 @@ class Event extends MatrixEvent {
unsigned: unsigned, unsigned: unsigned,
room: room); room: room);
String get messageType => (content['m.relates_to'] is Map && String get messageType => content['msgtype'] ?? MessageTypes.Text;
content['m.relates_to']['m.in_reply_to'] != null)
? MessageTypes.Reply
: content['msgtype'] ?? MessageTypes.Text;
void setRedactionEvent(Event redactedBecause) { void setRedactionEvent(Event redactedBecause) {
unsigned = { unsigned = {
@ -328,20 +331,10 @@ class Event extends MatrixEvent {
Future<dynamic> redact({String reason, String txid}) => Future<dynamic> redact({String reason, String txid}) =>
room.redactEvent(eventId, reason: reason, txid: 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<String, dynamic> &&
content['m.relates_to']['m.in_reply_to'] is Map<String, dynamic> &&
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. /// Searches for the reply event in the given timeline.
Future<Event> getReplyEvent(Timeline timeline) async { Future<Event> getReplyEvent(Timeline timeline) async {
if (!isReply) return null; if (relationshipType != RelationshipTypes.Reply) return null;
final String replyEventId = return await timeline.getEventById(relationshipEventId);
content['m.relates_to']['m.in_reply_to']['event_id'];
return await timeline.getEventById(replyEventId);
} }
/// If this event is encrypted and the decryption was not successful because /// 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.Text:
case MessageTypes.Notice: case MessageTypes.Notice:
case MessageTypes.None: case MessageTypes.None:
case MessageTypes.Reply:
localizedBody = body; localizedBody = body;
break; break;
} }
@ -663,9 +655,61 @@ class Event extends MatrixEvent {
static const Set<String> textOnlyMessageTypes = { static const Set<String> textOnlyMessageTypes = {
MessageTypes.Text, MessageTypes.Text,
MessageTypes.Reply,
MessageTypes.Notice, MessageTypes.Notice,
MessageTypes.Emote, MessageTypes.Emote,
MessageTypes.None, 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<Event> aggregatedEvents(Timeline timeline, String type) =>
hasAggregatedEvents(timeline, type)
? timeline.aggregatedEvents[eventId][type]
: <Event>{};
} }

View file

@ -104,7 +104,9 @@ class Room {
/// Flag if the room is partial, meaning not all state events have been loaded yet /// Flag if the room is partial, meaning not all state events have been loaded yet
bool partial = true; 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<void> postLoad() async { Future<void> postLoad() async {
if (!partial || client.database == null) { if (!partial || client.database == null) {
return; return;
@ -500,6 +502,7 @@ class Room {
Future<String> sendTextEvent(String message, Future<String> sendTextEvent(String message,
{String txid, {String txid,
Event inReplyTo, Event inReplyTo,
String editEventId,
bool parseMarkdown = true, bool parseMarkdown = true,
Map<String, Map<String, String>> emotePacks}) { Map<String, Map<String, String>> emotePacks}) {
final event = <String, dynamic>{ final event = <String, dynamic>{
@ -518,7 +521,20 @@ class Room {
event['formatted_body'] = html; 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<String> 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 /// Sends a [file] to this room after uploading it. Returns the mxc uri of
@ -529,6 +545,7 @@ class Room {
MatrixFile file, { MatrixFile file, {
String txid, String txid,
Event inReplyTo, Event inReplyTo,
String editEventId,
bool waitUntilSent = false, bool waitUntilSent = false,
MatrixImageFile thumbnail, MatrixImageFile thumbnail,
}) async { }) async {
@ -605,6 +622,7 @@ class Room {
content, content,
txid: txid, txid: txid,
inReplyTo: inReplyTo, inReplyTo: inReplyTo,
editEventId: editEventId,
); );
if (waitUntilSent) { if (waitUntilSent) {
await sendResponse; await sendResponse;
@ -615,7 +633,7 @@ class Room {
/// Sends an event to this room with this json as a content. Returns the /// Sends an event to this room with this json as a content. Returns the
/// event ID generated from the server. /// event ID generated from the server.
Future<String> sendEvent(Map<String, dynamic> content, Future<String> sendEvent(Map<String, dynamic> content,
{String type, String txid, Event inReplyTo}) async { {String type, String txid, Event inReplyTo, String editEventId}) async {
type = type ?? EventTypes.Message; type = type ?? EventTypes.Message;
final sendType = final sendType =
(encrypted && client.encryptionEnabled) ? EventTypes.Encrypted : type; (encrypted && client.encryptionEnabled) ? EventTypes.Encrypted : type;
@ -645,6 +663,20 @@ class Room {
}, },
}; };
} }
if (editEventId != null) {
final newContent = Map<String, dynamic>.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; final sortOrder = newSortOrder;
// Display a *sending* event and store it. // Display a *sending* event and store it.

View file

@ -35,6 +35,9 @@ class Timeline {
final Room room; final Room room;
List<Event> events = []; List<Event> events = [];
/// Map of event ID to map of type to set of aggregated events
Map<String, Map<String, Set<Event>>> aggregatedEvents = {};
final onTimelineUpdateCallback onUpdate; final onTimelineUpdateCallback onUpdate;
final onTimelineInsertCallback onInsert; final onTimelineInsertCallback onInsert;
@ -66,7 +69,10 @@ class Timeline {
await room.requestHistory( await room.requestHistory(
historyCount: historyCount, historyCount: historyCount,
onHistoryReceived: () { 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)); 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 // to be received via the onEvent stream, it is unneeded to call sortAndUpdate
roomSub ??= room.client.onRoomUpdate.stream roomSub ??= room.client.onRoomUpdate.stream
.where((r) => r.id == room.id && r.limitedTimeline == true) .where((r) => r.id == room.id && r.limitedTimeline == true)
.listen((r) => events.clear()); .listen((r) {
events.clear();
aggregatedEvents.clear();
});
sessionIdReceivedSub ??= sessionIdReceivedSub ??=
room.onSessionKeyReceived.stream.listen(_sessionKeyReceived); 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! /// Don't forget to call this before you dismiss this object!
@ -151,6 +165,47 @@ class Timeline {
return i; return i;
} }
void _removeEventFromSet(Set<Event> 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] = <String, Set<Event>>{};
}
if (!aggregatedEvents[event.relationshipEventId]
.containsKey(event.relationshipType)) {
aggregatedEvents[event.relationshipEventId]
[event.relationshipType] = <Event>{};
}
// 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 { void _handleEventUpdate(EventUpdate eventUpdate) async {
try { try {
if (eventUpdate.roomID != room.id) return; if (eventUpdate.roomID != room.id) return;
@ -161,12 +216,16 @@ class Timeline {
if (eventUpdate.eventType == EventTypes.Redaction) { if (eventUpdate.eventType == EventTypes.Redaction) {
final eventId = _findEvent(event_id: eventUpdate.content['redacts']); final eventId = _findEvent(event_id: eventUpdate.content['redacts']);
if (eventId != null) { if (eventId != null) {
removeAggregatedEvent(events[eventId]);
events[eventId].setRedactionEvent(Event.fromJson( events[eventId].setRedactionEvent(Event.fromJson(
eventUpdate.content, room, eventUpdate.sortOrder)); eventUpdate.content, room, eventUpdate.sortOrder));
} }
} else if (status == -2) { } else if (status == -2) {
var i = _findEvent(event_id: eventUpdate.content['event_id']); 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 { } else {
var i = _findEvent( var i = _findEvent(
event_id: eventUpdate.content['event_id'], event_id: eventUpdate.content['event_id'],
@ -186,6 +245,7 @@ class Timeline {
if (status < oldStatus && !(status == -1 && oldStatus == 0)) { if (status < oldStatus && !(status == -1 && oldStatus == 0)) {
events[i].status = oldStatus; events[i].status = oldStatus;
} }
addAggregatedEvent(events[i]);
} else { } else {
var newEvent = Event.fromJson( var newEvent = Event.fromJson(
eventUpdate.content, room, eventUpdate.sortOrder); eventUpdate.content, room, eventUpdate.sortOrder);
@ -196,6 +256,7 @@ class Timeline {
-1) return; -1) return;
events.insert(0, newEvent); events.insert(0, newEvent);
addAggregatedEvent(newEvent);
if (onInsert != null) onInsert(0); if (onInsert != null) onInsert(0);
} }
} }

View file

@ -67,7 +67,7 @@ void main() {
expect(event.formattedText, formatted_body); expect(event.formattedText, formatted_body);
expect(event.body, body); expect(event.body, body);
expect(event.type, EventTypes.Message); expect(event.type, EventTypes.Message);
expect(event.isReply, true); expect(event.relationshipType, RelationshipTypes.Reply);
jsonObj['state_key'] = ''; jsonObj['state_key'] = '';
var state = Event.fromJson(jsonObj, null); var state = Event.fromJson(jsonObj, null);
expect(state.eventId, id); expect(state.eventId, id);
@ -160,7 +160,43 @@ void main() {
'event_id': '1234', 'event_id': '1234',
}; };
event = Event.fromJson(jsonObj, null); 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'] = <String, dynamic>{
'msgtype': 'm.text',
'text': 'beep',
};
event = Event.fromJson(jsonObj, null);
expect(event.relationshipType, null);
expect(event.relationshipEventId, null);
jsonObj['content']['m.relates_to'] = <String, dynamic>{
'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 { test('redact', () async {
@ -790,5 +826,56 @@ void main() {
}, room); }, room);
expect(event.getLocalizedBody(FakeMatrixLocalizations()), null); 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>[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),
<Event>{});
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), <Event>{});
});
}); });
} }

View file

@ -2010,6 +2010,10 @@ class FakeMatrixApi extends MockClient {
(var reqI) => { (var reqI) => {
'event_id': '\$event${FakeMatrixApi.eventCounter++}', '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': '/client/r0/rooms/!localpart%3Aexample.com/typing/%40alice%3Aexample.com':
(var req) => {}, (var req) => {},
'/client/r0/rooms/!1234%3Aexample.com/send/m.room.message/1234': '/client/r0/rooms/!1234%3Aexample.com/send/m.room.message/1234':

View file

@ -27,7 +27,9 @@ import 'package:famedlysdk/src/database/database.dart'
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'fake_client.dart'; import 'fake_client.dart';
import 'fake_matrix_api.dart';
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
void main() { void main() {
@ -349,9 +351,87 @@ void main() {
}); });
test('sendEvent', () async { test('sendEvent', () async {
FakeMatrixApi.calledEndpoints.clear();
final dynamic resp = final dynamic resp =
await room.sendTextEvent('Hello world', txid: 'testtxid'); await room.sendTextEvent('Hello world', txid: 'testtxid');
expect(resp.startsWith('\$event'), true); 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':
'<mx-reply><blockquote><a href="https://matrix.to/#/!localpart:server.abc/\$replyEvent">In reply to</a> <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a><br>Blah</blockquote></mx-reply>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... // Not working because there is no real file to test it...