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 12 : Future<User?> fetchSenderUser() => room.requestUser(
42 4 : 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 4 : User get senderFromMemoryOrFallback =>
51 12 : 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 12 : Event? get redactedBecause {
63 22 : final redacted_because = unsigned?['redacted_because'];
64 12 : final room = this.room;
65 12 : return (redacted_because is Map<String, dynamic>)
66 5 : ? Event.fromJson(redacted_because, room)
67 : : null;
68 : }
69 :
70 24 : bool get redacted => redactedBecause != null;
71 :
72 4 : User? get stateKeyUser => stateKey != null
73 6 : ? room.unsafeGetUserFromMemoryOrFallback(stateKey!)
74 : : null;
75 :
76 : MatrixEvent? _originalSource;
77 :
78 62 : MatrixEvent? get originalSource => _originalSource;
79 :
80 36 : 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 36 : super(
94 : eventId: eventId,
95 : originServerTs: originServerTs,
96 36 : roomId: room.id,
97 : ) {
98 36 : this.eventId = eventId;
99 36 : 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 72 : this.prevContent = (prevContent != null && prevContent.isNotEmpty)
106 : ? prevContent
107 : : (unsigned != null &&
108 36 : unsigned.containsKey('prev_content') &&
109 6 : unsigned['prev_content'] is Map)
110 3 : ? unsigned['prev_content']
111 : : null;
112 : } catch (_) {
113 : // A strange bug in dart web makes this crash
114 : }
115 36 : 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 105 : 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 36 : static Map<String, dynamic> getMapFromPayload(Object? payload) {
151 36 : if (payload is String) {
152 : try {
153 9 : return json.decode(payload);
154 : } catch (e) {
155 0 : return {};
156 : }
157 : }
158 36 : if (payload is Map<String, dynamic>) return payload;
159 36 : 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 36 : factory Event.fromJson(
182 : Map<String, dynamic> jsonPayload,
183 : Room room,
184 : ) {
185 72 : final content = Event.getMapFromPayload(jsonPayload['content']);
186 72 : final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
187 72 : final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
188 : final originalSource =
189 72 : Event.getMapFromPayload(jsonPayload['original_source']);
190 36 : return Event(
191 72 : status: eventStatusFromInt(jsonPayload['status'] ??
192 34 : unsigned[messageSendingStatusKey] ??
193 34 : defaultStatus.intValue),
194 36 : stateKey: jsonPayload['state_key'],
195 : prevContent: prevContent,
196 : content: content,
197 36 : type: jsonPayload['type'],
198 36 : eventId: jsonPayload['event_id'] ?? '',
199 36 : senderId: jsonPayload['sender'],
200 36 : originServerTs: DateTime.fromMillisecondsSinceEpoch(
201 36 : jsonPayload['origin_server_ts'] ?? 0),
202 : unsigned: unsigned,
203 : room: room,
204 36 : originalSource: originalSource.isEmpty
205 : ? null
206 1 : : MatrixEvent.fromJson(originalSource));
207 : }
208 :
209 31 : @override
210 : Map<String, dynamic> toJson() {
211 31 : final data = <String, dynamic>{};
212 43 : if (stateKey != null) data['state_key'] = stateKey;
213 62 : if (prevContent?.isNotEmpty == true) {
214 0 : data['prev_content'] = prevContent;
215 : }
216 62 : data['content'] = content;
217 62 : data['type'] = type;
218 62 : data['event_id'] = eventId;
219 62 : data['room_id'] = roomId;
220 62 : data['sender'] = senderId;
221 93 : data['origin_server_ts'] = originServerTs.millisecondsSinceEpoch;
222 93 : if (unsigned?.isNotEmpty == true) {
223 24 : data['unsigned'] = unsigned;
224 : }
225 31 : if (originalSource != null) {
226 3 : data['original_source'] = originalSource?.toJson();
227 : }
228 93 : data['status'] = status.intValue;
229 : return data;
230 : }
231 :
232 66 : User get asUser => User.fromState(
233 : // state key should always be set for member events
234 33 : stateKey: stateKey!,
235 33 : prevContent: prevContent,
236 33 : content: content,
237 33 : typeKey: type,
238 33 : senderId: senderId,
239 33 : room: room,
240 : );
241 :
242 18 : String get messageType => type == EventTypes.Sticker
243 : ? MessageTypes.Sticker
244 12 : : (content.tryGet<String>('msgtype') ?? MessageTypes.Text);
245 :
246 5 : void setRedactionEvent(Event redactedBecause) {
247 10 : unsigned = {
248 5 : 'redacted_because': redactedBecause.toJson(),
249 : };
250 5 : prevContent = null;
251 5 : _originalSource = null;
252 5 : final contentKeyWhiteList = <String>[];
253 5 : switch (type) {
254 5 : case EventTypes.RoomMember:
255 2 : contentKeyWhiteList.add('membership');
256 : break;
257 5 : case EventTypes.RoomCreate:
258 2 : contentKeyWhiteList.add('creator');
259 : break;
260 5 : case EventTypes.RoomJoinRules:
261 2 : contentKeyWhiteList.add('join_rule');
262 : break;
263 5 : case EventTypes.RoomPowerLevels:
264 2 : contentKeyWhiteList.add('ban');
265 2 : contentKeyWhiteList.add('events');
266 2 : contentKeyWhiteList.add('events_default');
267 2 : contentKeyWhiteList.add('kick');
268 2 : contentKeyWhiteList.add('redact');
269 2 : contentKeyWhiteList.add('state_default');
270 2 : contentKeyWhiteList.add('users');
271 2 : contentKeyWhiteList.add('users_default');
272 : break;
273 5 : case EventTypes.RoomAliases:
274 2 : contentKeyWhiteList.add('aliases');
275 : break;
276 5 : case EventTypes.HistoryVisibility:
277 2 : contentKeyWhiteList.add('history_visibility');
278 : break;
279 : default:
280 : break;
281 : }
282 20 : content.removeWhere((k, v) => !contentKeyWhiteList.contains(k));
283 : }
284 :
285 : /// Returns the body of this event if it has a body.
286 30 : String get text => content.tryGet<String>('body') ?? '';
287 :
288 : /// Returns the formatted boy of this event if it has a formatted body.
289 15 : String get formattedText => content.tryGet<String>('formatted_body') ?? '';
290 :
291 : /// Use this to get the body.
292 10 : String get body {
293 10 : if (redacted) return 'Redacted';
294 30 : if (text != '') return text;
295 2 : 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 => switch (formattedText) {
301 : // if the formattedText is empty, fallback to body
302 4 : '' => body,
303 8 : final String s when content['format'] == 'org.matrix.custom.html' =>
304 2 : HtmlToText.convert(s),
305 2 : _ => body,
306 : };
307 :
308 : /// Returns a list of [Receipt] instances for this event.
309 3 : List<Receipt> get receipts {
310 3 : final room = this.room;
311 3 : final receipts = room.receiptState;
312 9 : final receiptsList = receipts.global.otherUsers.entries
313 8 : .where((entry) => entry.value.eventId == eventId)
314 5 : .map((entry) => Receipt(
315 2 : room.unsafeGetUserFromMemoryOrFallback(entry.key),
316 2 : entry.value.timestamp))
317 3 : .toList();
318 :
319 : // add your own only once
320 6 : final own = receipts.global.latestOwnReceipt ??
321 3 : receipts.mainThread?.latestOwnReceipt;
322 3 : if (own != null && own.eventId == eventId) {
323 1 : receiptsList.add(
324 4 : Receipt(room.unsafeGetUserFromMemoryOrFallback(room.client.userID!),
325 1 : own.timestamp),
326 : );
327 : }
328 :
329 : // also add main thread. https://github.com/famedly/product-management/issues/1020
330 : // also deduplicate.
331 8 : receiptsList.addAll(receipts.mainThread?.otherUsers.entries
332 2 : .where((entry) =>
333 4 : entry.value.eventId == eventId &&
334 6 : receiptsList.every((element) => element.user.id != entry.key))
335 3 : .map((entry) => Receipt(
336 2 : room.unsafeGetUserFromMemoryOrFallback(entry.key),
337 2 : entry.value.timestamp)) ??
338 3 : []);
339 :
340 : return receiptsList;
341 : }
342 :
343 0 : @Deprecated('Use [cancelSend()] instead.')
344 : Future<bool> remove() async {
345 : try {
346 0 : await cancelSend();
347 : return true;
348 : } catch (_) {
349 : return false;
350 : }
351 : }
352 :
353 : /// Removes an unsent or yet-to-send event from the database and timeline.
354 : /// These are events marked with the status `SENDING` or `ERROR`.
355 : /// Throws an exception if used for an already sent event!
356 : ///
357 6 : Future<void> cancelSend() async {
358 12 : if (status.isSent) {
359 2 : throw Exception('Can only delete events which are not sent yet!');
360 : }
361 :
362 34 : await room.client.database?.removeEvent(eventId, room.id);
363 :
364 22 : if (room.lastEvent != null && room.lastEvent!.eventId == eventId) {
365 2 : final redactedBecause = Event.fromMatrixEvent(
366 2 : MatrixEvent(
367 : type: EventTypes.Redaction,
368 4 : content: {'redacts': eventId},
369 2 : redacts: eventId,
370 2 : senderId: senderId,
371 4 : eventId: '${eventId}_cancel_send',
372 2 : originServerTs: DateTime.now(),
373 : ),
374 2 : room,
375 : );
376 :
377 6 : await room.client.handleSync(
378 2 : SyncUpdate(
379 : nextBatch: '',
380 2 : rooms: RoomsUpdate(
381 2 : join: {
382 6 : room.id: JoinedRoomUpdate(
383 2 : timeline: TimelineUpdate(
384 2 : events: [redactedBecause],
385 : ),
386 : )
387 : },
388 : ),
389 : ),
390 : );
391 : }
392 30 : room.client.onCancelSendEvent.add(eventId);
393 : }
394 :
395 : /// Try to send this event again. Only works with events of status -1.
396 4 : Future<String?> sendAgain({String? txid}) async {
397 8 : if (!status.isError) return null;
398 :
399 : // Retry sending a file:
400 : if ({
401 4 : MessageTypes.Image,
402 4 : MessageTypes.Video,
403 4 : MessageTypes.Audio,
404 4 : MessageTypes.File,
405 8 : }.contains(messageType)) {
406 0 : final file = room.sendingFilePlaceholders[eventId];
407 : if (file == null) {
408 0 : await cancelSend();
409 0 : throw Exception('Can not try to send again. File is no longer cached.');
410 : }
411 0 : final thumbnail = room.sendingFileThumbnails[eventId];
412 0 : final credentials = FileSendRequestCredentials.fromJson(unsigned ?? {});
413 0 : final inReplyTo = credentials.inReplyTo == null
414 : ? null
415 0 : : await room.getEventById(credentials.inReplyTo!);
416 0 : txid ??= unsigned?.tryGet<String>('transaction_id');
417 0 : return await room.sendFileEvent(
418 : file,
419 : txid: txid,
420 : thumbnail: thumbnail,
421 : inReplyTo: inReplyTo,
422 0 : editEventId: credentials.editEventId,
423 0 : shrinkImageMaxDimension: credentials.shrinkImageMaxDimension,
424 0 : extraContent: credentials.extraContent,
425 : );
426 : }
427 :
428 : // we do not remove the event here. It will automatically be updated
429 : // in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
430 8 : return await room.sendEvent(
431 4 : content,
432 4 : txid: txid ?? unsigned?.tryGet<String>('transaction_id') ?? eventId,
433 : );
434 : }
435 :
436 : /// Whether the client is allowed to redact this event.
437 12 : bool get canRedact => senderId == room.client.userID || room.canRedact;
438 :
439 : /// Redacts this event. Throws `ErrorResponse` on error.
440 1 : Future<String?> redactEvent({String? reason, String? txid}) async =>
441 3 : await room.redactEvent(eventId, reason: reason, txid: txid);
442 :
443 : /// Searches for the reply event in the given timeline.
444 0 : Future<Event?> getReplyEvent(Timeline timeline) async {
445 0 : if (relationshipType != RelationshipTypes.reply) return null;
446 0 : final relationshipEventId = this.relationshipEventId;
447 : return relationshipEventId == null
448 : ? null
449 0 : : await timeline.getEventById(relationshipEventId);
450 : }
451 :
452 : /// If this event is encrypted and the decryption was not successful because
453 : /// the session is unknown, this requests the session key from other devices
454 : /// in the room. If the event is not encrypted or the decryption failed because
455 : /// of a different error, this throws an exception.
456 1 : Future<void> requestKey() async {
457 2 : if (type != EventTypes.Encrypted ||
458 2 : messageType != MessageTypes.BadEncrypted ||
459 3 : content['can_request_session'] != true) {
460 : throw ('Session key not requestable');
461 : }
462 :
463 2 : final sessionId = content.tryGet<String>('session_id');
464 2 : final senderKey = content.tryGet<String>('sender_key');
465 : if (sessionId == null || senderKey == null) {
466 : throw ('Unknown session_id or sender_key');
467 : }
468 2 : await room.requestSessionKey(sessionId, senderKey);
469 : return;
470 : }
471 :
472 : /// Gets the info map of file events, or a blank map if none present
473 2 : Map get infoMap =>
474 6 : content.tryGetMap<String, Object?>('info') ?? <String, Object?>{};
475 :
476 : /// Gets the thumbnail info map of file events, or a blank map if nonepresent
477 8 : Map get thumbnailInfoMap => infoMap['thumbnail_info'] is Map
478 4 : ? infoMap['thumbnail_info']
479 1 : : <String, dynamic>{};
480 :
481 : /// Returns if a file event has an attachment
482 11 : bool get hasAttachment => content['url'] is String || content['file'] is Map;
483 :
484 : /// Returns if a file event has a thumbnail
485 2 : bool get hasThumbnail =>
486 12 : infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map;
487 :
488 : /// Returns if a file events attachment is encrypted
489 8 : bool get isAttachmentEncrypted => content['file'] is Map;
490 :
491 : /// Returns if a file events thumbnail is encrypted
492 8 : bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map;
493 :
494 : /// Gets the mimetype of the attachment of a file event, or a blank string if not present
495 8 : String get attachmentMimetype => infoMap['mimetype'] is String
496 6 : ? infoMap['mimetype'].toLowerCase()
497 1 : : (content
498 1 : .tryGetMap<String, Object?>('file')
499 1 : ?.tryGet<String>('mimetype') ??
500 : '');
501 :
502 : /// Gets the mimetype of the thumbnail of a file event, or a blank string if not present
503 8 : String get thumbnailMimetype => thumbnailInfoMap['mimetype'] is String
504 6 : ? thumbnailInfoMap['mimetype'].toLowerCase()
505 3 : : (infoMap['thumbnail_file'] is Map &&
506 4 : infoMap['thumbnail_file']['mimetype'] is String
507 3 : ? infoMap['thumbnail_file']['mimetype']
508 : : '');
509 :
510 : /// Gets the underlying mxc url of an attachment of a file event, or null if not present
511 2 : Uri? get attachmentMxcUrl {
512 2 : final url = isAttachmentEncrypted
513 3 : ? (content.tryGetMap<String, Object?>('file')?['url'])
514 4 : : content['url'];
515 4 : return url is String ? Uri.tryParse(url) : null;
516 : }
517 :
518 : /// Gets the underlying mxc url of a thumbnail of a file event, or null if not present
519 2 : Uri? get thumbnailMxcUrl {
520 2 : final url = isThumbnailEncrypted
521 3 : ? infoMap['thumbnail_file']['url']
522 4 : : infoMap['thumbnail_url'];
523 4 : return url is String ? Uri.tryParse(url) : null;
524 : }
525 :
526 : /// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present
527 2 : Uri? attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) {
528 : if (getThumbnail &&
529 6 : infoMap['size'] is int &&
530 6 : thumbnailInfoMap['size'] is int &&
531 0 : infoMap['size'] <= thumbnailInfoMap['size']) {
532 : getThumbnail = false;
533 : }
534 2 : if (getThumbnail && !hasThumbnail) {
535 : getThumbnail = false;
536 : }
537 4 : return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
538 : }
539 :
540 : // size determined from an approximate 800x800 jpeg thumbnail with method=scale
541 : static const _minNoThumbSize = 80 * 1024;
542 :
543 : /// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
544 : /// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
545 : /// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
546 : /// for the respective thumbnailing properties.
547 : /// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
548 : /// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
549 : /// [animated] says weather the thumbnail is animated
550 : ///
551 : /// Throws an exception if the scheme is not `mxc` or the homeserver is not
552 : /// set.
553 : ///
554 : /// Important! To use this link you have to set a http header like this:
555 : /// `headers: {"authorization": "Bearer ${client.accessToken}"}`
556 2 : Future<Uri?> getAttachmentUri(
557 : {bool getThumbnail = false,
558 : bool useThumbnailMxcUrl = false,
559 : double width = 800.0,
560 : double height = 800.0,
561 : ThumbnailMethod method = ThumbnailMethod.scale,
562 : int minNoThumbSize = _minNoThumbSize,
563 : bool animated = false}) async {
564 6 : if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
565 2 : !hasAttachment ||
566 2 : isAttachmentEncrypted) {
567 : return null; // can't url-thumbnail in encrypted rooms
568 : }
569 2 : if (useThumbnailMxcUrl && !hasThumbnail) {
570 : return null; // can't fetch from thumbnail
571 : }
572 4 : final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
573 : final thisMxcUrl =
574 8 : useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
575 : // if we have as method scale, we can return safely the original image, should it be small enough
576 : if (getThumbnail &&
577 2 : method == ThumbnailMethod.scale &&
578 4 : thisInfoMap['size'] is int &&
579 4 : thisInfoMap['size'] < minNoThumbSize) {
580 : getThumbnail = false;
581 : }
582 : // now generate the actual URLs
583 : if (getThumbnail) {
584 4 : return await Uri.parse(thisMxcUrl).getThumbnailUri(
585 4 : room.client,
586 : width: width,
587 : height: height,
588 : method: method,
589 : animated: animated,
590 : );
591 : } else {
592 8 : return await Uri.parse(thisMxcUrl).getDownloadUri(room.client);
593 : }
594 : }
595 :
596 : /// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
597 : /// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
598 : /// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
599 : /// for the respective thumbnailing properties.
600 : /// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
601 : /// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
602 : /// [animated] says weather the thumbnail is animated
603 : ///
604 : /// Throws an exception if the scheme is not `mxc` or the homeserver is not
605 : /// set.
606 : ///
607 : /// Important! To use this link you have to set a http header like this:
608 : /// `headers: {"authorization": "Bearer ${client.accessToken}"}`
609 0 : @Deprecated('Use getAttachmentUri() instead')
610 : Uri? getAttachmentUrl(
611 : {bool getThumbnail = false,
612 : bool useThumbnailMxcUrl = false,
613 : double width = 800.0,
614 : double height = 800.0,
615 : ThumbnailMethod method = ThumbnailMethod.scale,
616 : int minNoThumbSize = _minNoThumbSize,
617 : bool animated = false}) {
618 0 : if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
619 0 : !hasAttachment ||
620 0 : isAttachmentEncrypted) {
621 : return null; // can't url-thumbnail in encrypted rooms
622 : }
623 0 : if (useThumbnailMxcUrl && !hasThumbnail) {
624 : return null; // can't fetch from thumbnail
625 : }
626 0 : final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
627 : final thisMxcUrl =
628 0 : useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
629 : // if we have as method scale, we can return safely the original image, should it be small enough
630 : if (getThumbnail &&
631 0 : method == ThumbnailMethod.scale &&
632 0 : thisInfoMap['size'] is int &&
633 0 : thisInfoMap['size'] < minNoThumbSize) {
634 : getThumbnail = false;
635 : }
636 : // now generate the actual URLs
637 : if (getThumbnail) {
638 0 : return Uri.parse(thisMxcUrl).getThumbnail(
639 0 : room.client,
640 : width: width,
641 : height: height,
642 : method: method,
643 : animated: animated,
644 : );
645 : } else {
646 0 : return Uri.parse(thisMxcUrl).getDownloadLink(room.client);
647 : }
648 : }
649 :
650 : /// Returns if an attachment is in the local store
651 1 : Future<bool> isAttachmentInLocalStore({bool getThumbnail = false}) async {
652 3 : if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
653 0 : throw ("This event has the type '$type' and so it can't contain an attachment.");
654 : }
655 1 : final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
656 : if (mxcUrl == null) {
657 : throw "This event hasn't any attachment or thumbnail.";
658 : }
659 2 : getThumbnail = mxcUrl != attachmentMxcUrl;
660 : // Is this file storeable?
661 1 : final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
662 3 : final database = room.client.database;
663 : if (database == null) {
664 : return false;
665 : }
666 :
667 2 : final storeable = thisInfoMap['size'] is int &&
668 3 : thisInfoMap['size'] <= database.maxFileSize;
669 :
670 : Uint8List? uint8list;
671 : if (storeable) {
672 0 : uint8list = await database.getFile(mxcUrl);
673 : }
674 : return uint8list != null;
675 : }
676 :
677 : /// Downloads (and decrypts if necessary) the attachment of this
678 : /// event and returns it as a [MatrixFile]. If this event doesn't
679 : /// contain an attachment, this throws an error. Set [getThumbnail] to
680 : /// true to download the thumbnail instead. Set [fromLocalStoreOnly] to true
681 : /// if you want to retrieve the attachment from the local store only without
682 : /// making http request.
683 2 : Future<MatrixFile> downloadAndDecryptAttachment(
684 : {bool getThumbnail = false,
685 : Future<Uint8List> Function(Uri)? downloadCallback,
686 : bool fromLocalStoreOnly = false}) async {
687 6 : if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
688 0 : throw ("This event has the type '$type' and so it can't contain an attachment.");
689 : }
690 4 : if (status.isSending) {
691 0 : final localFile = room.sendingFilePlaceholders[eventId];
692 : if (localFile != null) return localFile;
693 : }
694 6 : final database = room.client.database;
695 2 : final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
696 : if (mxcUrl == null) {
697 : throw "This event hasn't any attachment or thumbnail.";
698 : }
699 4 : getThumbnail = mxcUrl != attachmentMxcUrl;
700 : final isEncrypted =
701 4 : getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted;
702 3 : if (isEncrypted && !room.client.encryptionEnabled) {
703 : throw ('Encryption is not enabled in your Client.');
704 : }
705 :
706 : // Is this file storeable?
707 4 : final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
708 : var storeable = database != null &&
709 2 : thisInfoMap['size'] is int &&
710 3 : thisInfoMap['size'] <= database.maxFileSize;
711 :
712 : Uint8List? uint8list;
713 : if (storeable) {
714 0 : uint8list = await room.client.database?.getFile(mxcUrl);
715 : }
716 :
717 : // Download the file
718 : final canDownloadFileFromServer = uint8list == null && !fromLocalStoreOnly;
719 : if (canDownloadFileFromServer) {
720 6 : final httpClient = room.client.httpClient;
721 0 : downloadCallback ??= (Uri url) async => (await httpClient.get(
722 : url,
723 0 : headers: {'authorization': 'Bearer ${room.client.accessToken}'},
724 : ))
725 0 : .bodyBytes;
726 : uint8list =
727 8 : await downloadCallback(await mxcUrl.getDownloadUri(room.client));
728 : storeable = database != null &&
729 : storeable &&
730 0 : uint8list.lengthInBytes < database.maxFileSize;
731 : if (storeable) {
732 0 : await database.storeFile(
733 0 : mxcUrl, uint8list, DateTime.now().millisecondsSinceEpoch);
734 : }
735 : } else if (uint8list == null) {
736 : throw ('Unable to download file from local store.');
737 : }
738 :
739 : // Decrypt the file
740 : if (isEncrypted) {
741 : final fileMap =
742 4 : getThumbnail ? infoMap['thumbnail_file'] : content['file'];
743 3 : if (!fileMap['key']['key_ops'].contains('decrypt')) {
744 : throw ("Missing 'decrypt' in 'key_ops'.");
745 : }
746 1 : final encryptedFile = EncryptedFile(
747 : data: uint8list,
748 1 : iv: fileMap['iv'],
749 2 : k: fileMap['key']['k'],
750 2 : sha256: fileMap['hashes']['sha256'],
751 : );
752 : uint8list =
753 4 : await room.client.nativeImplementations.decryptFile(encryptedFile);
754 : if (uint8list == null) {
755 : throw ('Unable to decrypt file');
756 : }
757 : }
758 4 : return MatrixFile(bytes: uint8list, name: body);
759 : }
760 :
761 : /// Returns if this is a known event type.
762 2 : bool get isEventTypeKnown =>
763 6 : EventLocalizations.localizationsMap.containsKey(type);
764 :
765 : /// Returns a localized String representation of this event. For a
766 : /// room list you may find [withSenderNamePrefix] useful. Set [hideReply] to
767 : /// crop all lines starting with '>'. With [plaintextBody] it'll use the
768 : /// plaintextBody instead of the normal body which in practice will convert
769 : /// the html body to a plain text body before falling back to the body. In
770 : /// either case this function won't return the html body without converting
771 : /// it to plain text.
772 : /// [removeMarkdown] allow to remove the markdown formating from the event body.
773 : /// Usefull form message preview or notifications text.
774 4 : Future<String> calcLocalizedBody(MatrixLocalizations i18n,
775 : {bool withSenderNamePrefix = false,
776 : bool hideReply = false,
777 : bool hideEdit = false,
778 : bool plaintextBody = false,
779 : bool removeMarkdown = false}) async {
780 4 : if (redacted) {
781 8 : await redactedBecause?.fetchSenderUser();
782 : }
783 :
784 : if (withSenderNamePrefix &&
785 4 : (type == EventTypes.Message || type.contains(EventTypes.Encrypted))) {
786 : // To be sure that if the event need to be localized, the user is in memory.
787 : // used by EventLocalizations._localizedBodyNormalMessage
788 2 : await fetchSenderUser();
789 : }
790 :
791 4 : return calcLocalizedBodyFallback(
792 : i18n,
793 : withSenderNamePrefix: withSenderNamePrefix,
794 : hideReply: hideReply,
795 : hideEdit: hideEdit,
796 : plaintextBody: plaintextBody,
797 : removeMarkdown: removeMarkdown,
798 : );
799 : }
800 :
801 0 : @Deprecated('Use calcLocalizedBody or calcLocalizedBodyFallback')
802 : String getLocalizedBody(MatrixLocalizations i18n,
803 : {bool withSenderNamePrefix = false,
804 : bool hideReply = false,
805 : bool hideEdit = false,
806 : bool plaintextBody = false,
807 : bool removeMarkdown = false}) =>
808 0 : calcLocalizedBodyFallback(i18n,
809 : withSenderNamePrefix: withSenderNamePrefix,
810 : hideReply: hideReply,
811 : hideEdit: hideEdit,
812 : plaintextBody: plaintextBody,
813 : removeMarkdown: removeMarkdown);
814 :
815 : /// Works similar to `calcLocalizedBody()` but does not wait for the sender
816 : /// user to be fetched. If it is not in the cache it will just use the
817 : /// fallback and display the localpart of the MXID according to the
818 : /// values of `formatLocalpart` and `mxidLocalPartFallback` in the `Client`
819 : /// class.
820 4 : String calcLocalizedBodyFallback(MatrixLocalizations i18n,
821 : {bool withSenderNamePrefix = false,
822 : bool hideReply = false,
823 : bool hideEdit = false,
824 : bool plaintextBody = false,
825 : bool removeMarkdown = false}) {
826 4 : if (redacted) {
827 16 : if (status.intValue < EventStatus.synced.intValue) {
828 2 : return i18n.cancelledSend;
829 : }
830 2 : return i18n.removedBy(this);
831 : }
832 :
833 2 : final body = calcUnlocalizedBody(
834 : hideReply: hideReply,
835 : hideEdit: hideEdit,
836 : plaintextBody: plaintextBody,
837 : removeMarkdown: removeMarkdown,
838 : );
839 :
840 6 : final callback = EventLocalizations.localizationsMap[type];
841 4 : var localizedBody = i18n.unknownEvent(type);
842 : if (callback != null) {
843 2 : localizedBody = callback(this, i18n, body);
844 : }
845 :
846 : // Add the sender name prefix
847 : if (withSenderNamePrefix &&
848 4 : type == EventTypes.Message &&
849 4 : textOnlyMessageTypes.contains(messageType)) {
850 10 : final senderNameOrYou = senderId == room.client.userID
851 0 : ? i18n.you
852 4 : : senderFromMemoryOrFallback.calcDisplayname(i18n: i18n);
853 2 : localizedBody = '$senderNameOrYou: $localizedBody';
854 : }
855 :
856 : return localizedBody;
857 : }
858 :
859 : /// Calculating the body of an event regardless of localization.
860 2 : String calcUnlocalizedBody({
861 : bool hideReply = false,
862 : bool hideEdit = false,
863 : bool plaintextBody = false,
864 : bool removeMarkdown = false,
865 : }) {
866 2 : if (redacted) {
867 0 : return 'Removed by ${senderFromMemoryOrFallback.displayName ?? senderId}';
868 : }
869 4 : var body = plaintextBody ? this.plaintextBody : this.body;
870 :
871 : // Html messages will already have their reply fallback removed during the Html to Text conversion.
872 : var mayHaveReplyFallback = !plaintextBody ||
873 6 : (content['format'] != 'org.matrix.custom.html' ||
874 4 : formattedText.isEmpty);
875 :
876 : // If we have an edit, we want to operate on the new content
877 4 : final newContent = content.tryGetMap<String, Object?>('m.new_content');
878 : if (hideEdit &&
879 4 : relationshipType == RelationshipTypes.edit &&
880 : newContent != null) {
881 : final newBody =
882 2 : newContent.tryGet<String>('formatted_body', TryGet.silent);
883 : if (plaintextBody &&
884 4 : newContent['format'] == 'org.matrix.custom.html' &&
885 : newBody != null &&
886 2 : newBody.isNotEmpty) {
887 : mayHaveReplyFallback = false;
888 2 : body = HtmlToText.convert(newBody);
889 : } else {
890 : mayHaveReplyFallback = true;
891 2 : body = newContent.tryGet<String>('body') ?? body;
892 : }
893 : }
894 : // Hide reply fallback
895 : // Be sure that the plaintextBody already stripped teh reply fallback,
896 : // if the message is formatted
897 : if (hideReply && mayHaveReplyFallback) {
898 2 : body = body.replaceFirst(
899 2 : RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
900 : }
901 :
902 : // return the html tags free body
903 2 : if (removeMarkdown == true) {
904 2 : final html = markdown(body, convertLinebreaks: false);
905 2 : final document = parse(
906 : html,
907 : );
908 4 : body = document.documentElement?.text ?? body;
909 : }
910 : return body;
911 : }
912 :
913 : static const Set<String> textOnlyMessageTypes = {
914 : MessageTypes.Text,
915 : MessageTypes.Notice,
916 : MessageTypes.Emote,
917 : MessageTypes.None,
918 : };
919 :
920 : /// returns if this event matches the passed event or transaction id
921 4 : bool matchesEventOrTransactionId(String? search) {
922 : if (search == null) {
923 : return false;
924 : }
925 8 : if (eventId == search) {
926 : return true;
927 : }
928 12 : return unsigned?['transaction_id'] == search;
929 : }
930 :
931 : /// Get the relationship type of an event. `null` if there is none
932 33 : String? get relationshipType {
933 66 : final mRelatesTo = content.tryGetMap<String, Object?>('m.relates_to');
934 : if (mRelatesTo == null) {
935 : return null;
936 : }
937 7 : final relType = mRelatesTo.tryGet<String>('rel_type');
938 7 : if (relType == RelationshipTypes.thread) {
939 : return RelationshipTypes.thread;
940 : }
941 :
942 7 : if (mRelatesTo.containsKey('m.in_reply_to')) {
943 : return RelationshipTypes.reply;
944 : }
945 : return relType;
946 : }
947 :
948 : /// Get the event ID that this relationship will reference. `null` if there is none
949 9 : String? get relationshipEventId {
950 18 : final relatesToMap = content.tryGetMap<String, Object?>('m.relates_to');
951 5 : return relatesToMap?.tryGet<String>('event_id') ??
952 : relatesToMap
953 4 : ?.tryGetMap<String, Object?>('m.in_reply_to')
954 4 : ?.tryGet<String>('event_id');
955 : }
956 :
957 : /// Get whether this event has aggregated events from a certain [type]
958 : /// To be able to do that you need to pass a [timeline]
959 2 : bool hasAggregatedEvents(Timeline timeline, String type) =>
960 10 : timeline.aggregatedEvents[eventId]?.containsKey(type) == true;
961 :
962 : /// Get all the aggregated event objects for a given [type]. To be able to do this
963 : /// you have to pass a [timeline]
964 2 : Set<Event> aggregatedEvents(Timeline timeline, String type) =>
965 8 : timeline.aggregatedEvents[eventId]?[type] ?? <Event>{};
966 :
967 : /// Fetches the event to be rendered, taking into account all the edits and the like.
968 : /// It needs a [timeline] for that.
969 2 : Event getDisplayEvent(Timeline timeline) {
970 2 : if (redacted) {
971 : return this;
972 : }
973 2 : if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
974 : // alright, we have an edit
975 2 : final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
976 : // we only allow edits made by the original author themself
977 14 : .where((e) => e.senderId == senderId && e.type == EventTypes.Message)
978 2 : .toList();
979 : // we need to check again if it isn't empty, as we potentially removed all
980 : // aggregated edits
981 2 : if (allEditEvents.isNotEmpty) {
982 10 : allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch -
983 6 : b.originServerTs.millisecondsSinceEpoch >
984 : 0
985 : ? 1
986 2 : : -1);
987 4 : final rawEvent = allEditEvents.last.toJson();
988 : // update the content of the new event to render
989 6 : if (rawEvent['content']['m.new_content'] is Map) {
990 6 : rawEvent['content'] = rawEvent['content']['m.new_content'];
991 : }
992 4 : return Event.fromJson(rawEvent, room);
993 : }
994 : }
995 : return this;
996 : }
997 :
998 : /// returns if a message is a rich message
999 2 : bool get isRichMessage =>
1000 6 : content['format'] == 'org.matrix.custom.html' &&
1001 6 : content['formatted_body'] is String;
1002 :
1003 : // regexes to fetch the number of emotes, including emoji, and if the message consists of only those
1004 : // to match an emoji we can use the following regex:
1005 : // (?:\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}]?
1006 : // we need to replace \x{0000} with \u0000, the comment is left in the other format to be able to paste into regex101.com
1007 : // to see if there is a custom emote, we use the following regex: <img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>
1008 : // now we combind the two to have four regexes:
1009 : // 1. are there only emoji, or whitespace
1010 : // 2. are there only emoji, emotes, or whitespace
1011 : // 3. count number of emoji
1012 : // 4- count number of emoji or emotes
1013 6 : static final RegExp _onlyEmojiRegex = RegExp(
1014 : r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
1015 : caseSensitive: false,
1016 : multiLine: false);
1017 6 : static final RegExp _onlyEmojiEmoteRegex = RegExp(
1018 : 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)*$',
1019 : caseSensitive: false,
1020 : multiLine: false);
1021 6 : static final RegExp _countEmojiRegex = RegExp(
1022 : r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?)',
1023 : caseSensitive: false,
1024 : multiLine: false);
1025 6 : static final RegExp _countEmojiEmoteRegex = RegExp(
1026 : r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>)',
1027 : caseSensitive: false,
1028 : multiLine: false);
1029 :
1030 : /// Returns if a given event only has emotes, emojis or whitespace as content.
1031 : /// If the body contains a reply then it is stripped.
1032 : /// This is useful to determine if stand-alone emotes should be displayed bigger.
1033 2 : bool get onlyEmotes {
1034 2 : if (isRichMessage) {
1035 4 : final formattedTextStripped = formattedText.replaceAll(
1036 2 : RegExp('<mx-reply>.*</mx-reply>',
1037 : caseSensitive: false, multiLine: false, dotAll: true),
1038 : '');
1039 4 : return _onlyEmojiEmoteRegex.hasMatch(formattedTextStripped);
1040 : } else {
1041 6 : return _onlyEmojiRegex.hasMatch(plaintextBody);
1042 : }
1043 : }
1044 :
1045 : /// Gets the number of emotes in a given message. This is useful to determine
1046 : /// if the emotes should be displayed bigger.
1047 : /// If the body contains a reply then it is stripped.
1048 : /// WARNING: This does **not** test if there are only emotes. Use `event.onlyEmotes` for that!
1049 2 : int get numberEmotes {
1050 2 : if (isRichMessage) {
1051 4 : final formattedTextStripped = formattedText.replaceAll(
1052 2 : RegExp('<mx-reply>.*</mx-reply>',
1053 : caseSensitive: false, multiLine: false, dotAll: true),
1054 : '');
1055 6 : return _countEmojiEmoteRegex.allMatches(formattedTextStripped).length;
1056 : } else {
1057 8 : return _countEmojiRegex.allMatches(plaintextBody).length;
1058 : }
1059 : }
1060 :
1061 : /// If this event is in Status SENDING and it aims to send a file, then this
1062 : /// shows the status of the file sending.
1063 0 : FileSendingStatus? get fileSendingStatus {
1064 0 : final status = unsigned?.tryGet<String>(fileSendingStatusKey);
1065 : if (status == null) return null;
1066 0 : return FileSendingStatus.values.singleWhereOrNull(
1067 0 : (fileSendingStatus) => fileSendingStatus.name == status);
1068 : }
1069 : }
1070 :
1071 : enum FileSendingStatus {
1072 : generatingThumbnail,
1073 : encrypting,
1074 : uploading,
1075 : }
|