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