/*
* 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 .
*/
import 'dart:async';
import 'dart:convert';
import 'package:html_unescape/html_unescape.dart';
import 'package:matrix_file_e2ee/matrix_file_e2ee.dart';
import '../famedlysdk.dart';
import 'client.dart';
import 'database/database.dart' show DbRoom;
import 'event.dart';
import 'timeline.dart';
import 'user.dart';
import 'utils/event_update.dart';
import 'utils/markdown.dart';
import 'utils/marked_unread.dart';
import 'utils/matrix_file.dart';
import 'utils/matrix_localizations.dart';
import 'utils/states_map.dart';
enum PushRuleState { notify, mentions_only, dont_notify }
enum JoinRules { public, knock, invite, private }
enum GuestAccess { can_join, forbidden }
enum HistoryVisibility { invited, joined, shared, world_readable }
const String MessageSendingStatusKey =
'com.famedly.famedlysdk.message_sending_status';
/// Represents a Matrix room.
class Room {
/// The full qualified Matrix ID for the room in the format '!localid:server.abc'.
final String id;
/// Membership status of the user for this room.
Membership membership;
/// The count of unread notifications.
int notificationCount;
/// The count of highlighted notifications.
int highlightCount;
/// A token that can be supplied to the from parameter of the rooms/{roomId}/messages endpoint.
String prev_batch;
/// The users which can be used to generate a room name if the room does not have one.
/// Required if the room's m.room.name or m.room.canonical_alias state events are unset or empty.
List mHeroes = [];
/// The number of users with membership of join, including the client's own user ID.
int mJoinedMemberCount;
/// The number of users with membership of invite.
int mInvitedMemberCount;
StatesMap states = StatesMap();
/// Key-Value store for ephemerals.
Map ephemerals = {};
/// Key-Value store for private account data only visible for this user.
Map roomAccountData = {};
double _newestSortOrder;
double _oldestSortOrder;
double get newSortOrder {
var now = DateTime.now().millisecondsSinceEpoch.toDouble();
if (_newestSortOrder >= now) {
now = _newestSortOrder + 1;
}
_newestSortOrder = now;
return _newestSortOrder;
}
double get oldSortOrder {
_oldestSortOrder--;
return _oldestSortOrder;
}
void resetSortOrder() {
_oldestSortOrder = _newestSortOrder = 0.0;
}
Future updateSortOrder() async {
await client.database?.updateRoomSortOrder(
_oldestSortOrder, _newestSortOrder, client.id, id);
}
/// Flag if the room is partial, meaning not all state events have been loaded yet
bool partial = true;
/// Post-loads the room.
/// This load all the missing state events for the room from the database
/// If the room has already been loaded, this does nothing.
Future postLoad() async {
if (!partial || client.database == null) {
return;
}
final allStates = await client.database
.getUnimportantRoomStatesForRoom(
client.id, id, client.importantStateEvents.toList())
.get();
for (final state in allStates) {
final newState = Event.fromDb(state, this);
setState(newState);
}
partial = false;
}
/// Returns the [Event] for the given [typeKey] and optional [stateKey].
/// If no [stateKey] is provided, it defaults to an empty string.
Event getState(String typeKey, [String stateKey = '']) =>
states.states[typeKey] != null ? states.states[typeKey][stateKey] : null;
/// Adds the [state] to this room and overwrites a state with the same
/// typeKey/stateKey key pair if there is one.
void setState(Event state) {
// Decrypt if necessary
if (state.type == EventTypes.Encrypted && client.encryptionEnabled) {
try {
state = client.encryption.decryptRoomEventSync(id, state);
} catch (e, s) {
Logs().e('[LibOlm] Could not decrypt room state', e, s);
}
}
if (!(state.stateKey is String) &&
![EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted]
.contains(state.type) ||
(state.type == EventTypes.Message &&
state.messageType.startsWith('m.room.verification.'))) {
return;
}
final oldStateEvent = getState(state.type, state.stateKey ?? '');
if (oldStateEvent != null && oldStateEvent.sortOrder >= state.sortOrder) {
return;
}
if (!states.states.containsKey(state.type)) {
states.states[state.type] = {};
}
states.states[state.type][state.stateKey ?? ''] = state;
}
/// ID of the fully read marker event.
String get fullyRead => roomAccountData['m.fully_read'] != null
? roomAccountData['m.fully_read'].content['event_id']
: '';
/// If something changes, this callback will be triggered. Will return the
/// room id.
final StreamController onUpdate = StreamController.broadcast();
/// If there is a new session key received, this will be triggered with
/// the session ID.
final StreamController onSessionKeyReceived =
StreamController.broadcast();
/// The name of the room if set by a participant.
String get name => states[EventTypes.RoomName] != null &&
states[EventTypes.RoomName].content['name'] is String
? states[EventTypes.RoomName].content['name']
: '';
/// The pinned events for this room. If there are none this returns an empty
/// list.
List get pinnedEventIds => states[EventTypes.RoomPinnedEvents] != null
? (states[EventTypes.RoomPinnedEvents].content['pinned'] is List
? states[EventTypes.RoomPinnedEvents].content['pinned']
: [])
: [];
/// Returns a localized displayname for this server. If the room is a groupchat
/// without a name, then it will return the localized version of 'Group with Alice' instead
/// of just 'Alice' to make it different to a direct chat.
/// Empty chats will become the localized version of 'Empty Chat'.
/// This method requires a localization class which implements [MatrixLocalizations]
String getLocalizedDisplayname(MatrixLocalizations i18n) {
if ((name?.isEmpty ?? true) &&
(canonicalAlias?.isEmpty ?? true) &&
!isDirectChat &&
(mHeroes != null && mHeroes.isNotEmpty)) {
return i18n.groupWith(displayname);
}
if (displayname?.isNotEmpty ?? false) {
return displayname;
}
return i18n.emptyChat;
}
/// The topic of the room if set by a participant.
String get topic => states[EventTypes.RoomTopic] != null &&
states[EventTypes.RoomTopic].content['topic'] is String
? states[EventTypes.RoomTopic].content['topic']
: '';
/// The avatar of the room if set by a participant.
Uri get avatar {
if (states[EventTypes.RoomAvatar] != null &&
states[EventTypes.RoomAvatar].content['url'] is String) {
return Uri.tryParse(states[EventTypes.RoomAvatar].content['url']);
}
if (mHeroes != null && mHeroes.length == 1 && states[mHeroes[0]] != null) {
return states[mHeroes[0]].asUser.avatarUrl;
}
if (membership == Membership.invite &&
getState(EventTypes.RoomMember, client.userID) != null) {
return getState(EventTypes.RoomMember, client.userID).sender.avatarUrl;
}
return null;
}
/// The address in the format: #roomname:homeserver.org.
String get canonicalAlias => states[EventTypes.RoomCanonicalAlias] != null &&
states[EventTypes.RoomCanonicalAlias].content['alias'] is String
? states[EventTypes.RoomCanonicalAlias].content['alias']
: '';
/// If this room is a direct chat, this is the matrix ID of the user.
/// Returns null otherwise.
String get directChatMatrixID {
String returnUserId;
if (membership == Membership.invite) {
final invitation = getState(EventTypes.RoomMember, client.userID);
if (invitation != null && invitation.content['is_direct'] == true) {
return invitation.senderId;
}
}
if (client.directChats is Map) {
client.directChats.forEach((String userId, dynamic roomIds) {
if (roomIds is List) {
for (var i = 0; i < roomIds.length; i++) {
if (roomIds[i] == id) {
returnUserId = userId;
break;
}
}
}
});
}
return returnUserId;
}
/// Wheither this is a direct chat or not
bool get isDirectChat => directChatMatrixID != null;
/// Must be one of [all, mention]
String notificationSettings;
Event get lastEvent {
// as lastEvent calculation is based on the state events we unfortunately cannot
// use sortOrder here: With many state events we just know which ones are the
// newest ones, without knowing in which order they actually happened. As such,
// using the origin_server_ts is the best guess for this algorithm. While not
// perfect, it is only used for the room preview in the room list and sorting
// said room list, so it should be good enough.
var lastTime = DateTime.fromMillisecondsSinceEpoch(0);
final lastEvents = [
for (var type in client.roomPreviewLastEvents) getState(type)
]..removeWhere((e) => e == null);
var lastEvent = lastEvents.isEmpty
? null
: lastEvents.reduce((a, b) {
if (a.sortOrder == b.sortOrder) {
// if two events have the same sort order we want to give encrypted events a lower priority
// This is so that if the same event exists in the state both encrypted *and* unencrypted,
// the unencrypted one is picked
return a.type == EventTypes.Encrypted ? b : a;
}
return a.sortOrder > b.sortOrder ? a : b;
});
if (lastEvent == null) {
states.forEach((final String key, final entry) {
if (!entry.containsKey('')) return;
final Event state = entry[''];
if (state.originServerTs != null &&
state.originServerTs.millisecondsSinceEpoch >
lastTime.millisecondsSinceEpoch) {
lastTime = state.originServerTs;
lastEvent = state;
}
});
}
return lastEvent;
}
/// Returns a list of all current typing users.
List get typingUsers {
if (!ephemerals.containsKey('m.typing')) return [];
List typingMxid = ephemerals['m.typing'].content['user_ids'];
var typingUsers = [];
for (var i = 0; i < typingMxid.length; i++) {
typingUsers.add(getUserByMXIDSync(typingMxid[i]));
}
return typingUsers;
}
/// Your current client instance.
final Client client;
Room({
this.id,
this.membership = Membership.join,
this.notificationCount = 0,
this.highlightCount = 0,
this.prev_batch = '',
this.client,
this.notificationSettings,
this.mHeroes = const [],
this.mInvitedMemberCount = 0,
this.mJoinedMemberCount = 0,
this.roomAccountData = const {},
double newestSortOrder = 0.0,
double oldestSortOrder = 0.0,
}) : _newestSortOrder = newestSortOrder,
_oldestSortOrder = oldestSortOrder;
/// The default count of how much events should be requested when requesting the
/// history of this room.
static const int DefaultHistoryCount = 100;
/// Calculates the displayname. First checks if there is a name, then checks for a canonical alias and
/// then generates a name from the heroes.
String get displayname {
if (name != null && name.isNotEmpty) return name;
if (canonicalAlias != null &&
canonicalAlias.isNotEmpty &&
canonicalAlias.length > 3) {
return canonicalAlias.localpart;
}
var heroes = [];
if (mHeroes != null &&
mHeroes.isNotEmpty &&
mHeroes.any((h) => h.isNotEmpty)) {
heroes = mHeroes;
} else {
if (states[EventTypes.RoomMember] is Map) {
for (var entry in states[EventTypes.RoomMember].entries) {
Event state = entry.value;
if (state.type == EventTypes.RoomMember &&
state.stateKey != client?.userID) heroes.add(state.stateKey);
}
}
}
if (heroes.isNotEmpty) {
var displayname = '';
for (var i = 0; i < heroes.length; i++) {
if (heroes[i].isEmpty) continue;
displayname += getUserByMXIDSync(heroes[i]).calcDisplayname() + ', ';
}
return displayname.substring(0, displayname.length - 2);
}
if (membership == Membership.invite &&
getState(EventTypes.RoomMember, client.userID) != null) {
return getState(EventTypes.RoomMember, client.userID)
.sender
.calcDisplayname();
}
return 'Empty chat';
}
@Deprecated('Use [lastEvent.body] instead')
String get lastMessage {
if (lastEvent != null) {
return lastEvent.body;
} else {
return '';
}
}
/// When the last message received.
DateTime get timeCreated {
if (lastEvent != null) {
return lastEvent.originServerTs;
}
return DateTime.now();
}
/// Call the Matrix API to change the name of this room. Returns the event ID of the
/// new m.room.name event.
Future setName(String newName) => client.sendState(
id,
EventTypes.RoomName,
{'name': newName},
);
/// Call the Matrix API to change the topic of this room.
Future setDescription(String newName) => client.sendState(
id,
EventTypes.RoomTopic,
{'topic': newName},
);
/// Add a tag to the room.
Future addTag(String tag, {double order}) => client.addRoomTag(
client.userID,
id,
tag,
order: order,
);
/// Removes a tag from the room.
Future removeTag(String tag) => client.removeRoomTag(
client.userID,
id,
tag,
);
/// Returns all tags for this room.
Map get tags {
if (roomAccountData['m.tag'] == null ||
!(roomAccountData['m.tag'].content['tags'] is Map)) {
return {};
}
final tags = (roomAccountData['m.tag'].content['tags'] as Map)
.map((k, v) => MapEntry(k, Tag.fromJson(v)));
tags.removeWhere((k, v) => !TagType.isValid(k));
return tags;
}
bool get markedUnread {
return MarkedUnread.fromJson(
roomAccountData[EventType.MarkedUnread]?.content ?? {})
.unread;
}
/// Returns true if this room is unread
bool get isUnread => notificationCount > 0 || markedUnread;
/// Sets an unread flag manually for this room. Similar to the setUnreadMarker
/// this changes the local account data model before syncing it to make sure
/// this works if there is no connection to the homeserver.
Future setUnread(bool unread) async {
final content = MarkedUnread(unread).toJson();
await _handleFakeSync(SyncUpdate()
..rooms = (RoomsUpdate()
..join = (({}..[id] = (JoinedRoomUpdate()
..accountData = [
BasicRoomEvent()
..content = content
..roomId = id
..type = EventType.MarkedUnread
])))));
await client.setRoomAccountData(
client.userID,
id,
EventType.MarkedUnread,
content,
);
if (unread == false && lastEvent != null) {
await sendReadMarker(
lastEvent.eventId,
readReceiptLocationEventId: lastEvent.eventId,
);
}
}
/// Returns true if this room has a m.favourite tag.
bool get isFavourite => tags[TagType.Favourite] != null;
/// Sets the m.favourite tag for this room.
Future setFavourite(bool favourite) =>
favourite ? addTag(TagType.Favourite) : removeTag(TagType.Favourite);
/// Call the Matrix API to change the pinned events of this room.
Future setPinnedEvents(List pinnedEventIds) =>
client.sendState(
id,
EventTypes.RoomPinnedEvents,
{'pinned': pinnedEventIds},
);
/// return all current emote packs for this room
Map> get emotePacks {
final packs = >{};
final normalizeEmotePackName = (String name) {
name = name.replaceAll(' ', '-');
name = name.replaceAll(RegExp(r'[^\w-]'), '');
return name.toLowerCase();
};
final allMxcs = {}; // for easy dedupint
final addEmotePack = (String packName, Map content,
[String packNameOverride]) {
if (!(content['emoticons'] is Map) && !(content['short'] is Map)) {
return;
}
if (content['pack'] is Map && content['pack']['short'] is String) {
packName = content['pack']['short'];
}
if (packNameOverride != null && packNameOverride.isNotEmpty) {
packName = packNameOverride;
}
packName = normalizeEmotePackName(packName);
if (!packs.containsKey(packName)) {
packs[packName] = {};
}
if (content['emoticons'] is Map) {
content['emoticons'].forEach((key, value) {
if (key is String &&
value is Map &&
value['url'] is String &&
value['url'].startsWith('mxc://')) {
if (allMxcs.add(value['url'])) {
packs[packName][key] = value['url'];
}
}
});
} else {
content['short'].forEach((key, value) {
if (key is String && value is String && value.startsWith('mxc://')) {
if (allMxcs.add(value)) {
packs[packName][key] = value;
}
}
});
}
};
// first add all the user emotes
final userEmotes = client.accountData['im.ponies.user_emotes'];
if (userEmotes != null) {
addEmotePack('user', userEmotes.content);
}
// next add all the external emote rooms
final emoteRooms = client.accountData['im.ponies.emote_rooms'];
if (emoteRooms != null && emoteRooms.content['rooms'] is Map) {
for (final roomEntry in emoteRooms.content['rooms'].entries) {
final roomId = roomEntry.key;
final room = client.getRoomById(roomId);
if (room != null && roomEntry.value is Map) {
for (final stateKeyEntry in roomEntry.value.entries) {
final stateKey = stateKeyEntry.key;
final event = room.getState('im.ponies.room_emotes', stateKey);
if (event != null && stateKeyEntry.value is Map) {
addEmotePack(
(room.canonicalAlias?.isEmpty ?? true)
? room.id
: room.canonicalAlias,
event.content,
stateKeyEntry.value['name']);
}
}
}
}
}
// finally add all the room emotes
final allRoomEmotes = states.states['im.ponies.room_emotes'];
if (allRoomEmotes != null) {
for (final entry in allRoomEmotes.entries) {
final stateKey = entry.key;
final event = entry.value;
addEmotePack(stateKey.isEmpty ? 'room' : stateKey, event.content);
}
}
return packs;
}
/// Sends a normal text message to this room. Returns the event ID generated
/// by the server for this message.
Future sendTextEvent(String message,
{String txid,
Event inReplyTo,
String editEventId,
bool parseMarkdown = true,
Map> emotePacks,
bool parseCommands = true,
String msgtype = MessageTypes.Text}) {
if (parseCommands) {
return client.parseAndRunCommand(this, message,
inReplyTo: inReplyTo, editEventId: editEventId, txid: txid);
}
final event = {
'msgtype': msgtype,
'body': message,
};
if (parseMarkdown) {
final html = markdown(event['body'], emotePacks ?? this.emotePacks);
// if the decoded html is the same as the body, there is no need in sending a formatted message
if (HtmlUnescape().convert(html.replaceAll(RegExp(r'
\n?'), '\n')) !=
event['body']) {
event['format'] = 'org.matrix.custom.html';
event['formatted_body'] = html;
}
}
return sendEvent(event,
txid: txid, inReplyTo: inReplyTo, editEventId: editEventId);
}
/// Sends a reaction to an event with an [eventId] and the content [key] into a room.
/// Returns the event ID generated by the server for this reaction.
Future sendReaction(String eventId, String key, {String txid}) {
return sendEvent({
'm.relates_to': {
'rel_type': RelationshipTypes.Reaction,
'event_id': eventId,
'key': key,
},
}, type: EventTypes.Reaction, txid: txid);
}
/// Sends the location with description [body] and geo URI [geoUri] into a room.
/// Returns the event ID generated by the server for this message.
Future sendLocation(String body, String geoUri, {String txid}) {
final event = {
'msgtype': 'm.location',
'body': body,
'geo_uri': geoUri,
};
return sendEvent(event, txid: txid);
}
/// Sends a [file] to this room after uploading it. Returns the mxc uri of
/// the uploaded file. If [waitUntilSent] is true, the future will wait until
/// the message event has received the server. Otherwise the future will only
/// wait until the file has been uploaded.
Future sendFileEvent(
MatrixFile file, {
String txid,
Event inReplyTo,
String editEventId,
bool waitUntilSent = false,
MatrixImageFile thumbnail,
}) async {
MatrixFile uploadFile = file; // ignore: omit_local_variable_types
MatrixFile uploadThumbnail = thumbnail; // ignore: omit_local_variable_types
EncryptedFile encryptedFile;
EncryptedFile encryptedThumbnail;
if (encrypted && client.fileEncryptionEnabled) {
encryptedFile = await file.encrypt();
uploadFile = encryptedFile.toMatrixFile();
if (thumbnail != null) {
encryptedThumbnail = await thumbnail.encrypt();
uploadThumbnail = encryptedThumbnail.toMatrixFile();
}
}
final uploadResp = await client.upload(
uploadFile.bytes,
uploadFile.name,
contentType: uploadFile.mimeType,
);
final thumbnailUploadResp = uploadThumbnail != null
? await client.upload(
uploadThumbnail.bytes,
uploadThumbnail.name,
contentType: uploadThumbnail.mimeType,
)
: null;
// Send event
var content = {
'msgtype': file.msgType,
'body': file.name,
'filename': file.name,
if (encryptedFile == null) 'url': uploadResp,
if (encryptedFile != null)
'file': {
'url': uploadResp,
'mimetype': file.mimeType,
'v': 'v2',
'key': {
'alg': 'A256CTR',
'ext': true,
'k': encryptedFile.k,
'key_ops': ['encrypt', 'decrypt'],
'kty': 'oct'
},
'iv': encryptedFile.iv,
'hashes': {'sha256': encryptedFile.sha256}
},
'info': {
...file.info,
if (thumbnail != null && encryptedThumbnail == null)
'thumbnail_url': thumbnailUploadResp,
if (thumbnail != null && encryptedThumbnail != null)
'thumbnail_file': {
'url': thumbnailUploadResp,
'mimetype': thumbnail.mimeType,
'v': 'v2',
'key': {
'alg': 'A256CTR',
'ext': true,
'k': encryptedThumbnail.k,
'key_ops': ['encrypt', 'decrypt'],
'kty': 'oct'
},
'iv': encryptedThumbnail.iv,
'hashes': {'sha256': encryptedThumbnail.sha256}
},
if (thumbnail != null) 'thumbnail_info': thumbnail.info,
}
};
final sendResponse = sendEvent(
content,
txid: txid,
inReplyTo: inReplyTo,
editEventId: editEventId,
);
if (waitUntilSent) {
await sendResponse;
}
return uploadResp;
}
Future _sendContent(
String type,
Map content, {
String txid,
}) async {
txid ??= client.generateUniqueTransactionId();
final mustEncrypt = encrypted && client.encryptionEnabled;
final sendMessageContent = mustEncrypt
? await client.encryption
.encryptGroupMessagePayload(id, content, type: type)
: content;
return await client.sendMessage(
id,
mustEncrypt ? EventTypes.Encrypted : type,
txid,
sendMessageContent,
);
}
/// Sends an event to this room with this json as a content. Returns the
/// event ID generated from the server.
Future sendEvent(
Map content, {
String type,
String txid,
Event inReplyTo,
String editEventId,
}) async {
type = type ?? EventTypes.Message;
// Create new transaction id
String messageID;
if (txid == null) {
messageID = client.generateUniqueTransactionId();
} else {
messageID = txid;
}
if (inReplyTo != null) {
var replyText = '<${inReplyTo.senderId}> ' + inReplyTo.body;
var replyTextLines = replyText.split('\n');
for (var i = 0; i < replyTextLines.length; i++) {
replyTextLines[i] = '> ' + replyTextLines[i];
}
replyText = replyTextLines.join('\n');
content['format'] = 'org.matrix.custom.html';
// be sure that we strip any previous reply fallbacks
final replyHtml = (inReplyTo.formattedText.isNotEmpty
? inReplyTo.formattedText
: htmlEscape.convert(inReplyTo.body).replaceAll('\n', '
'))
.replaceAll(
RegExp(r'.*<\/mx-reply>',
caseSensitive: false, multiLine: false, dotAll: true),
'');
final repliedHtml = content.tryGet(
'formatted_body',
htmlEscape
.convert(content.tryGet('body', ''))
.replaceAll('\n', '
'));
content['formatted_body'] =
'In reply to ${inReplyTo.senderId}
$replyHtml
$repliedHtml';
content['body'] = replyText + "\n\n${content.tryGet('body', '')}";
content['m.relates_to'] = {
'm.in_reply_to': {
'event_id': inReplyTo.eventId,
},
};
}
if (editEventId != null) {
final newContent = content.copy();
content['m.new_content'] = newContent;
content['m.relates_to'] = {
'event_id': editEventId,
'rel_type': RelationshipTypes.Edit,
};
if (content['body'] is String) {
content['body'] = '* ' + content['body'];
}
if (content['formatted_body'] is String) {
content['formatted_body'] = '* ' + content['formatted_body'];
}
}
final sentDate = DateTime.now();
final syncUpdate = SyncUpdate()
..rooms = (RoomsUpdate()
..join = ({}..[id] = (JoinedRoomUpdate()
..timeline = (TimelineUpdate()
..events = [
MatrixEvent()
..content = content
..type = type
..eventId = messageID
..senderId = client.userID
..originServerTs = sentDate
..unsigned = {
MessageSendingStatusKey: 0,
'transaction_id': messageID,
},
]))));
await _handleFakeSync(syncUpdate);
// Send the text and on success, store and display a *sent* event.
String res;
while (res == null) {
try {
res = await _sendContent(
type,
content,
txid: messageID,
);
} catch (e, s) {
if ((DateTime.now().millisecondsSinceEpoch -
sentDate.millisecondsSinceEpoch) <
(1000 * client.sendMessageTimeoutSeconds)) {
Logs().w('[Client] Problem while sending message because of "' +
e.toString() +
'". Try again in 1 seconds...');
await Future.delayed(Duration(seconds: 1));
} else {
Logs().w('[Client] Problem while sending message', e, s);
syncUpdate.rooms.join.values.first.timeline.events.first
.unsigned[MessageSendingStatusKey] = -1;
await _handleFakeSync(syncUpdate);
return null;
}
}
}
syncUpdate.rooms.join.values.first.timeline.events.first
.unsigned[MessageSendingStatusKey] = 1;
syncUpdate.rooms.join.values.first.timeline.events.first.eventId = res;
await _handleFakeSync(syncUpdate);
return res;
}
/// Call the Matrix API to join this room if the user is not already a member.
/// If this room is intended to be a direct chat, the direct chat flag will
/// automatically be set.
Future join({bool leaveIfNotFound = true}) async {
try {
await client.joinRoom(id);
final invitation = getState(EventTypes.RoomMember, client.userID);
if (invitation != null &&
invitation.content['is_direct'] is bool &&
invitation.content['is_direct']) {
await addToDirectChat(invitation.sender.id);
}
} on MatrixException catch (exception) {
if (leaveIfNotFound &&
[MatrixError.M_NOT_FOUND, MatrixError.M_UNKNOWN]
.contains(exception.error)) {
await leave();
}
rethrow;
}
return;
}
/// Call the Matrix API to leave this room. If this room is set as a direct
/// chat, this will be removed too.
Future leave() async {
if (directChatMatrixID != '') await removeFromDirectChat();
try {
await client.leaveRoom(id);
} on MatrixException catch (exception) {
if ([MatrixError.M_NOT_FOUND, MatrixError.M_UNKNOWN]
.contains(exception.error)) {
await _handleFakeSync(
SyncUpdate()
..rooms = (RoomsUpdate()
..leave = {
'$id': (LeftRoomUpdate()),
}),
);
}
rethrow;
}
return;
}
/// Call the Matrix API to forget this room if you already left it.
Future forget() async {
await client.database?.forgetRoom(client.id, id);
await client.forgetRoom(id);
return;
}
/// Call the Matrix API to kick a user from this room.
Future kick(String userID) => client.kickFromRoom(id, userID);
/// Call the Matrix API to ban a user from this room.
Future ban(String userID) => client.banFromRoom(id, userID);
/// Call the Matrix API to unban a banned user from this room.
Future unban(String userID) => client.unbanInRoom(id, userID);
/// Set the power level of the user with the [userID] to the value [power].
/// Returns the event ID of the new state event. If there is no known
/// power level event, there might something broken and this returns null.
Future setPower(String userID, int power) async {
if (states[EventTypes.RoomPowerLevels] == null) return null;
final powerMap = {}
..addAll(states[EventTypes.RoomPowerLevels].content);
if (powerMap['users'] == null) powerMap['users'] = {};
powerMap['users'][userID] = power;
return await client.sendState(
id,
EventTypes.RoomPowerLevels,
powerMap,
);
}
/// Call the Matrix API to invite a user to this room.
Future invite(String userID) => client.inviteToRoom(id, userID);
/// Request more previous events from the server. [historyCount] defines how much events should
/// be received maximum. When the request is answered, [onHistoryReceived] will be triggered **before**
/// the historical events will be published in the onEvent stream.
Future requestHistory(
{int historyCount = DefaultHistoryCount, onHistoryReceived}) async {
final resp = await client.requestMessages(
id,
prev_batch,
Direction.b,
limit: historyCount,
filter: Client.messagesFilters,
);
if (onHistoryReceived != null) onHistoryReceived();
prev_batch = resp.end;
final loadFn = () async {
if (!((resp.chunk?.isNotEmpty ?? false) && resp.end != null)) return;
await client.handleSync(
SyncUpdate()
..rooms = (RoomsUpdate()
..join = ({}..[id] = (JoinedRoomUpdate()
..state = resp.state
..timeline = (TimelineUpdate()
..limited = false
..events = resp.chunk
..prevBatch = resp.end)))),
sortAtTheEnd: true);
};
if (client.database != null) {
await client.database.transaction(() async {
await client.database.setRoomPrevBatch(resp.end, client.id, id);
await loadFn();
await updateSortOrder();
});
} else {
await loadFn();
}
}
/// Sets this room as a direct chat for this user if not already.
Future addToDirectChat(String userID) async {
var directChats = client.directChats;
if (directChats[userID] is List) {
if (!directChats[userID].contains(id)) {
directChats[userID].add(id);
} else {
return;
} // Is already in direct chats
} else {
directChats[userID] = [id];
}
await client.setAccountData(
client.userID,
'm.direct',
directChats,
);
return;
}
/// Removes this room from all direct chat tags.
Future removeFromDirectChat() async {
var directChats = client.directChats;
if (directChats[directChatMatrixID] is List &&
directChats[directChatMatrixID].contains(id)) {
directChats[directChatMatrixID].remove(id);
} else {
return;
} // Nothing to do here
await client.setRoomAccountData(
client.userID,
id,
'm.direct',
directChats,
);
return;
}
/// Sets the position of the read marker for a given room, and optionally the
/// read receipt's location.
Future sendReadMarker(String eventId,
{String readReceiptLocationEventId}) async {
if (readReceiptLocationEventId != null) {
notificationCount = 0;
await client.database?.resetNotificationCount(client.id, id);
}
await client.sendReadMarker(
id,
eventId,
readReceiptLocationEventId: readReceiptLocationEventId,
);
return;
}
/// This API updates the marker for the given receipt type to the event ID
/// specified.
Future sendReceiptMarker(String eventId) async {
notificationCount = 0;
await client.database?.resetNotificationCount(client.id, id);
await client.sendReceiptMarker(
id,
eventId,
);
return;
}
/// Sends *m.fully_read* and *m.read* for the given event ID.
@Deprecated('Use sendReadMarker instead')
Future sendReadReceipt(String eventID) async {
notificationCount = 0;
await client.database?.resetNotificationCount(client.id, id);
await client.sendReadMarker(
id,
eventID,
readReceiptLocationEventId: eventID,
);
return;
}
/// Returns a Room from a json String which comes normally from the store. If the
/// state are also given, the method will await them.
static Future getRoomFromTableRow(
// either Map or DbRoom
DbRoom row,
Client matrix, {
dynamic states, // DbRoomState, as iterator and optionally as future
dynamic
roomAccountData, // DbRoomAccountData, as iterator and optionally as future
}) async {
final newRoom = Room(
id: row.roomId,
membership: Membership.values
.firstWhere((e) => e.toString() == 'Membership.' + row.membership),
notificationCount: row.notificationCount,
highlightCount: row.highlightCount,
// TODO: do proper things
notificationSettings: 'mention',
prev_batch: row.prevBatch,
mInvitedMemberCount: row.invitedMemberCount,
mJoinedMemberCount: row.joinedMemberCount,
mHeroes: row.heroes?.split(',') ?? [],
client: matrix,
roomAccountData: {},
newestSortOrder: row.newestSortOrder,
oldestSortOrder: row.oldestSortOrder,
);
if (states != null) {
var rawStates;
if (states is Future) {
rawStates = await states;
} else {
rawStates = states;
}
for (final rawState in rawStates) {
final newState = Event.fromDb(rawState, newRoom);
newRoom.setState(newState);
}
}
var newRoomAccountData = {};
if (roomAccountData != null) {
var rawRoomAccountData;
if (roomAccountData is Future) {
rawRoomAccountData = await roomAccountData;
} else {
rawRoomAccountData = roomAccountData;
}
for (final singleAccountData in rawRoomAccountData) {
final content = Event.getMapFromPayload(singleAccountData.content);
final newData = BasicRoomEvent(
content: content,
type: singleAccountData.type,
roomId: singleAccountData.roomId,
);
newRoomAccountData[newData.type] = newData;
}
}
newRoom.roomAccountData = newRoomAccountData;
return newRoom;
}
/// Creates a timeline from the store. Returns a [Timeline] object.
Future getTimeline(
{onTimelineUpdateCallback onUpdate,
onTimelineInsertCallback onInsert}) async {
await postLoad();
var events;
if (client.database != null) {
events = await client.database.getEventList(client.id, this);
} else {
events = [];
}
// Try again to decrypt encrypted events and update the database.
if (encrypted && client.database != null && client.encryptionEnabled) {
await client.database.transaction(() async {
for (var i = 0; i < events.length; i++) {
if (events[i].type == EventTypes.Encrypted &&
events[i].content['can_request_session'] == true) {
events[i] = await client.encryption
.decryptRoomEvent(id, events[i], store: true);
}
}
});
}
var timeline = Timeline(
room: this,
events: events,
onUpdate: onUpdate,
onInsert: onInsert,
);
if (client.database == null) {
prev_batch = '';
await requestHistory(historyCount: 10);
}
return timeline;
}
/// Returns all participants for this room. With lazy loading this
/// list may not be complete. User [requestParticipants] in this
/// case.
List getParticipants() {
var userList = [];
if (states[EventTypes.RoomMember] is Map) {
for (var entry in states[EventTypes.RoomMember].entries) {
Event state = entry.value;
if (state.type == EventTypes.RoomMember) userList.add(state.asUser);
}
}
return userList;
}
bool _requestedParticipants = false;
/// Request the full list of participants from the server. The local list
/// from the store is not complete if the client uses lazy loading.
Future> requestParticipants() async {
if (!participantListComplete && partial && client.database != null) {
// we aren't fully loaded, maybe the users are in the database
final users = await client.database.getUsers(client.id, this);
for (final user in users) {
setState(user);
}
}
if (_requestedParticipants || participantListComplete) {
return getParticipants();
}
final matrixEvents = await client.requestMembers(id);
final users =
matrixEvents.map((e) => Event.fromMatrixEvent(e, this).asUser).toList();
for (final user in users) {
setState(user); // at *least* cache this in-memory
}
_requestedParticipants = true;
users.removeWhere(
(u) => [Membership.leave, Membership.ban].contains(u.membership));
return users;
}
/// Checks if the local participant list of joined and invited users is complete.
bool get participantListComplete {
var knownParticipants = getParticipants();
knownParticipants.removeWhere(
(u) => ![Membership.join, Membership.invite].contains(u.membership));
return knownParticipants.length ==
(mJoinedMemberCount ?? 0) + (mInvitedMemberCount ?? 0);
}
/// Returns the [User] object for the given [mxID] or requests it from
/// the homeserver and waits for a response.
Future getUserByMXID(String mxID) async {
if (states[mxID] != null) return states[mxID].asUser;
return requestUser(mxID);
}
/// Returns the [User] object for the given [mxID] or requests it from
/// the homeserver and returns a default [User] object while waiting.
User getUserByMXIDSync(String mxID) {
if (states[mxID] != null) {
return states[mxID].asUser;
} else {
requestUser(mxID, ignoreErrors: true);
return User(mxID, room: this);
}
}
final Set _requestingMatrixIds = {};
/// Requests a missing [User] for this room. Important for clients using
/// lazy loading. If the user can't be found this method tries to fetch
/// the displayname and avatar from the profile if [requestProfile] is true.
Future requestUser(
String mxID, {
bool ignoreErrors = false,
bool requestProfile = true,
}) async {
if (getState(EventTypes.RoomMember, mxID) != null) {
return getState(EventTypes.RoomMember, mxID).asUser;
}
if (client.database != null) {
// it may be in the database
final user = await client.database.getUser(client.id, mxID, this);
if (user != null) {
setState(user);
if (onUpdate != null) onUpdate.add(id);
return user;
}
}
if (mxID == null || !_requestingMatrixIds.add(mxID)) return null;
Map resp;
try {
resp = await client.requestStateContent(
id,
EventTypes.RoomMember,
mxID,
);
} catch (exception) {
if (!ignoreErrors) {
_requestingMatrixIds.remove(mxID);
rethrow;
}
}
if (resp == null && requestProfile) {
try {
final profile = await client.requestProfile(mxID);
resp = {
'displayname': profile.displayname,
'avatar_url': profile.avatarUrl.toString(),
};
} catch (exception) {
_requestingMatrixIds.remove(mxID);
if (!ignoreErrors) rethrow;
}
}
if (resp == null) {
return null;
}
final user = User(mxID,
displayName: resp['displayname'],
avatarUrl: resp['avatar_url'],
room: this);
states[mxID] = user;
await client.database?.transaction(() async {
final content = {
'sender': mxID,
'type': EventTypes.RoomMember,
'content': resp,
'state_key': mxID,
};
await client.database.storeEventUpdate(
client.id,
EventUpdate(
content: content,
roomID: id,
type: EventUpdateType.state,
eventType: EventTypes.RoomMember,
sortOrder: 0.0),
);
});
if (onUpdate != null) onUpdate.add(id);
_requestingMatrixIds.remove(mxID);
return user;
}
/// Searches for the event on the server. Returns null if not found.
Future getEventById(String eventID) async {
try {
final matrixEvent = await client.requestEvent(id, eventID);
final event = Event.fromMatrixEvent(matrixEvent, this);
if (event.type == EventTypes.Encrypted && client.encryptionEnabled) {
// attempt decryption
return await client.encryption
.decryptRoomEvent(id, event, store: false);
}
return event;
} on MatrixException catch (err) {
if (err.errcode == 'M_NOT_FOUND') {
return null;
}
rethrow;
}
}
/// Returns the power level of the given user ID.
int getPowerLevelByUserId(String userId) {
var powerLevel = 0;
Event powerLevelState = states[EventTypes.RoomPowerLevels];
if (powerLevelState == null) return powerLevel;
if (powerLevelState.content['users_default'] is int) {
powerLevel = powerLevelState.content['users_default'];
}
if (powerLevelState.content
.tryGet