famedlysdk/lib/src/event.dart

791 lines
26 KiB
Dart

/*
* Copyright (c) 2019 Zender & Kurtz GbR.
*
* Authors:
* Christian Pauly <krille@famedly.com>
* Marcel Radzio <mtrnord@famedly.com>
*
* This file is part of famedlysdk.
*
* famedlysdk is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* famedlysdk 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with famedlysdk. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'dart:typed_data';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/utils/receipt.dart';
import 'package:http/http.dart' as http;
import 'package:matrix_file_e2ee/matrix_file_e2ee.dart';
import './room.dart';
import 'utils/matrix_localizations.dart';
import './database/database.dart' show DbRoomState, DbEvent;
/// 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 {
/// The Matrix ID for this event in the format '$localpart:server.abc'. Please not
/// that account data, presence and other events may not have an eventId.
final String eventId;
/// The json payload of the content. The content highly depends on the type.
Map<String, dynamic> content;
/// The type String of this event. For example 'm.room.message'.
final String typeKey;
/// The ID of the room this event belongs to.
final String roomId;
/// The user who has sent this event if it is not a global account data event.
final String senderId;
User get sender => room.getUserByMXIDSync(senderId ?? '@unknown');
/// The time this event has received at the server. May be null for events like
/// account data.
final DateTime time;
/// Optional additional content for this event.
Map<String, dynamic> unsigned;
/// The room this event belongs to. May be null.
final Room room;
/// Optional. The previous content for this state.
/// This will be present only for state events appearing in the timeline.
/// If this is not a state event, or there is no previous content, this key will be null.
Map<String, dynamic> prevContent;
/// Optional. This key will only be present for state events. A unique key which defines
/// the overwriting semantics for this piece of room state.
final String stateKey;
/// The status of this event.
/// -1=ERROR
/// 0=SENDING
/// 1=SENT
/// 2=TIMELINE
/// 3=ROOM_STATE
int status;
static const int defaultStatus = 2;
static const Map<String, int> STATUS_TYPE = {
'ERROR': -1,
'SENDING': 0,
'SENT': 1,
'TIMELINE': 2,
'ROOM_STATE': 3,
};
/// Optional. The event that redacted this event, if any. Otherwise null.
Event get redactedBecause =>
unsigned != null && unsigned.containsKey('redacted_because')
? Event.fromJson(unsigned['redacted_because'], room)
: null;
bool get redacted => redactedBecause != null;
User get stateKeyUser => room.getUserByMXIDSync(stateKey);
double sortOrder;
Event(
{this.status = defaultStatus,
this.content,
this.typeKey,
this.eventId,
this.roomId,
this.senderId,
this.time,
this.unsigned,
this.prevContent,
this.stateKey,
this.room,
this.sortOrder = 0.0});
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 {};
}
/// Get a State event from a table row or from the event stream.
factory Event.fromJson(Map<String, dynamic> jsonPayload, Room room,
[double sortOrder]) {
final content = Event.getMapFromPayload(jsonPayload['content']);
final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
return Event(
status: jsonPayload['status'] ?? defaultStatus,
stateKey: jsonPayload['state_key'],
prevContent: prevContent,
content: content,
typeKey: jsonPayload['type'],
eventId: jsonPayload['event_id'],
roomId: jsonPayload['room_id'],
senderId: jsonPayload['sender'],
time: jsonPayload.containsKey('origin_server_ts')
? DateTime.fromMillisecondsSinceEpoch(jsonPayload['origin_server_ts'])
: DateTime.now(),
unsigned: unsigned,
room: room,
sortOrder: sortOrder ?? 0.0,
);
}
/// Get an event from either DbRoomState or DbEvent
factory Event.fromDb(dynamic dbEntry, Room room) {
if (!(dbEntry is DbRoomState || dbEntry is DbEvent)) {
throw ('Unknown db type');
}
final content = Event.getMapFromPayload(dbEntry.content);
final unsigned = Event.getMapFromPayload(dbEntry.unsigned);
final prevContent = Event.getMapFromPayload(dbEntry.prevContent);
return Event(
status: (dbEntry is DbEvent ? dbEntry.status : null) ?? defaultStatus,
stateKey: dbEntry.stateKey,
prevContent: prevContent,
content: content,
typeKey: dbEntry.type,
eventId: dbEntry.eventId,
roomId: dbEntry.roomId,
senderId: dbEntry.sender,
time: dbEntry.originServerTs ?? DateTime.now(),
unsigned: unsigned,
room: room,
sortOrder: dbEntry.sortOrder ?? 0.0,
);
}
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
if (stateKey != null) data['state_key'] = stateKey;
if (prevContent != null && prevContent.isNotEmpty) {
data['prev_content'] = prevContent;
}
data['content'] = content;
data['type'] = typeKey;
data['event_id'] = eventId;
data['room_id'] = roomId;
data['sender'] = senderId;
data['origin_server_ts'] = time.millisecondsSinceEpoch;
if (unsigned != null && unsigned.isNotEmpty) {
data['unsigned'] = unsigned;
}
return data;
}
/// The unique key of this event. For events with a [stateKey], it will be the
/// stateKey. Otherwise it will be the [type] as a string.
@deprecated
String get key => stateKey == null || stateKey.isEmpty ? typeKey : stateKey;
User get asUser => User.fromState(
stateKey: stateKey,
prevContent: prevContent,
content: content,
typeKey: typeKey,
eventId: eventId,
roomId: roomId,
senderId: senderId,
time: time,
unsigned: unsigned,
room: room);
/// Get the real type.
EventTypes get type {
switch (typeKey) {
case 'm.room.avatar':
return EventTypes.RoomAvatar;
case 'm.room.name':
return EventTypes.RoomName;
case 'm.room.topic':
return EventTypes.RoomTopic;
case 'm.room.aliases':
return EventTypes.RoomAliases;
case 'm.room.canonical_alias':
return EventTypes.RoomCanonicalAlias;
case 'm.room.create':
return EventTypes.RoomCreate;
case 'm.room.redaction':
return EventTypes.Redaction;
case 'm.room.join_rules':
return EventTypes.RoomJoinRules;
case 'm.room.member':
return EventTypes.RoomMember;
case 'm.room.power_levels':
return EventTypes.RoomPowerLevels;
case 'm.room.guest_access':
return EventTypes.GuestAccess;
case 'm.room.history_visibility':
return EventTypes.HistoryVisibility;
case 'm.sticker':
return EventTypes.Sticker;
case 'm.room.message':
return EventTypes.Message;
case 'm.room.encrypted':
return EventTypes.Encrypted;
case 'm.room.encryption':
return EventTypes.Encryption;
case 'm.room.tombsone':
return EventTypes.RoomTombstone;
case 'm.call.invite':
return EventTypes.CallInvite;
case 'm.call.answer':
return EventTypes.CallAnswer;
case 'm.call.candidates':
return EventTypes.CallCandidates;
case 'm.call.hangup':
return EventTypes.CallHangup;
}
return EventTypes.Unknown;
}
///
MessageTypes get messageType {
switch (content['msgtype'] ?? 'm.text') {
case 'm.text':
if (content.containsKey('m.relates_to')) {
return MessageTypes.Reply;
}
return MessageTypes.Text;
case 'm.notice':
return MessageTypes.Notice;
case 'm.emote':
return MessageTypes.Emote;
case 'm.image':
return MessageTypes.Image;
case 'm.video':
return MessageTypes.Video;
case 'm.audio':
return MessageTypes.Audio;
case 'm.file':
return MessageTypes.File;
case 'm.sticker':
return MessageTypes.Sticker;
case 'm.location':
return MessageTypes.Location;
case 'm.bad.encrypted':
return MessageTypes.BadEncrypted;
default:
if (type == EventTypes.Message) {
return MessageTypes.Text;
}
return MessageTypes.None;
}
}
void setRedactionEvent(Event redactedBecause) {
unsigned = {
'redacted_because': redactedBecause.toJson(),
};
prevContent = null;
var 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;
}
var toRemoveList = <String>[];
for (var entry in content.entries) {
if (!contentKeyWhiteList.contains(entry.key)) {
toRemoveList.add(entry.key);
}
}
toRemoveList.forEach((s) => content.remove(s));
}
/// Returns the body of this event if it has a body.
String get text => content['body'] ?? '';
/// Returns the formatted boy of this event if it has a formatted body.
String get formattedText => content['formatted_body'] ?? '';
@Deprecated('Use [body] instead.')
String getBody() => body;
/// Use this to get the body.
String get body {
if (redacted) return 'Redacted';
if (text != '') return text;
if (formattedText != '') return formattedText;
return '$type';
}
/// Returns a list of [Receipt] instances for this event.
List<Receipt> get receipts {
if (!(room.roomAccountData.containsKey('m.receipt'))) return [];
var receiptsList = <Receipt>[];
for (var entry in room.roomAccountData['m.receipt'].content.entries) {
if (entry.value['event_id'] == eventId) {
receiptsList.add(Receipt(room.getUserByMXIDSync(entry.key),
DateTime.fromMillisecondsSinceEpoch(entry.value['ts'])));
}
}
return receiptsList;
}
/// Removes this event if the status is < 1. This event will just be removed
/// from the database and the timelines. Returns false if not removed.
Future<bool> remove() async {
if (status < 1) {
await room.client.database?.removeEvent(room.client.id, eventId, room.id);
room.client.onEvent.add(EventUpdate(
roomID: room.id,
type: 'timeline',
eventType: typeKey,
content: {
'event_id': eventId,
'status': -2,
'content': {'body': 'Removed...'}
},
sortOrder: sortOrder));
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 != -1) return null;
await remove();
final eventID = await room.sendTextEvent(text, txid: txid);
return eventID;
}
/// Whether the client is allowed to redact this event.
bool get canRedact => senderId == room.client.userID || room.canRedact;
/// Redacts this event. Returns [ErrorResponse] on error.
Future<dynamic> redact({String reason, String txid}) =>
room.redactEvent(eventId, reason: reason, txid: txid);
/// Whether this event is in reply to another event.
bool get isReply =>
content['m.relates_to'] is Map<String, dynamic> &&
content['m.relates_to']['m.in_reply_to'] is Map<String, dynamic> &&
content['m.relates_to']['m.in_reply_to']['event_id'] is String &&
(content['m.relates_to']['m.in_reply_to']['event_id'] as String)
.isNotEmpty;
/// Searches for the reply event in the given timeline.
Future<Event> getReplyEvent(Timeline timeline) async {
if (!isReply) return null;
final String replyEventId =
content['m.relates_to']['m.in_reply_to']['event_id'];
return await timeline.getEventById(replyEventId);
}
Future<void> loadSession() {
return room.loadInboundGroupSessionKeyForEvent(this);
}
/// Trys to decrypt this event. Returns a m.bad.encrypted event
/// if it fails and does nothing if the event was not encrypted.
Event get decrypted => room.decryptGroupMessage(this);
/// 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['body'] != DecryptError.UNKNOWN_SESSION) {
throw ('Session key not unknown');
}
await room.requestSessionKey(content['session_id'], content['sender_key']);
return;
}
bool get hasThumbnail =>
content['info'] is Map<String, dynamic> &&
(content['info'].containsKey('thumbnail_url') ||
content['info'].containsKey('thumbnail_file'));
/// Downloads (and decryptes 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}) async {
if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
throw ("This event has the type '$typeKey' and so it can't contain an attachment.");
}
if (!getThumbnail &&
!content.containsKey('url') &&
!content.containsKey('file')) {
throw ("This event hasn't any attachment.");
}
if (getThumbnail && !hasThumbnail) {
throw ("This event hasn't any thumbnail.");
}
final isEncrypted = getThumbnail
? !content['info'].containsKey('thumbnail_url')
: !content.containsKey('url');
if (isEncrypted && !room.client.encryptionEnabled) {
throw ('Encryption is not enabled in your Client.');
}
var mxContent = getThumbnail
? Uri.parse(isEncrypted
? content['info']['thumbnail_file']['url']
: content['info']['thumbnail_url'])
: Uri.parse(isEncrypted ? content['file']['url'] : content['url']);
Uint8List uint8list;
// Is this file storeable?
final infoMap =
getThumbnail ? content['info']['thumbnail_info'] : content['info'];
final storeable = room.client.database != null &&
infoMap is Map<String, dynamic> &&
infoMap['size'] is int &&
infoMap['size'] <= room.client.database.maxFileSize;
if (storeable) {
uint8list = await room.client.database.getFile(mxContent.toString());
}
// Download the file
if (uint8list == null) {
uint8list =
(await http.get(mxContent.getDownloadLink(room.client))).bodyBytes;
if (storeable) {
await room.client.database
.storeFile(mxContent.toString(), uint8list, DateTime.now());
}
}
// Decrypt the file
if (isEncrypted) {
final fileMap =
getThumbnail ? content['info']['thumbnail_file'] : content['file'];
if (!fileMap['key']['key_ops'].contains('decrypt')) {
throw ("Missing 'decrypt' in 'key_ops'.");
}
final encryptedFile = EncryptedFile();
encryptedFile.data = uint8list;
encryptedFile.iv = fileMap['iv'];
encryptedFile.k = fileMap['key']['k'];
encryptedFile.sha256 = fileMap['hashes']['sha256'];
uint8list = await decryptFile(encryptedFile);
}
return MatrixFile(bytes: uint8list, path: '/$body');
}
/// 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 '>'.
String getLocalizedBody(MatrixLocalizations i18n,
{bool withSenderNamePrefix = false, bool hideReply = false}) {
if (redacted) {
return i18n.removedBy(redactedBecause.sender.calcDisplayname());
}
var localizedBody = body;
final senderName = sender.calcDisplayname();
switch (type) {
case EventTypes.Sticker:
localizedBody = i18n.sentASticker(senderName);
break;
case EventTypes.Redaction:
localizedBody = i18n.redactedAnEvent(senderName);
break;
case EventTypes.RoomAliases:
localizedBody = i18n.changedTheRoomAliases(senderName);
break;
case EventTypes.RoomCanonicalAlias:
localizedBody = i18n.changedTheRoomInvitationLink(senderName);
break;
case EventTypes.RoomCreate:
localizedBody = i18n.createdTheChat(senderName);
break;
case EventTypes.RoomTombstone:
localizedBody = i18n.roomHasBeenUpgraded;
break;
case EventTypes.RoomJoinRules:
var joinRules = JoinRules.values.firstWhere(
(r) =>
r.toString().replaceAll('JoinRules.', '') ==
content['join_rule'],
orElse: () => null);
if (joinRules == null) {
localizedBody = i18n.changedTheJoinRules(senderName);
} else {
localizedBody = i18n.changedTheJoinRulesTo(
senderName, joinRules.getLocalizedString(i18n));
}
break;
case EventTypes.RoomMember:
var text = 'Failed to parse member event';
final targetName = stateKeyUser.calcDisplayname();
// Has the membership changed?
final newMembership = content['membership'] ?? '';
final oldMembership = unsigned['prev_content'] is Map<String, dynamic>
? unsigned['prev_content']['membership'] ?? ''
: '';
if (newMembership != oldMembership) {
if (oldMembership == 'invite' && newMembership == 'join') {
text = i18n.acceptedTheInvitation(targetName);
} else if (oldMembership == 'invite' && newMembership == 'leave') {
if (stateKey == senderId) {
text = i18n.rejectedTheInvitation(targetName);
} else {
text = i18n.hasWithdrawnTheInvitationFor(senderName, targetName);
}
} else if (oldMembership == 'leave' && newMembership == 'join') {
text = i18n.joinedTheChat(targetName);
} else if (oldMembership == 'join' && newMembership == 'ban') {
text = i18n.kickedAndBanned(senderName, targetName);
} else if (oldMembership == 'join' &&
newMembership == 'leave' &&
stateKey != senderId) {
text = i18n.kicked(senderName, targetName);
} else if (oldMembership == 'join' &&
newMembership == 'leave' &&
stateKey == senderId) {
text = i18n.userLeftTheChat(targetName);
} else if (oldMembership == 'invite' && newMembership == 'ban') {
text = i18n.bannedUser(senderName, targetName);
} else if (oldMembership == 'leave' && newMembership == 'ban') {
text = i18n.bannedUser(senderName, targetName);
} else if (oldMembership == 'ban' && newMembership == 'leave') {
text = i18n.unbannedUser(senderName, targetName);
} else if (newMembership == 'invite') {
text = i18n.invitedUser(senderName, targetName);
} else if (newMembership == 'join') {
text = i18n.joinedTheChat(targetName);
}
} else if (newMembership == 'join') {
final newAvatar = content['avatar_url'] ?? '';
final oldAvatar = unsigned['prev_content'] is Map<String, dynamic>
? unsigned['prev_content']['avatar_url'] ?? ''
: '';
final newDisplayname = content['displayname'] ?? '';
final oldDisplayname =
unsigned['prev_content'] is Map<String, dynamic>
? unsigned['prev_content']['displayname'] ?? ''
: '';
// Has the user avatar changed?
if (newAvatar != oldAvatar) {
text = i18n.changedTheProfileAvatar(targetName);
}
// Has the user avatar changed?
else if (newDisplayname != oldDisplayname) {
text = i18n.changedTheDisplaynameTo(targetName, newDisplayname);
}
}
localizedBody = text;
break;
case EventTypes.RoomPowerLevels:
localizedBody = i18n.changedTheChatPermissions(senderName);
break;
case EventTypes.RoomName:
localizedBody = i18n.changedTheChatNameTo(senderName, content['name']);
break;
case EventTypes.RoomTopic:
localizedBody =
i18n.changedTheChatDescriptionTo(senderName, content['topic']);
break;
case EventTypes.RoomAvatar:
localizedBody = i18n.changedTheChatAvatar(senderName);
break;
case EventTypes.GuestAccess:
var guestAccess = GuestAccess.values.firstWhere(
(r) =>
r.toString().replaceAll('GuestAccess.', '') ==
content['guest_access'],
orElse: () => null);
if (guestAccess == null) {
localizedBody = i18n.changedTheGuestAccessRules(senderName);
} else {
localizedBody = i18n.changedTheGuestAccessRulesTo(
senderName, guestAccess.getLocalizedString(i18n));
}
break;
case EventTypes.HistoryVisibility:
var historyVisibility = HistoryVisibility.values.firstWhere(
(r) =>
r.toString().replaceAll('HistoryVisibility.', '') ==
content['history_visibility'],
orElse: () => null);
if (historyVisibility == null) {
localizedBody = i18n.changedTheHistoryVisibility(senderName);
} else {
localizedBody = i18n.changedTheHistoryVisibilityTo(
senderName, historyVisibility.getLocalizedString(i18n));
}
break;
case EventTypes.Encryption:
localizedBody = i18n.activatedEndToEndEncryption(senderName);
if (!room.client.encryptionEnabled) {
localizedBody += '. ' + i18n.needPantalaimonWarning;
}
break;
case EventTypes.Encrypted:
case EventTypes.Message:
switch (messageType) {
case MessageTypes.Image:
localizedBody = i18n.sentAPicture(senderName);
break;
case MessageTypes.File:
localizedBody = i18n.sentAFile(senderName);
break;
case MessageTypes.Audio:
localizedBody = i18n.sentAnAudio(senderName);
break;
case MessageTypes.Video:
localizedBody = i18n.sentAVideo(senderName);
break;
case MessageTypes.Location:
localizedBody = i18n.sharedTheLocation(senderName);
break;
case MessageTypes.Sticker:
localizedBody = i18n.sentASticker(senderName);
break;
case MessageTypes.Emote:
localizedBody = '* $body';
break;
case MessageTypes.BadEncrypted:
String errorText;
switch (body) {
case DecryptError.CHANNEL_CORRUPTED:
errorText = i18n.channelCorruptedDecryptError + '.';
break;
case DecryptError.NOT_ENABLED:
errorText = i18n.encryptionNotEnabled + '.';
break;
case DecryptError.UNKNOWN_ALGORITHM:
errorText = i18n.unknownEncryptionAlgorithm + '.';
break;
case DecryptError.UNKNOWN_SESSION:
errorText = i18n.noPermission + '.';
break;
default:
errorText = body;
break;
}
localizedBody = i18n.couldNotDecryptMessage(errorText);
break;
case MessageTypes.Text:
case MessageTypes.Notice:
case MessageTypes.None:
case MessageTypes.Reply:
localizedBody = body;
break;
}
break;
default:
localizedBody = i18n.unknownEvent(typeKey);
}
// Hide reply fallback
if (hideReply) {
localizedBody = localizedBody.replaceFirst(
RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
}
// Add the sender name prefix
if (withSenderNamePrefix &&
type == EventTypes.Message &&
textOnlyMessageTypes.contains(messageType)) {
final senderNameOrYou =
senderId == room.client.userID ? i18n.you : senderName;
localizedBody = '$senderNameOrYou: $localizedBody';
}
return localizedBody;
}
static const Set<MessageTypes> textOnlyMessageTypes = {
MessageTypes.Text,
MessageTypes.Reply,
MessageTypes.Notice,
MessageTypes.Emote,
MessageTypes.None,
};
}
enum MessageTypes {
Text,
Emote,
Notice,
Image,
Video,
Audio,
File,
Location,
Reply,
Sticker,
BadEncrypted,
None,
}
enum EventTypes {
Message,
Sticker,
Redaction,
RoomAliases,
RoomCanonicalAlias,
RoomCreate,
RoomJoinRules,
RoomMember,
RoomPowerLevels,
RoomName,
RoomTopic,
RoomAvatar,
RoomTombstone,
GuestAccess,
HistoryVisibility,
Encryption,
Encrypted,
CallInvite,
CallAnswer,
CallCandidates,
CallHangup,
Unknown,
}