You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
792 lines
29 KiB
792 lines
29 KiB
/* |
|
* Famedly Matrix SDK |
|
* Copyright (C) 2019, 2020, 2021 Famedly GmbH |
|
* |
|
* This program is free software: you can redistribute it and/or modify |
|
* it under the terms of the GNU Affero General Public License as |
|
* published by the Free Software Foundation, either version 3 of the |
|
* License, or (at your option) any later version. |
|
* |
|
* This program is distributed in the hope that it will be useful, |
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
* GNU Affero General Public License for more details. |
|
* |
|
* You should have received a copy of the GNU Affero General Public License |
|
* along with this program. If not, see <https://www.gnu.org/licenses/>. |
|
*/ |
|
|
|
import 'dart:convert'; |
|
import 'dart:typed_data'; |
|
|
|
import 'package:http/http.dart' as http; |
|
|
|
import '../matrix.dart'; |
|
import 'utils/event_localizations.dart'; |
|
import 'utils/html_to_text.dart'; |
|
|
|
abstract class RelationshipTypes { |
|
static const String reply = 'm.in_reply_to'; |
|
static const String edit = 'm.replace'; |
|
static const String reaction = 'm.annotation'; |
|
} |
|
|
|
/// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event. |
|
class Event extends MatrixEvent { |
|
User get sender => room.getUserByMXIDSync(senderId); |
|
|
|
@Deprecated('Use [originServerTs] instead') |
|
DateTime get time => originServerTs; |
|
|
|
@Deprecated('Use [type] instead') |
|
String get typeKey => type; |
|
|
|
@Deprecated('Use [sender.calcDisplayname()] instead') |
|
String? get senderName => sender.calcDisplayname(); |
|
|
|
/// The room this event belongs to. May be null. |
|
final Room room; |
|
|
|
/// The status of this event. |
|
EventStatus status; |
|
|
|
static const EventStatus defaultStatus = EventStatus.synced; |
|
|
|
/// Optional. The event that redacted this event, if any. Otherwise null. |
|
Event? get redactedBecause { |
|
final redacted_because = unsigned?['redacted_because']; |
|
final room = this.room; |
|
return (redacted_because is Map<String, dynamic>) |
|
? Event.fromJson(redacted_because, room) |
|
: null; |
|
} |
|
|
|
bool get redacted => redactedBecause != null; |
|
|
|
User? get stateKeyUser => room.getUserByMXIDSync(stateKey!); |
|
|
|
Event({ |
|
this.status = defaultStatus, |
|
required Map<String, dynamic> content, |
|
required String type, |
|
required String eventId, |
|
required String senderId, |
|
required DateTime originServerTs, |
|
Map<String, dynamic>? unsigned, |
|
Map<String, dynamic>? prevContent, |
|
String? stateKey, |
|
required this.room, |
|
}) : super( |
|
content: content, |
|
type: type, |
|
eventId: eventId, |
|
senderId: senderId, |
|
originServerTs: originServerTs, |
|
roomId: room.id, |
|
) { |
|
this.eventId = eventId; |
|
this.unsigned = unsigned; |
|
// synapse unfortunately isn't following the spec and tosses the prev_content |
|
// into the unsigned block. |
|
// Currently we are facing a very strange bug in web which is impossible to debug. |
|
// It may be because of this line so we put this in try-catch until we can fix it. |
|
try { |
|
this.prevContent = (prevContent != null && prevContent.isNotEmpty) |
|
? prevContent |
|
: (unsigned != null && |
|
unsigned.containsKey('prev_content') && |
|
unsigned['prev_content'] is Map) |
|
? unsigned['prev_content'] |
|
: null; |
|
} catch (_) { |
|
// A strange bug in dart web makes this crash |
|
} |
|
this.stateKey = stateKey; |
|
|
|
// Mark event as failed to send if status is `sending` and event is older |
|
// than the timeout. This should not happen with the deprecated Moor |
|
// database! |
|
if (status.isSending && room.client.database != null) { |
|
// Age of this event in milliseconds |
|
final age = DateTime.now().millisecondsSinceEpoch - |
|
originServerTs.millisecondsSinceEpoch; |
|
|
|
final room = this.room; |
|
if (age > room.client.sendMessageTimeoutSeconds * 1000) { |
|
// Update this event in database and open timelines |
|
final json = toJson(); |
|
json['unsigned'] ??= <String, dynamic>{}; |
|
json['unsigned'][messageSendingStatusKey] = EventStatus.error.intValue; |
|
room.client.handleSync( |
|
SyncUpdate( |
|
nextBatch: '', |
|
rooms: RoomsUpdate( |
|
join: { |
|
room.id: JoinedRoomUpdate( |
|
timeline: TimelineUpdate( |
|
events: [MatrixEvent.fromJson(json)], |
|
), |
|
) |
|
}, |
|
), |
|
), |
|
); |
|
} |
|
} |
|
} |
|
|
|
static Map<String, dynamic> getMapFromPayload(dynamic payload) { |
|
if (payload is String) { |
|
try { |
|
return json.decode(payload); |
|
} catch (e) { |
|
return {}; |
|
} |
|
} |
|
if (payload is Map<String, dynamic>) return payload; |
|
return {}; |
|
} |
|
|
|
factory Event.fromMatrixEvent( |
|
MatrixEvent matrixEvent, |
|
Room room, { |
|
EventStatus status = defaultStatus, |
|
}) => |
|
Event( |
|
status: status, |
|
content: matrixEvent.content, |
|
type: matrixEvent.type, |
|
eventId: matrixEvent.eventId, |
|
senderId: matrixEvent.senderId, |
|
originServerTs: matrixEvent.originServerTs, |
|
unsigned: matrixEvent.unsigned, |
|
prevContent: matrixEvent.prevContent, |
|
stateKey: matrixEvent.stateKey, |
|
room: room, |
|
); |
|
|
|
/// Get a State event from a table row or from the event stream. |
|
factory Event.fromJson( |
|
Map<String, dynamic> jsonPayload, |
|
Room room, |
|
) { |
|
final content = Event.getMapFromPayload(jsonPayload['content']); |
|
final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']); |
|
final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']); |
|
return Event( |
|
status: eventStatusFromInt(jsonPayload['status'] ?? |
|
unsigned[messageSendingStatusKey] ?? |
|
defaultStatus.intValue), |
|
stateKey: jsonPayload['state_key'], |
|
prevContent: prevContent, |
|
content: content, |
|
type: jsonPayload['type'], |
|
eventId: jsonPayload['event_id'] ?? '', |
|
senderId: jsonPayload['sender'], |
|
originServerTs: jsonPayload.containsKey('origin_server_ts') |
|
? DateTime.fromMillisecondsSinceEpoch(jsonPayload['origin_server_ts']) |
|
: DateTime.now(), |
|
unsigned: unsigned, |
|
room: room, |
|
); |
|
} |
|
|
|
@override |
|
Map<String, dynamic> toJson() { |
|
final data = <String, dynamic>{}; |
|
if (stateKey != null) data['state_key'] = stateKey; |
|
if (prevContent?.isNotEmpty == true) { |
|
data['prev_content'] = prevContent; |
|
} |
|
data['content'] = content; |
|
data['type'] = type; |
|
data['event_id'] = eventId; |
|
data['room_id'] = roomId; |
|
data['sender'] = senderId; |
|
data['origin_server_ts'] = originServerTs.millisecondsSinceEpoch; |
|
if (unsigned?.isNotEmpty == true) { |
|
data['unsigned'] = unsigned; |
|
} |
|
return data; |
|
} |
|
|
|
User get asUser => User.fromState( |
|
// state key should always be set for member events |
|
stateKey: stateKey!, |
|
prevContent: prevContent, |
|
content: content, |
|
typeKey: type, |
|
eventId: eventId, |
|
roomId: roomId, |
|
senderId: senderId, |
|
originServerTs: originServerTs, |
|
unsigned: unsigned, |
|
room: room); |
|
|
|
String get messageType => type == EventTypes.Sticker |
|
? MessageTypes.Sticker |
|
: (content['msgtype'] is String ? content['msgtype'] : MessageTypes.Text); |
|
|
|
void setRedactionEvent(Event redactedBecause) { |
|
unsigned = { |
|
'redacted_because': redactedBecause.toJson(), |
|
}; |
|
prevContent = null; |
|
final contentKeyWhiteList = <String>[]; |
|
switch (type) { |
|
case EventTypes.RoomMember: |
|
contentKeyWhiteList.add('membership'); |
|
break; |
|
case EventTypes.RoomCreate: |
|
contentKeyWhiteList.add('creator'); |
|
break; |
|
case EventTypes.RoomJoinRules: |
|
contentKeyWhiteList.add('join_rule'); |
|
break; |
|
case EventTypes.RoomPowerLevels: |
|
contentKeyWhiteList.add('ban'); |
|
contentKeyWhiteList.add('events'); |
|
contentKeyWhiteList.add('events_default'); |
|
contentKeyWhiteList.add('kick'); |
|
contentKeyWhiteList.add('redact'); |
|
contentKeyWhiteList.add('state_default'); |
|
contentKeyWhiteList.add('users'); |
|
contentKeyWhiteList.add('users_default'); |
|
break; |
|
case EventTypes.RoomAliases: |
|
contentKeyWhiteList.add('aliases'); |
|
break; |
|
case EventTypes.HistoryVisibility: |
|
contentKeyWhiteList.add('history_visibility'); |
|
break; |
|
default: |
|
break; |
|
} |
|
content.removeWhere((k, v) => !contentKeyWhiteList.contains(k)); |
|
} |
|
|
|
/// Returns the body of this event if it has a body. |
|
String get text => content['body'] is String ? content['body'] : ''; |
|
|
|
/// Returns the formatted boy of this event if it has a formatted body. |
|
String get formattedText => |
|
content['formatted_body'] is String ? content['formatted_body'] : ''; |
|
|
|
/// Use this to get the body. |
|
String get body { |
|
if (redacted) return 'Redacted'; |
|
if (text != '') return text; |
|
if (formattedText != '') return formattedText; |
|
return '$type'; |
|
} |
|
|
|
/// Use this to get a plain-text representation of the event, stripping things |
|
/// like spoilers and thelike. Useful for plain text notifications. |
|
String get plaintextBody => content['format'] == 'org.matrix.custom.html' |
|
? HtmlToText.convert(formattedText) |
|
: body; |
|
|
|
/// Returns a list of [Receipt] instances for this event. |
|
List<Receipt> get receipts { |
|
final room = this.room; |
|
final receipt = room.roomAccountData['m.receipt']; |
|
if (receipt == null) return []; |
|
return receipt.content.entries |
|
.where((entry) => entry.value['event_id'] == eventId) |
|
.map((entry) => Receipt(room.getUserByMXIDSync(entry.key), |
|
DateTime.fromMillisecondsSinceEpoch(entry.value['ts']))) |
|
.toList(); |
|
} |
|
|
|
/// Removes this event if the status is [sending], [error] or [removed]. |
|
/// This event will just be removed from the database and the timelines. |
|
/// Returns [false] if not removed. |
|
Future<bool> remove() async { |
|
final room = this.room; |
|
|
|
if (!status.isSent) { |
|
await room.client.database?.removeEvent(eventId, room.id); |
|
|
|
room.client.onEvent.add(EventUpdate( |
|
roomID: room.id, |
|
type: EventUpdateType.timeline, |
|
content: { |
|
'event_id': eventId, |
|
'status': EventStatus.removed.intValue, |
|
'content': {'body': 'Removed...'} |
|
}, |
|
)); |
|
return true; |
|
} |
|
return false; |
|
} |
|
|
|
/// Try to send this event again. Only works with events of status -1. |
|
Future<String?> sendAgain({String? txid}) async { |
|
if (!status.isError) return null; |
|
// we do not remove the event here. It will automatically be updated |
|
// in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2 |
|
final newEventId = await room.sendEvent( |
|
content, |
|
txid: txid ?? unsigned?['transaction_id'] ?? eventId, |
|
); |
|
return newEventId; |
|
} |
|
|
|
/// Whether the client is allowed to redact this event. |
|
bool get canRedact => senderId == room.client.userID || room.canRedact; |
|
|
|
/// Redacts this event. Throws `ErrorResponse` on error. |
|
Future<String?> redactEvent({String? reason, String? txid}) async => |
|
await room.redactEvent(eventId, reason: reason, txid: txid); |
|
|
|
/// Searches for the reply event in the given timeline. |
|
Future<Event?> getReplyEvent(Timeline timeline) async { |
|
if (relationshipType != RelationshipTypes.reply) return null; |
|
final relationshipEventId = this.relationshipEventId; |
|
return relationshipEventId == null |
|
? null |
|
: await timeline.getEventById(relationshipEventId); |
|
} |
|
|
|
/// If this event is encrypted and the decryption was not successful because |
|
/// the session is unknown, this requests the session key from other devices |
|
/// in the room. If the event is not encrypted or the decryption failed because |
|
/// of a different error, this throws an exception. |
|
Future<void> requestKey() async { |
|
if (type != EventTypes.Encrypted || |
|
messageType != MessageTypes.BadEncrypted || |
|
content['can_request_session'] != true) { |
|
throw ('Session key not requestable'); |
|
} |
|
await room.requestSessionKey(content['session_id'], content['sender_key']); |
|
return; |
|
} |
|
|
|
/// Gets the info map of file events, or a blank map if none present |
|
Map get infoMap => |
|
content['info'] is Map ? content['info'] : <String, dynamic>{}; |
|
|
|
/// Gets the thumbnail info map of file events, or a blank map if nonepresent |
|
Map get thumbnailInfoMap => infoMap['thumbnail_info'] is Map |
|
? infoMap['thumbnail_info'] |
|
: <String, dynamic>{}; |
|
|
|
/// Returns if a file event has an attachment |
|
bool get hasAttachment => content['url'] is String || content['file'] is Map; |
|
|
|
/// Returns if a file event has a thumbnail |
|
bool get hasThumbnail => |
|
infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map; |
|
|
|
/// Returns if a file events attachment is encrypted |
|
bool get isAttachmentEncrypted => content['file'] is Map; |
|
|
|
/// Returns if a file events thumbnail is encrypted |
|
bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map; |
|
|
|
/// Gets the mimetype of the attachment of a file event, or a blank string if not present |
|
String get attachmentMimetype => infoMap['mimetype'] is String |
|
? infoMap['mimetype'].toLowerCase() |
|
: (content['file'] is Map && content['file']['mimetype'] is String |
|
? content['file']['mimetype'] |
|
: ''); |
|
|
|
/// Gets the mimetype of the thumbnail of a file event, or a blank string if not present |
|
String get thumbnailMimetype => thumbnailInfoMap['mimetype'] is String |
|
? thumbnailInfoMap['mimetype'].toLowerCase() |
|
: (infoMap['thumbnail_file'] is Map && |
|
infoMap['thumbnail_file']['mimetype'] is String |
|
? infoMap['thumbnail_file']['mimetype'] |
|
: ''); |
|
|
|
/// Gets the underlying mxc url of an attachment of a file event, or null if not present |
|
Uri? get attachmentMxcUrl { |
|
final url = isAttachmentEncrypted ? content['file']['url'] : content['url']; |
|
return url is String ? Uri.tryParse(url) : null; |
|
} |
|
|
|
/// Gets the underlying mxc url of a thumbnail of a file event, or null if not present |
|
Uri? get thumbnailMxcUrl { |
|
final url = isThumbnailEncrypted |
|
? infoMap['thumbnail_file']['url'] |
|
: infoMap['thumbnail_url']; |
|
return url is String ? Uri.tryParse(url) : null; |
|
} |
|
|
|
/// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present |
|
Uri? attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) { |
|
if (getThumbnail && |
|
infoMap['size'] is int && |
|
thumbnailInfoMap['size'] is int && |
|
infoMap['size'] <= thumbnailInfoMap['size']) { |
|
getThumbnail = false; |
|
} |
|
if (getThumbnail && !hasThumbnail) { |
|
getThumbnail = false; |
|
} |
|
return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl; |
|
} |
|
|
|
// size determined from an approximate 800x800 jpeg thumbnail with method=scale |
|
static const _minNoThumbSize = 80 * 1024; |
|
|
|
/// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny. |
|
/// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment. |
|
/// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method] |
|
/// for the respective thumbnailing properties. |
|
/// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k |
|
/// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment. |
|
/// [animated] says weather the thumbnail is animated |
|
Uri? getAttachmentUrl( |
|
{bool getThumbnail = false, |
|
bool useThumbnailMxcUrl = false, |
|
double width = 800.0, |
|
double height = 800.0, |
|
ThumbnailMethod method = ThumbnailMethod.scale, |
|
int minNoThumbSize = _minNoThumbSize, |
|
bool animated = false}) { |
|
if (![EventTypes.Message, EventTypes.Sticker].contains(type) || |
|
!hasAttachment || |
|
isAttachmentEncrypted) { |
|
return null; // can't url-thumbnail in encrypted rooms |
|
} |
|
if (useThumbnailMxcUrl && !hasThumbnail) { |
|
return null; // can't fetch from thumbnail |
|
} |
|
final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap; |
|
final thisMxcUrl = |
|
useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url']; |
|
// if we have as method scale, we can return safely the original image, should it be small enough |
|
if (getThumbnail && |
|
method == ThumbnailMethod.scale && |
|
thisInfoMap['size'] is int && |
|
thisInfoMap['size'] < minNoThumbSize) { |
|
getThumbnail = false; |
|
} |
|
// now generate the actual URLs |
|
if (getThumbnail) { |
|
return Uri.parse(thisMxcUrl).getThumbnail( |
|
room.client, |
|
width: width, |
|
height: height, |
|
method: method, |
|
animated: animated, |
|
); |
|
} else { |
|
return Uri.parse(thisMxcUrl).getDownloadLink(room.client); |
|
} |
|
} |
|
|
|
/// Returns if an attachment is in the local store |
|
Future<bool> isAttachmentInLocalStore({bool getThumbnail = false}) async { |
|
if (![EventTypes.Message, EventTypes.Sticker].contains(type)) { |
|
throw ("This event has the type '$type' and so it can't contain an attachment."); |
|
} |
|
final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail); |
|
if (mxcUrl == null) { |
|
throw "This event hasn't any attachment or thumbnail."; |
|
} |
|
getThumbnail = mxcUrl != attachmentMxcUrl; |
|
// Is this file storeable? |
|
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap; |
|
final database = room.client.database; |
|
if (database == null) { |
|
return false; |
|
} |
|
|
|
final storeable = thisInfoMap['size'] is int && |
|
thisInfoMap['size'] <= database.maxFileSize; |
|
|
|
Uint8List? uint8list; |
|
if (storeable) { |
|
uint8list = await database.getFile(mxcUrl); |
|
} |
|
return uint8list != null; |
|
} |
|
|
|
/// Downloads (and decrypts if necessary) the attachment of this |
|
/// event and returns it as a [MatrixFile]. If this event doesn't |
|
/// contain an attachment, this throws an error. Set [getThumbnail] to |
|
/// true to download the thumbnail instead. |
|
Future<MatrixFile> downloadAndDecryptAttachment( |
|
{bool getThumbnail = false, |
|
Future<Uint8List> Function(Uri)? downloadCallback}) async { |
|
if (![EventTypes.Message, EventTypes.Sticker].contains(type)) { |
|
throw ("This event has the type '$type' and so it can't contain an attachment."); |
|
} |
|
final database = room.client.database; |
|
final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail); |
|
if (mxcUrl == null) { |
|
throw "This event hasn't any attachment or thumbnail."; |
|
} |
|
getThumbnail = mxcUrl != attachmentMxcUrl; |
|
final isEncrypted = |
|
getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted; |
|
if (isEncrypted && !room.client.encryptionEnabled) { |
|
throw ('Encryption is not enabled in your Client.'); |
|
} |
|
|
|
// Is this file storeable? |
|
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap; |
|
var storeable = database != null && |
|
thisInfoMap['size'] is int && |
|
thisInfoMap['size'] <= database.maxFileSize; |
|
|
|
Uint8List? uint8list; |
|
if (storeable) { |
|
uint8list = await room.client.database?.getFile(mxcUrl); |
|
} |
|
|
|
// Download the file |
|
if (uint8list == null) { |
|
downloadCallback ??= (Uri url) async => (await http.get(url)).bodyBytes; |
|
uint8list = await downloadCallback(mxcUrl.getDownloadLink(room.client)); |
|
storeable = database != null && |
|
storeable && |
|
uint8list.lengthInBytes < database.maxFileSize; |
|
if (storeable) { |
|
await database.storeFile( |
|
mxcUrl, uint8list, DateTime.now().millisecondsSinceEpoch); |
|
} |
|
} |
|
|
|
// Decrypt the file |
|
if (isEncrypted) { |
|
final fileMap = |
|
getThumbnail ? infoMap['thumbnail_file'] : content['file']; |
|
if (!fileMap['key']['key_ops'].contains('decrypt')) { |
|
throw ("Missing 'decrypt' in 'key_ops'."); |
|
} |
|
final encryptedFile = EncryptedFile( |
|
data: uint8list, |
|
iv: fileMap['iv'], |
|
k: fileMap['key']['k'], |
|
sha256: fileMap['hashes']['sha256'], |
|
); |
|
uint8list = await room.client.runInBackground<Uint8List?, EncryptedFile>( |
|
decryptFile, encryptedFile); |
|
if (uint8list == null) { |
|
throw ('Unable to decrypt file'); |
|
} |
|
} |
|
return MatrixFile(bytes: uint8list, name: body); |
|
} |
|
|
|
/// Returns if this is a known event type. |
|
bool get isEventTypeKnown => |
|
EventLocalizations.localizationsMap.containsKey(type); |
|
|
|
/// Returns a localized String representation of this event. For a |
|
/// room list you may find [withSenderNamePrefix] useful. Set [hideReply] to |
|
/// crop all lines starting with '>'. With [plaintextBody] it'll use the |
|
/// plaintextBody instead of the normal body. |
|
String getLocalizedBody( |
|
MatrixLocalizations i18n, { |
|
bool withSenderNamePrefix = false, |
|
bool hideReply = false, |
|
bool hideEdit = false, |
|
bool plaintextBody = false, |
|
}) { |
|
if (redacted) { |
|
return i18n.removedBy(redactedBecause?.sender.calcDisplayname() ?? ''); |
|
} |
|
var body = plaintextBody ? this.plaintextBody : this.body; |
|
|
|
// we need to know if the message is an html message to be able to determine |
|
// if we need to strip the reply fallback. |
|
var htmlMessage = content['format'] != 'org.matrix.custom.html'; |
|
// If we have an edit, we want to operate on the new content |
|
if (hideEdit && |
|
relationshipType == RelationshipTypes.edit && |
|
content.tryGet<Map<String, dynamic>>('m.new_content') != null) { |
|
if (plaintextBody && |
|
content['m.new_content']['format'] == 'org.matrix.custom.html') { |
|
htmlMessage = true; |
|
body = HtmlToText.convert( |
|
(content['m.new_content'] as Map<String, dynamic>) |
|
.tryGet<String>('formatted_body') ?? |
|
formattedText); |
|
} else { |
|
htmlMessage = false; |
|
body = (content['m.new_content'] as Map<String, dynamic>) |
|
.tryGet<String>('body') ?? |
|
body; |
|
} |
|
} |
|
// Hide reply fallback |
|
// Be sure that the plaintextBody already stripped teh reply fallback, |
|
// if the message is formatted |
|
if (hideReply && (!plaintextBody || htmlMessage)) { |
|
body = body.replaceFirst( |
|
RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), ''); |
|
} |
|
final callback = EventLocalizations.localizationsMap[type]; |
|
var localizedBody = i18n.unknownEvent(type); |
|
if (callback != null) { |
|
localizedBody = callback(this, i18n, body); |
|
} |
|
|
|
// Add the sender name prefix |
|
if (withSenderNamePrefix && |
|
type == EventTypes.Message && |
|
textOnlyMessageTypes.contains(messageType)) { |
|
final senderNameOrYou = senderId == room.client.userID |
|
? i18n.you |
|
: (sender.calcDisplayname()); |
|
localizedBody = '$senderNameOrYou: $localizedBody'; |
|
} |
|
|
|
return localizedBody; |
|
} |
|
|
|
static const Set<String> textOnlyMessageTypes = { |
|
MessageTypes.Text, |
|
MessageTypes.Notice, |
|
MessageTypes.Emote, |
|
MessageTypes.None, |
|
}; |
|
|
|
/// returns if this event matches the passed event or transaction id |
|
bool matchesEventOrTransactionId(String? search) { |
|
if (search == null) { |
|
return false; |
|
} |
|
if (eventId == search) { |
|
return true; |
|
} |
|
return unsigned?['transaction_id'] == search; |
|
} |
|
|
|
/// Get the relationship type of an event. `null` if there is none |
|
String? get relationshipType { |
|
if (content.tryGet<Map<String, dynamic>>('m.relates_to') == null) { |
|
return null; |
|
} |
|
if (content['m.relates_to'].containsKey('m.in_reply_to')) { |
|
return RelationshipTypes.reply; |
|
} |
|
return content |
|
.tryGet<Map<String, dynamic>>('m.relates_to') |
|
?.tryGet<String>('rel_type'); |
|
} |
|
|
|
/// Get the event ID that this relationship will reference. `null` if there is none |
|
String? get relationshipEventId { |
|
if (!(content['m.relates_to'] is Map)) { |
|
return null; |
|
} |
|
if (content['m.relates_to'].containsKey('event_id')) { |
|
return content['m.relates_to']['event_id']; |
|
} |
|
if (content['m.relates_to']['m.in_reply_to'] is Map && |
|
content['m.relates_to']['m.in_reply_to'].containsKey('event_id')) { |
|
return content['m.relates_to']['m.in_reply_to']['event_id']; |
|
} |
|
return null; |
|
} |
|
|
|
/// Get whether this event has aggregated events from a certain [type] |
|
/// To be able to do that you need to pass a [timeline] |
|
bool hasAggregatedEvents(Timeline timeline, String type) => |
|
timeline.aggregatedEvents[eventId]?.containsKey(type) == true; |
|
|
|
/// Get all the aggregated event objects for a given [type]. To be able to do this |
|
/// you have to pass a [timeline] |
|
Set<Event> aggregatedEvents(Timeline timeline, String type) => |
|
timeline.aggregatedEvents[eventId]?[type] ?? <Event>{}; |
|
|
|
/// Fetches the event to be rendered, taking into account all the edits and the like. |
|
/// It needs a [timeline] for that. |
|
Event getDisplayEvent(Timeline timeline) { |
|
if (redacted) { |
|
return this; |
|
} |
|
if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) { |
|
// alright, we have an edit |
|
final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit) |
|
// we only allow edits made by the original author themself |
|
.where((e) => e.senderId == senderId && e.type == EventTypes.Message) |
|
.toList(); |
|
// we need to check again if it isn't empty, as we potentially removed all |
|
// aggregated edits |
|
if (allEditEvents.isNotEmpty) { |
|
allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch - |
|
b.originServerTs.millisecondsSinceEpoch > |
|
0 |
|
? 1 |
|
: -1); |
|
final rawEvent = allEditEvents.last.toJson(); |
|
// update the content of the new event to render |
|
if (rawEvent['content']['m.new_content'] is Map) { |
|
rawEvent['content'] = rawEvent['content']['m.new_content']; |
|
} |
|
return Event.fromJson(rawEvent, room); |
|
} |
|
} |
|
return this; |
|
} |
|
|
|
/// returns if a message is a rich message |
|
bool get isRichMessage => |
|
content['format'] == 'org.matrix.custom.html' && |
|
content['formatted_body'] is String; |
|
|
|
// regexes to fetch the number of emotes, including emoji, and if the message consists of only those |
|
// to match an emoji we can use the following regex: |
|
// (?:\x{00a9}|\x{00ae}|[\x{2600}-\x{27bf}]|[\x{2b00}-\x{2bff}]|\x{d83c}[\x{d000}-\x{dfff}]|\x{d83d}[\x{d000}-\x{dfff}]|\x{d83e}[\x{d000}-\x{dfff}])[\x{fe00}-\x{fe0f}]? |
|
// we need to replace \x{0000} with \u0000, the comment is left in the other format to be able to paste into regex101.com |
|
// to see if there is a custom emote, we use the following regex: <img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*> |
|
// now we combind the two to have four regexes: |
|
// 1. are there only emoji, or whitespace |
|
// 2. are there only emoji, emotes, or whitespace |
|
// 3. count number of emoji |
|
// 4- count number of emoji or emotes |
|
static final RegExp _onlyEmojiRegex = RegExp( |
|
r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$', |
|
caseSensitive: false, |
|
multiLine: false); |
|
static final RegExp _onlyEmojiEmoteRegex = RegExp( |
|
r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>|\s)*$', |
|
caseSensitive: false, |
|
multiLine: false); |
|
static final RegExp _countEmojiRegex = RegExp( |
|
r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?)', |
|
caseSensitive: false, |
|
multiLine: false); |
|
static final RegExp _countEmojiEmoteRegex = RegExp( |
|
r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>)', |
|
caseSensitive: false, |
|
multiLine: false); |
|
|
|
/// Returns if a given event only has emotes, emojis or whitespace as content. |
|
/// If the body contains a reply then it is stripped. |
|
/// This is useful to determine if stand-alone emotes should be displayed bigger. |
|
bool get onlyEmotes { |
|
if (isRichMessage) { |
|
final formattedTextStripped = formattedText.replaceAll( |
|
RegExp('<mx-reply>.*<\/mx-reply>', |
|
caseSensitive: false, multiLine: false, dotAll: true), |
|
''); |
|
return _onlyEmojiEmoteRegex.hasMatch(formattedTextStripped); |
|
} else { |
|
return _onlyEmojiRegex.hasMatch(plaintextBody); |
|
} |
|
} |
|
|
|
/// Gets the number of emotes in a given message. This is useful to determine |
|
/// if the emotes should be displayed bigger. |
|
/// If the body contains a reply then it is stripped. |
|
/// WARNING: This does **not** test if there are only emotes. Use `event.onlyEmotes` for that! |
|
int get numberEmotes { |
|
if (isRichMessage) { |
|
final formattedTextStripped = formattedText.replaceAll( |
|
RegExp('<mx-reply>.*<\/mx-reply>', |
|
caseSensitive: false, multiLine: false, dotAll: true), |
|
''); |
|
return _countEmojiEmoteRegex.allMatches(formattedTextStripped).length; |
|
} else { |
|
return _countEmojiRegex.allMatches(plaintextBody).length; |
|
} |
|
} |
|
}
|
|
|