Merge branch 'soru/event-aggregation' into 'master'
Properly imlement event aggregations See merge request famedly/famedlysdk!399
This commit is contained in:
commit
1cd430ab3b
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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>{};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>{});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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':
|
||||||
|
|
|
@ -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...
|
||||||
|
|
Loading…
Reference in a new issue