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