Line data Source code
1 : /*
2 : * Famedly Matrix SDK
3 : * Copyright (C) 2019, 2020, 2021 Famedly GmbH
4 : *
5 : * This program is free software: you can redistribute it and/or modify
6 : * it under the terms of the GNU Affero General Public License as
7 : * published by the Free Software Foundation, either version 3 of the
8 : * License, or (at your option) any later version.
9 : *
10 : * This program is distributed in the hope that it will be useful,
11 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 : * GNU Affero General Public License for more details.
14 : *
15 : * You should have received a copy of the GNU Affero General Public License
16 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 : */
18 :
19 : import 'dart:convert';
20 : import 'dart:typed_data';
21 :
22 : import 'package:collection/collection.dart';
23 : import 'package:html/parser.dart';
24 :
25 : import 'package:matrix/matrix.dart';
26 : import 'package:matrix/src/utils/event_localizations.dart';
27 : import 'package:matrix/src/utils/file_send_request_credentials.dart';
28 : import 'package:matrix/src/utils/html_to_text.dart';
29 : import 'package:matrix/src/utils/markdown.dart';
30 :
31 : abstract class RelationshipTypes {
32 : static const String reply = 'm.in_reply_to';
33 : static const String edit = 'm.replace';
34 : static const String reaction = 'm.annotation';
35 : static const String thread = 'm.thread';
36 : }
37 :
38 : /// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event.
39 : class Event extends MatrixEvent {
40 : /// Requests the user object of the sender of this event.
41 3 : Future<User?> fetchSenderUser() => room.requestUser(
42 1 : senderId,
43 : ignoreErrors: true,
44 : );
45 :
46 0 : @Deprecated(
47 : 'Use eventSender instead or senderFromMemoryOrFallback for a synchronous alternative')
48 0 : User get sender => senderFromMemoryOrFallback;
49 :
50 3 : User get senderFromMemoryOrFallback =>
51 9 : room.unsafeGetUserFromMemoryOrFallback(senderId);
52 :
53 : /// The room this event belongs to. May be null.
54 : final Room room;
55 :
56 : /// The status of this event.
57 : EventStatus status;
58 :
59 : static const EventStatus defaultStatus = EventStatus.synced;
60 :
61 : /// Optional. The event that redacted this event, if any. Otherwise null.
62 11 : Event? get redactedBecause {
63 20 : final redacted_because = unsigned?['redacted_because'];
64 11 : final room = this.room;
65 11 : return (redacted_because is Map<String, dynamic>)
66 4 : ? Event.fromJson(redacted_because, room)
67 : : null;
68 : }
69 :
70 22 : bool get redacted => redactedBecause != null;
71 :
72 2 : User? get stateKeyUser => stateKey != null
73 3 : ? room.unsafeGetUserFromMemoryOrFallback(stateKey!)
74 : : null;
75 :
76 : MatrixEvent? _originalSource;
77 :
78 58 : MatrixEvent? get originalSource => _originalSource;
79 :
80 35 : Event({
81 : this.status = defaultStatus,
82 : required Map<String, dynamic> super.content,
83 : required super.type,
84 : required String eventId,
85 : required super.senderId,
86 : required DateTime originServerTs,
87 : Map<String, dynamic>? unsigned,
88 : Map<String, dynamic>? prevContent,
89 : String? stateKey,
90 : required this.room,
91 : MatrixEvent? originalSource,
92 : }) : _originalSource = originalSource,
93 35 : super(
94 : eventId: eventId,
95 : originServerTs: originServerTs,
96 35 : roomId: room.id,
97 : ) {
98 35 : this.eventId = eventId;
99 35 : this.unsigned = unsigned;
100 : // synapse unfortunately isn't following the spec and tosses the prev_content
101 : // into the unsigned block.
102 : // Currently we are facing a very strange bug in web which is impossible to debug.
103 : // It may be because of this line so we put this in try-catch until we can fix it.
104 : try {
105 70 : this.prevContent = (prevContent != null && prevContent.isNotEmpty)
106 : ? prevContent
107 : : (unsigned != null &&
108 35 : unsigned.containsKey('prev_content') &&
109 4 : unsigned['prev_content'] is Map)
110 2 : ? unsigned['prev_content']
111 : : null;
112 : } catch (_) {
113 : // A strange bug in dart web makes this crash
114 : }
115 35 : this.stateKey = stateKey;
116 :
117 : // Mark event as failed to send if status is `sending` and event is older
118 : // than the timeout. This should not happen with the deprecated Moor
119 : // database!
120 100 : if (status.isSending && room.client.database != null) {
121 : // Age of this event in milliseconds
122 21 : final age = DateTime.now().millisecondsSinceEpoch -
123 7 : originServerTs.millisecondsSinceEpoch;
124 :
125 7 : final room = this.room;
126 28 : if (age > room.client.sendTimelineEventTimeout.inMilliseconds) {
127 : // Update this event in database and open timelines
128 0 : final json = toJson();
129 0 : json['unsigned'] ??= <String, dynamic>{};
130 0 : json['unsigned'][messageSendingStatusKey] = EventStatus.error.intValue;
131 : // ignore: discarded_futures
132 0 : room.client.handleSync(
133 0 : SyncUpdate(
134 : nextBatch: '',
135 0 : rooms: RoomsUpdate(
136 0 : join: {
137 0 : room.id: JoinedRoomUpdate(
138 0 : timeline: TimelineUpdate(
139 0 : events: [MatrixEvent.fromJson(json)],
140 : ),
141 : )
142 : },
143 : ),
144 : ),
145 : );
146 : }
147 : }
148 : }
149 :
150 35 : static Map<String, dynamic> getMapFromPayload(Object? payload) {
151 35 : if (payload is String) {
152 : try {
153 9 : return json.decode(payload);
154 : } catch (e) {
155 0 : return {};
156 : }
157 : }
158 35 : if (payload is Map<String, dynamic>) return payload;
159 35 : return {};
160 : }
161 :
162 7 : factory Event.fromMatrixEvent(
163 : MatrixEvent matrixEvent,
164 : Room room, {
165 : EventStatus status = defaultStatus,
166 : }) =>
167 7 : Event(
168 : status: status,
169 7 : content: matrixEvent.content,
170 7 : type: matrixEvent.type,
171 7 : eventId: matrixEvent.eventId,
172 7 : senderId: matrixEvent.senderId,
173 7 : originServerTs: matrixEvent.originServerTs,
174 7 : unsigned: matrixEvent.unsigned,
175 7 : prevContent: matrixEvent.prevContent,
176 7 : stateKey: matrixEvent.stateKey,
177 : room: room,
178 : );
179 :
180 : /// Get a State event from a table row or from the event stream.
181 35 : factory Event.fromJson(
182 : Map<String, dynamic> jsonPayload,
183 : Room room,
184 : ) {
185 70 : final content = Event.getMapFromPayload(jsonPayload['content']);
186 70 : final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
187 70 : final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
188 : final originalSource =
189 70 : Event.getMapFromPayload(jsonPayload['original_source']);
190 35 : return Event(
191 70 : status: eventStatusFromInt(jsonPayload['status'] ??
192 32 : unsigned[messageSendingStatusKey] ??
193 32 : defaultStatus.intValue),
194 35 : stateKey: jsonPayload['state_key'],
195 : prevContent: prevContent,
196 : content: content,
197 35 : type: jsonPayload['type'],
198 35 : eventId: jsonPayload['event_id'] ?? '',
199 35 : senderId: jsonPayload['sender'],
200 35 : originServerTs: DateTime.fromMillisecondsSinceEpoch(
201 35 : jsonPayload['origin_server_ts'] ?? 0),
202 : unsigned: unsigned,
203 : room: room,
204 35 : originalSource: originalSource.isEmpty
205 : ? null
206 1 : : MatrixEvent.fromJson(originalSource));
207 : }
208 :
209 29 : @override
210 : Map<String, dynamic> toJson() {
211 29 : final data = <String, dynamic>{};
212 41 : if (stateKey != null) data['state_key'] = stateKey;
213 58 : if (prevContent?.isNotEmpty == true) {
214 0 : data['prev_content'] = prevContent;
215 : }
216 58 : data['content'] = content;
217 58 : data['type'] = type;
218 58 : data['event_id'] = eventId;
219 58 : data['room_id'] = roomId;
220 58 : data['sender'] = senderId;
221 87 : data['origin_server_ts'] = originServerTs.millisecondsSinceEpoch;
222 87 : if (unsigned?.isNotEmpty == true) {
223 22 : data['unsigned'] = unsigned;
224 : }
225 29 : if (originalSource != null) {
226 3 : data['original_source'] = originalSource?.toJson();
227 : }
228 : return data;
229 : }
230 :
231 62 : User get asUser => User.fromState(
232 : // state key should always be set for member events
233 31 : stateKey: stateKey!,
234 31 : prevContent: prevContent,
235 31 : content: content,
236 31 : typeKey: type,
237 31 : senderId: senderId,
238 31 : room: room,
239 : );
240 :
241 15 : String get messageType => type == EventTypes.Sticker
242 : ? MessageTypes.Sticker
243 10 : : (content.tryGet<String>('msgtype') ?? MessageTypes.Text);
244 :
245 4 : void setRedactionEvent(Event redactedBecause) {
246 8 : unsigned = {
247 4 : 'redacted_because': redactedBecause.toJson(),
248 : };
249 4 : prevContent = null;
250 4 : _originalSource = null;
251 4 : final contentKeyWhiteList = <String>[];
252 4 : switch (type) {
253 4 : case EventTypes.RoomMember:
254 1 : contentKeyWhiteList.add('membership');
255 : break;
256 4 : case EventTypes.RoomCreate:
257 1 : contentKeyWhiteList.add('creator');
258 : break;
259 4 : case EventTypes.RoomJoinRules:
260 1 : contentKeyWhiteList.add('join_rule');
261 : break;
262 4 : case EventTypes.RoomPowerLevels:
263 1 : contentKeyWhiteList.add('ban');
264 1 : contentKeyWhiteList.add('events');
265 1 : contentKeyWhiteList.add('events_default');
266 1 : contentKeyWhiteList.add('kick');
267 1 : contentKeyWhiteList.add('redact');
268 1 : contentKeyWhiteList.add('state_default');
269 1 : contentKeyWhiteList.add('users');
270 1 : contentKeyWhiteList.add('users_default');
271 : break;
272 4 : case EventTypes.RoomAliases:
273 1 : contentKeyWhiteList.add('aliases');
274 : break;
275 4 : case EventTypes.HistoryVisibility:
276 1 : contentKeyWhiteList.add('history_visibility');
277 : break;
278 : default:
279 : break;
280 : }
281 16 : content.removeWhere((k, v) => !contentKeyWhiteList.contains(k));
282 : }
283 :
284 : /// Returns the body of this event if it has a body.
285 27 : String get text => content.tryGet<String>('body') ?? '';
286 :
287 : /// Returns the formatted boy of this event if it has a formatted body.
288 12 : String get formattedText => content.tryGet<String>('formatted_body') ?? '';
289 :
290 : /// Use this to get the body.
291 9 : String get body {
292 9 : if (redacted) return 'Redacted';
293 27 : if (text != '') return text;
294 2 : if (formattedText != '') return formattedText;
295 1 : return type;
296 : }
297 :
298 : /// Use this to get a plain-text representation of the event, stripping things
299 : /// like spoilers and thelike. Useful for plain text notifications.
300 4 : String get plaintextBody => content['format'] == 'org.matrix.custom.html'
301 2 : ? HtmlToText.convert(formattedText)
302 1 : : body;
303 :
304 : /// Returns a list of [Receipt] instances for this event.
305 3 : List<Receipt> get receipts {
306 3 : final room = this.room;
307 3 : final receipts = room.receiptState;
308 9 : final receiptsList = receipts.global.otherUsers.entries
309 8 : .where((entry) => entry.value.eventId == eventId)
310 5 : .map((entry) => Receipt(
311 2 : room.unsafeGetUserFromMemoryOrFallback(entry.key),
312 2 : entry.value.timestamp))
313 3 : .toList();
314 :
315 6 : final own = receipts.global.latestOwnReceipt;
316 3 : if (own != null && own.eventId == eventId) {
317 2 : receiptsList.add(Receipt(
318 3 : room.unsafeGetUserFromMemoryOrFallback(room.client.userID!),
319 1 : own.timestamp));
320 : }
321 :
322 : return receiptsList;
323 : }
324 :
325 0 : @Deprecated('Use [cancelSend()] instead.')
326 : Future<bool> remove() async {
327 : try {
328 0 : await cancelSend();
329 : return true;
330 : } catch (_) {
331 : return false;
332 : }
333 : }
334 :
335 : /// Removes an unsent or yet-to-send event from the database and timeline.
336 : /// These are events marked with the status `SENDING` or `ERROR`.
337 : /// Throws an exception if used for an already sent event!
338 3 : Future<void> cancelSend() async {
339 6 : if (status.isSent) {
340 1 : throw Exception('Can only delete events which are not sent yet!');
341 : }
342 :
343 17 : await room.client.database?.removeEvent(eventId, room.id);
344 15 : room.client.onCancelSendEvent.add(eventId);
345 : }
346 :
347 : /// Try to send this event again. Only works with events of status -1.
348 3 : Future<String?> sendAgain({String? txid}) async {
349 6 : if (!status.isError) return null;
350 :
351 : // Retry sending a file:
352 : if ({
353 3 : MessageTypes.Image,
354 3 : MessageTypes.Video,
355 3 : MessageTypes.Audio,
356 3 : MessageTypes.File,
357 6 : }.contains(messageType)) {
358 0 : final file = room.sendingFilePlaceholders[eventId];
359 : if (file == null) {
360 0 : await cancelSend();
361 0 : throw Exception('Can not try to send again. File is no longer cached.');
362 : }
363 0 : final thumbnail = room.sendingFileThumbnails[eventId];
364 0 : final credentials = FileSendRequestCredentials.fromJson(unsigned ?? {});
365 0 : final inReplyTo = credentials.inReplyTo == null
366 : ? null
367 0 : : await room.getEventById(credentials.inReplyTo!);
368 0 : txid ??= unsigned?.tryGet<String>('transaction_id');
369 0 : return await room.sendFileEvent(
370 : file,
371 : txid: txid,
372 : thumbnail: thumbnail,
373 : inReplyTo: inReplyTo,
374 0 : editEventId: credentials.editEventId,
375 0 : shrinkImageMaxDimension: credentials.shrinkImageMaxDimension,
376 0 : extraContent: credentials.extraContent,
377 : );
378 : }
379 :
380 : // we do not remove the event here. It will automatically be updated
381 : // in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
382 6 : return await room.sendEvent(
383 3 : content,
384 4 : txid: txid ?? unsigned?.tryGet<String>('transaction_id') ?? eventId,
385 : );
386 : }
387 :
388 : /// Whether the client is allowed to redact this event.
389 6 : bool get canRedact => senderId == room.client.userID || room.canRedact;
390 :
391 : /// Redacts this event. Throws `ErrorResponse` on error.
392 1 : Future<String?> redactEvent({String? reason, String? txid}) async =>
393 3 : await room.redactEvent(eventId, reason: reason, txid: txid);
394 :
395 : /// Searches for the reply event in the given timeline.
396 0 : Future<Event?> getReplyEvent(Timeline timeline) async {
397 0 : if (relationshipType != RelationshipTypes.reply) return null;
398 0 : final relationshipEventId = this.relationshipEventId;
399 : return relationshipEventId == null
400 : ? null
401 0 : : await timeline.getEventById(relationshipEventId);
402 : }
403 :
404 : /// If this event is encrypted and the decryption was not successful because
405 : /// the session is unknown, this requests the session key from other devices
406 : /// in the room. If the event is not encrypted or the decryption failed because
407 : /// of a different error, this throws an exception.
408 1 : Future<void> requestKey() async {
409 2 : if (type != EventTypes.Encrypted ||
410 2 : messageType != MessageTypes.BadEncrypted ||
411 3 : content['can_request_session'] != true) {
412 : throw ('Session key not requestable');
413 : }
414 :
415 2 : final sessionId = content.tryGet<String>('session_id');
416 2 : final senderKey = content.tryGet<String>('sender_key');
417 : if (sessionId == null || senderKey == null) {
418 : throw ('Unknown session_id or sender_key');
419 : }
420 2 : await room.requestSessionKey(sessionId, senderKey);
421 : return;
422 : }
423 :
424 : /// Gets the info map of file events, or a blank map if none present
425 1 : Map get infoMap =>
426 3 : content.tryGetMap<String, Object?>('info') ?? <String, Object?>{};
427 :
428 : /// Gets the thumbnail info map of file events, or a blank map if nonepresent
429 4 : Map get thumbnailInfoMap => infoMap['thumbnail_info'] is Map
430 2 : ? infoMap['thumbnail_info']
431 1 : : <String, dynamic>{};
432 :
433 : /// Returns if a file event has an attachment
434 7 : bool get hasAttachment => content['url'] is String || content['file'] is Map;
435 :
436 : /// Returns if a file event has a thumbnail
437 1 : bool get hasThumbnail =>
438 6 : infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map;
439 :
440 : /// Returns if a file events attachment is encrypted
441 4 : bool get isAttachmentEncrypted => content['file'] is Map;
442 :
443 : /// Returns if a file events thumbnail is encrypted
444 4 : bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map;
445 :
446 : /// Gets the mimetype of the attachment of a file event, or a blank string if not present
447 4 : String get attachmentMimetype => infoMap['mimetype'] is String
448 3 : ? infoMap['mimetype'].toLowerCase()
449 1 : : (content
450 1 : .tryGetMap<String, Object?>('file')
451 1 : ?.tryGet<String>('mimetype') ??
452 : '');
453 :
454 : /// Gets the mimetype of the thumbnail of a file event, or a blank string if not present
455 4 : String get thumbnailMimetype => thumbnailInfoMap['mimetype'] is String
456 3 : ? thumbnailInfoMap['mimetype'].toLowerCase()
457 3 : : (infoMap['thumbnail_file'] is Map &&
458 4 : infoMap['thumbnail_file']['mimetype'] is String
459 3 : ? infoMap['thumbnail_file']['mimetype']
460 : : '');
461 :
462 : /// Gets the underlying mxc url of an attachment of a file event, or null if not present
463 1 : Uri? get attachmentMxcUrl {
464 1 : final url = isAttachmentEncrypted
465 3 : ? (content.tryGetMap<String, Object?>('file')?['url'])
466 2 : : content['url'];
467 2 : return url is String ? Uri.tryParse(url) : null;
468 : }
469 :
470 : /// Gets the underlying mxc url of a thumbnail of a file event, or null if not present
471 1 : Uri? get thumbnailMxcUrl {
472 1 : final url = isThumbnailEncrypted
473 3 : ? infoMap['thumbnail_file']['url']
474 2 : : infoMap['thumbnail_url'];
475 2 : return url is String ? Uri.tryParse(url) : null;
476 : }
477 :
478 : /// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present
479 1 : Uri? attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) {
480 : if (getThumbnail &&
481 3 : infoMap['size'] is int &&
482 3 : thumbnailInfoMap['size'] is int &&
483 0 : infoMap['size'] <= thumbnailInfoMap['size']) {
484 : getThumbnail = false;
485 : }
486 1 : if (getThumbnail && !hasThumbnail) {
487 : getThumbnail = false;
488 : }
489 2 : return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
490 : }
491 :
492 : // size determined from an approximate 800x800 jpeg thumbnail with method=scale
493 : static const _minNoThumbSize = 80 * 1024;
494 :
495 : /// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
496 : /// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
497 : /// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
498 : /// for the respective thumbnailing properties.
499 : /// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
500 : /// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
501 : /// [animated] says weather the thumbnail is animated
502 1 : Uri? getAttachmentUrl(
503 : {bool getThumbnail = false,
504 : bool useThumbnailMxcUrl = false,
505 : double width = 800.0,
506 : double height = 800.0,
507 : ThumbnailMethod method = ThumbnailMethod.scale,
508 : int minNoThumbSize = _minNoThumbSize,
509 : bool animated = false}) {
510 3 : if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
511 1 : !hasAttachment ||
512 1 : isAttachmentEncrypted) {
513 : return null; // can't url-thumbnail in encrypted rooms
514 : }
515 1 : if (useThumbnailMxcUrl && !hasThumbnail) {
516 : return null; // can't fetch from thumbnail
517 : }
518 2 : final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
519 : final thisMxcUrl =
520 4 : useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
521 : // if we have as method scale, we can return safely the original image, should it be small enough
522 : if (getThumbnail &&
523 1 : method == ThumbnailMethod.scale &&
524 2 : thisInfoMap['size'] is int &&
525 2 : thisInfoMap['size'] < minNoThumbSize) {
526 : getThumbnail = false;
527 : }
528 : // now generate the actual URLs
529 : if (getThumbnail) {
530 2 : return Uri.parse(thisMxcUrl).getThumbnail(
531 2 : room.client,
532 : width: width,
533 : height: height,
534 : method: method,
535 : animated: animated,
536 : );
537 : } else {
538 4 : return Uri.parse(thisMxcUrl).getDownloadLink(room.client);
539 : }
540 : }
541 :
542 : /// Returns if an attachment is in the local store
543 1 : Future<bool> isAttachmentInLocalStore({bool getThumbnail = false}) async {
544 3 : if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
545 0 : throw ("This event has the type '$type' and so it can't contain an attachment.");
546 : }
547 1 : final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
548 : if (mxcUrl == null) {
549 : throw "This event hasn't any attachment or thumbnail.";
550 : }
551 2 : getThumbnail = mxcUrl != attachmentMxcUrl;
552 : // Is this file storeable?
553 1 : final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
554 3 : final database = room.client.database;
555 : if (database == null) {
556 : return false;
557 : }
558 :
559 2 : final storeable = thisInfoMap['size'] is int &&
560 3 : thisInfoMap['size'] <= database.maxFileSize;
561 :
562 : Uint8List? uint8list;
563 : if (storeable) {
564 0 : uint8list = await database.getFile(mxcUrl);
565 : }
566 : return uint8list != null;
567 : }
568 :
569 : /// Downloads (and decrypts if necessary) the attachment of this
570 : /// event and returns it as a [MatrixFile]. If this event doesn't
571 : /// contain an attachment, this throws an error. Set [getThumbnail] to
572 : /// true to download the thumbnail instead. Set [fromLocalStoreOnly] to true
573 : /// if you want to retrieve the attachment from the local store only without
574 : /// making http request.
575 1 : Future<MatrixFile> downloadAndDecryptAttachment(
576 : {bool getThumbnail = false,
577 : Future<Uint8List> Function(Uri)? downloadCallback,
578 : bool fromLocalStoreOnly = false}) async {
579 3 : if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
580 0 : throw ("This event has the type '$type' and so it can't contain an attachment.");
581 : }
582 2 : if (status.isSending) {
583 0 : final localFile = room.sendingFilePlaceholders[eventId];
584 : if (localFile != null) return localFile;
585 : }
586 3 : final database = room.client.database;
587 1 : final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
588 : if (mxcUrl == null) {
589 : throw "This event hasn't any attachment or thumbnail.";
590 : }
591 2 : getThumbnail = mxcUrl != attachmentMxcUrl;
592 : final isEncrypted =
593 2 : getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted;
594 3 : if (isEncrypted && !room.client.encryptionEnabled) {
595 : throw ('Encryption is not enabled in your Client.');
596 : }
597 :
598 : // Is this file storeable?
599 2 : final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
600 : var storeable = database != null &&
601 2 : thisInfoMap['size'] is int &&
602 3 : thisInfoMap['size'] <= database.maxFileSize;
603 :
604 : Uint8List? uint8list;
605 : if (storeable) {
606 0 : uint8list = await room.client.database?.getFile(mxcUrl);
607 : }
608 :
609 : // Download the file
610 : final canDownloadFileFromServer = uint8list == null && !fromLocalStoreOnly;
611 : if (canDownloadFileFromServer) {
612 3 : final httpClient = room.client.httpClient;
613 : downloadCallback ??=
614 0 : (Uri url) async => (await httpClient.get(url)).bodyBytes;
615 4 : uint8list = await downloadCallback(mxcUrl.getDownloadLink(room.client));
616 : storeable = database != null &&
617 : storeable &&
618 0 : uint8list.lengthInBytes < database.maxFileSize;
619 : if (storeable) {
620 0 : await database.storeFile(
621 0 : mxcUrl, uint8list, DateTime.now().millisecondsSinceEpoch);
622 : }
623 : } else if (uint8list == null) {
624 : throw ('Unable to download file from local store.');
625 : }
626 :
627 : // Decrypt the file
628 : if (isEncrypted) {
629 : final fileMap =
630 4 : getThumbnail ? infoMap['thumbnail_file'] : content['file'];
631 3 : if (!fileMap['key']['key_ops'].contains('decrypt')) {
632 : throw ("Missing 'decrypt' in 'key_ops'.");
633 : }
634 1 : final encryptedFile = EncryptedFile(
635 : data: uint8list,
636 1 : iv: fileMap['iv'],
637 2 : k: fileMap['key']['k'],
638 2 : sha256: fileMap['hashes']['sha256'],
639 : );
640 : uint8list =
641 4 : await room.client.nativeImplementations.decryptFile(encryptedFile);
642 : if (uint8list == null) {
643 : throw ('Unable to decrypt file');
644 : }
645 : }
646 2 : return MatrixFile(bytes: uint8list, name: body);
647 : }
648 :
649 : /// Returns if this is a known event type.
650 1 : bool get isEventTypeKnown =>
651 3 : EventLocalizations.localizationsMap.containsKey(type);
652 :
653 : /// Returns a localized String representation of this event. For a
654 : /// room list you may find [withSenderNamePrefix] useful. Set [hideReply] to
655 : /// crop all lines starting with '>'. With [plaintextBody] it'll use the
656 : /// plaintextBody instead of the normal body.
657 : /// [removeMarkdown] allow to remove the markdown formating from the event body.
658 : /// Usefull form message preview or notifications text.
659 1 : Future<String> calcLocalizedBody(MatrixLocalizations i18n,
660 : {bool withSenderNamePrefix = false,
661 : bool hideReply = false,
662 : bool hideEdit = false,
663 : bool plaintextBody = false,
664 : bool removeMarkdown = false}) async {
665 1 : if (redacted) {
666 2 : await redactedBecause?.fetchSenderUser();
667 : }
668 :
669 : if (withSenderNamePrefix &&
670 2 : (type == EventTypes.Message || type.contains(EventTypes.Encrypted))) {
671 : // To be sure that if the event need to be localized, the user is in memory.
672 : // used by EventLocalizations._localizedBodyNormalMessage
673 1 : await fetchSenderUser();
674 : }
675 :
676 1 : return calcLocalizedBodyFallback(i18n,
677 : withSenderNamePrefix: withSenderNamePrefix,
678 : hideReply: hideReply,
679 : hideEdit: hideEdit,
680 : plaintextBody: plaintextBody,
681 : removeMarkdown: removeMarkdown);
682 : }
683 :
684 0 : @Deprecated('Use calcLocalizedBody or calcLocalizedBodyFallback')
685 : String getLocalizedBody(MatrixLocalizations i18n,
686 : {bool withSenderNamePrefix = false,
687 : bool hideReply = false,
688 : bool hideEdit = false,
689 : bool plaintextBody = false,
690 : bool removeMarkdown = false}) =>
691 0 : calcLocalizedBodyFallback(i18n,
692 : withSenderNamePrefix: withSenderNamePrefix,
693 : hideReply: hideReply,
694 : hideEdit: hideEdit,
695 : plaintextBody: plaintextBody,
696 : removeMarkdown: removeMarkdown);
697 :
698 : /// Works similar to `calcLocalizedBody()` but does not wait for the sender
699 : /// user to be fetched. If it is not in the cache it will just use the
700 : /// fallback and display the localpart of the MXID according to the
701 : /// values of `formatLocalpart` and `mxidLocalPartFallback` in the `Client`
702 : /// class.
703 1 : String calcLocalizedBodyFallback(MatrixLocalizations i18n,
704 : {bool withSenderNamePrefix = false,
705 : bool hideReply = false,
706 : bool hideEdit = false,
707 : bool plaintextBody = false,
708 : bool removeMarkdown = false}) {
709 1 : if (redacted) {
710 1 : return i18n.removedBy(this);
711 : }
712 :
713 1 : final body = calcUnlocalizedBody(
714 : hideReply: hideReply,
715 : hideEdit: hideEdit,
716 : plaintextBody: plaintextBody,
717 : removeMarkdown: removeMarkdown,
718 : );
719 :
720 3 : final callback = EventLocalizations.localizationsMap[type];
721 2 : var localizedBody = i18n.unknownEvent(type);
722 : if (callback != null) {
723 1 : localizedBody = callback(this, i18n, body);
724 : }
725 :
726 : // Add the sender name prefix
727 : if (withSenderNamePrefix &&
728 2 : type == EventTypes.Message &&
729 2 : textOnlyMessageTypes.contains(messageType)) {
730 5 : final senderNameOrYou = senderId == room.client.userID
731 0 : ? i18n.you
732 2 : : senderFromMemoryOrFallback.calcDisplayname(i18n: i18n);
733 1 : localizedBody = '$senderNameOrYou: $localizedBody';
734 : }
735 :
736 : return localizedBody;
737 : }
738 :
739 : /// Calculating the body of an event regardless of localization.
740 1 : String calcUnlocalizedBody(
741 : {bool hideReply = false,
742 : bool hideEdit = false,
743 : bool plaintextBody = false,
744 : bool removeMarkdown = false}) {
745 1 : if (redacted) {
746 0 : return 'Removed by ${senderFromMemoryOrFallback.displayName ?? senderId}';
747 : }
748 2 : var body = plaintextBody ? this.plaintextBody : this.body;
749 :
750 : // we need to know if the message is an html message to be able to determine
751 : // if we need to strip the reply fallback.
752 3 : var htmlMessage = content['format'] != 'org.matrix.custom.html';
753 : // If we have an edit, we want to operate on the new content
754 2 : final newContent = content.tryGetMap<String, Object?>('m.new_content');
755 : if (hideEdit &&
756 2 : relationshipType == RelationshipTypes.edit &&
757 : newContent != null) {
758 2 : if (plaintextBody && newContent['format'] == 'org.matrix.custom.html') {
759 : htmlMessage = true;
760 1 : body = HtmlToText.convert(
761 1 : newContent.tryGet<String>('formatted_body') ?? formattedText);
762 : } else {
763 : htmlMessage = false;
764 1 : body = newContent.tryGet<String>('body') ?? body;
765 : }
766 : }
767 : // Hide reply fallback
768 : // Be sure that the plaintextBody already stripped teh reply fallback,
769 : // if the message is formatted
770 : if (hideReply && (!plaintextBody || htmlMessage)) {
771 1 : body = body.replaceFirst(
772 1 : RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
773 : }
774 :
775 : // return the html tags free body
776 1 : if (removeMarkdown == true) {
777 1 : final html = markdown(body, convertLinebreaks: false);
778 1 : final document = parse(
779 : html,
780 : );
781 2 : body = document.documentElement?.text ?? body;
782 : }
783 : return body;
784 : }
785 :
786 : static const Set<String> textOnlyMessageTypes = {
787 : MessageTypes.Text,
788 : MessageTypes.Notice,
789 : MessageTypes.Emote,
790 : MessageTypes.None,
791 : };
792 :
793 : /// returns if this event matches the passed event or transaction id
794 3 : bool matchesEventOrTransactionId(String? search) {
795 : if (search == null) {
796 : return false;
797 : }
798 6 : if (eventId == search) {
799 : return true;
800 : }
801 9 : return unsigned?['transaction_id'] == search;
802 : }
803 :
804 : /// Get the relationship type of an event. `null` if there is none
805 31 : String? get relationshipType {
806 62 : final mRelatesTo = content.tryGetMap<String, Object?>('m.relates_to');
807 : if (mRelatesTo == null) {
808 : return null;
809 : }
810 6 : final relType = mRelatesTo.tryGet<String>('rel_type');
811 6 : if (relType == RelationshipTypes.thread) {
812 : return RelationshipTypes.thread;
813 : }
814 :
815 6 : if (mRelatesTo.containsKey('m.in_reply_to')) {
816 : return RelationshipTypes.reply;
817 : }
818 : return relType;
819 : }
820 :
821 : /// Get the event ID that this relationship will reference. `null` if there is none
822 8 : String? get relationshipEventId {
823 16 : final relatesToMap = content.tryGetMap<String, Object?>('m.relates_to');
824 4 : return relatesToMap?.tryGet<String>('event_id') ??
825 : relatesToMap
826 3 : ?.tryGetMap<String, Object?>('m.in_reply_to')
827 3 : ?.tryGet<String>('event_id');
828 : }
829 :
830 : /// Get whether this event has aggregated events from a certain [type]
831 : /// To be able to do that you need to pass a [timeline]
832 1 : bool hasAggregatedEvents(Timeline timeline, String type) =>
833 5 : timeline.aggregatedEvents[eventId]?.containsKey(type) == true;
834 :
835 : /// Get all the aggregated event objects for a given [type]. To be able to do this
836 : /// you have to pass a [timeline]
837 1 : Set<Event> aggregatedEvents(Timeline timeline, String type) =>
838 4 : timeline.aggregatedEvents[eventId]?[type] ?? <Event>{};
839 :
840 : /// Fetches the event to be rendered, taking into account all the edits and the like.
841 : /// It needs a [timeline] for that.
842 1 : Event getDisplayEvent(Timeline timeline) {
843 1 : if (redacted) {
844 : return this;
845 : }
846 1 : if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
847 : // alright, we have an edit
848 1 : final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
849 : // we only allow edits made by the original author themself
850 7 : .where((e) => e.senderId == senderId && e.type == EventTypes.Message)
851 1 : .toList();
852 : // we need to check again if it isn't empty, as we potentially removed all
853 : // aggregated edits
854 1 : if (allEditEvents.isNotEmpty) {
855 5 : allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch -
856 3 : b.originServerTs.millisecondsSinceEpoch >
857 : 0
858 : ? 1
859 1 : : -1);
860 2 : final rawEvent = allEditEvents.last.toJson();
861 : // update the content of the new event to render
862 3 : if (rawEvent['content']['m.new_content'] is Map) {
863 3 : rawEvent['content'] = rawEvent['content']['m.new_content'];
864 : }
865 2 : return Event.fromJson(rawEvent, room);
866 : }
867 : }
868 : return this;
869 : }
870 :
871 : /// returns if a message is a rich message
872 1 : bool get isRichMessage =>
873 3 : content['format'] == 'org.matrix.custom.html' &&
874 3 : content['formatted_body'] is String;
875 :
876 : // regexes to fetch the number of emotes, including emoji, and if the message consists of only those
877 : // to match an emoji we can use the following regex:
878 : // (?:\x{00a9}|\x{00ae}|[\x{2600}-\x{27bf}]|[\x{2b00}-\x{2bff}]|\x{d83c}[\x{d000}-\x{dfff}]|\x{d83d}[\x{d000}-\x{dfff}]|\x{d83e}[\x{d000}-\x{dfff}])[\x{fe00}-\x{fe0f}]?
879 : // we need to replace \x{0000} with \u0000, the comment is left in the other format to be able to paste into regex101.com
880 : // to see if there is a custom emote, we use the following regex: <img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>
881 : // now we combind the two to have four regexes:
882 : // 1. are there only emoji, or whitespace
883 : // 2. are there only emoji, emotes, or whitespace
884 : // 3. count number of emoji
885 : // 4- count number of emoji or emotes
886 3 : static final RegExp _onlyEmojiRegex = RegExp(
887 : r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
888 : caseSensitive: false,
889 : multiLine: false);
890 3 : static final RegExp _onlyEmojiEmoteRegex = RegExp(
891 : r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>|\s)*$',
892 : caseSensitive: false,
893 : multiLine: false);
894 3 : static final RegExp _countEmojiRegex = RegExp(
895 : r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?)',
896 : caseSensitive: false,
897 : multiLine: false);
898 3 : static final RegExp _countEmojiEmoteRegex = RegExp(
899 : r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>)',
900 : caseSensitive: false,
901 : multiLine: false);
902 :
903 : /// Returns if a given event only has emotes, emojis or whitespace as content.
904 : /// If the body contains a reply then it is stripped.
905 : /// This is useful to determine if stand-alone emotes should be displayed bigger.
906 1 : bool get onlyEmotes {
907 1 : if (isRichMessage) {
908 2 : final formattedTextStripped = formattedText.replaceAll(
909 1 : RegExp('<mx-reply>.*</mx-reply>',
910 : caseSensitive: false, multiLine: false, dotAll: true),
911 : '');
912 2 : return _onlyEmojiEmoteRegex.hasMatch(formattedTextStripped);
913 : } else {
914 3 : return _onlyEmojiRegex.hasMatch(plaintextBody);
915 : }
916 : }
917 :
918 : /// Gets the number of emotes in a given message. This is useful to determine
919 : /// if the emotes should be displayed bigger.
920 : /// If the body contains a reply then it is stripped.
921 : /// WARNING: This does **not** test if there are only emotes. Use `event.onlyEmotes` for that!
922 1 : int get numberEmotes {
923 1 : if (isRichMessage) {
924 2 : final formattedTextStripped = formattedText.replaceAll(
925 1 : RegExp('<mx-reply>.*</mx-reply>',
926 : caseSensitive: false, multiLine: false, dotAll: true),
927 : '');
928 3 : return _countEmojiEmoteRegex.allMatches(formattedTextStripped).length;
929 : } else {
930 4 : return _countEmojiRegex.allMatches(plaintextBody).length;
931 : }
932 : }
933 :
934 : /// If this event is in Status SENDING and it aims to send a file, then this
935 : /// shows the status of the file sending.
936 0 : FileSendingStatus? get fileSendingStatus {
937 0 : final status = unsigned?.tryGet<String>(fileSendingStatusKey);
938 : if (status == null) return null;
939 0 : return FileSendingStatus.values.singleWhereOrNull(
940 0 : (fileSendingStatus) => fileSendingStatus.name == status);
941 : }
942 : }
943 :
944 : enum FileSendingStatus {
945 : generatingThumbnail,
946 : encrypting,
947 : uploading,
948 : }
|