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