matrix-0.8.13/lib/src/room.dart

2101 lines
71 KiB
Dart
Raw Permalink Normal View History

2022-04-18 08:57:08 +00:00
/*
* 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:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:matrix/src/utils/crypto/crypto.dart';
import 'package:matrix/src/utils/space_child.dart';
import 'package:matrix/widget.dart';
import '../matrix.dart';
import 'utils/markdown.dart';
import 'utils/marked_unread.dart';
/// https://github.com/matrix-org/matrix-doc/pull/2746
/// version 1
const String voipProtoVersion = '1';
enum PushRuleState { notify, mentionsOnly, dontNotify }
enum JoinRules { public, knock, invite, private }
enum GuestAccess { canJoin, forbidden }
enum HistoryVisibility { invited, joined, shared, worldReadable }
const Map<GuestAccess, String> _guestAccessMap = {
GuestAccess.canJoin: 'can_join',
GuestAccess.forbidden: 'forbidden',
};
const Map<HistoryVisibility, String> _historyVisibilityMap = {
HistoryVisibility.invited: 'invited',
HistoryVisibility.joined: 'joined',
HistoryVisibility.shared: 'shared',
HistoryVisibility.worldReadable: 'world_readable',
};
const String messageSendingStatusKey =
'com.famedly.famedlysdk.message_sending_status';
const String sortOrderKey = 'com.famedly.famedlysdk.sort_order';
/// 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;
RoomSummary summary;
@deprecated
List<String>? get mHeroes => summary.mHeroes;
@deprecated
int? get mJoinedMemberCount => summary.mJoinedMemberCount;
@deprecated
int? get mInvitedMemberCount => summary.mInvitedMemberCount;
/// The room states are a key value store of the key (`type`,`state_key`) => State(event).
/// In a lot of cases the `state_key` might be an empty string. You **should** use the
/// methods `getState()` and `setState()` to interact with the room states.
Map<String, Map<String, Event>> states = {};
/// Key-Value store for ephemerals.
Map<String, BasicRoomEvent> ephemerals = {};
/// Key-Value store for private account data only visible for this user.
Map<String, BasicRoomEvent> roomAccountData = {};
Map<String, dynamic> toJson() => {
'id': id,
'membership': membership.toString().split('.').last,
'highlight_count': highlightCount,
'notification_count': notificationCount,
'prev_batch': prev_batch,
'summary': summary.toJson(),
'newest_sort_order': 0,
'oldest_sort_order': 0,
};
factory Room.fromJson(Map<String, dynamic> json, Client client) => Room(
client: client,
id: json['id'],
membership: Membership.values.singleWhere(
(m) => m.toString() == 'Membership.${json['membership']}',
orElse: () => Membership.join,
),
notificationCount: json['notification_count'],
highlightCount: json['highlight_count'],
prev_batch: json['prev_batch'],
summary:
RoomSummary.fromJson(Map<String, dynamic>.from(json['summary'])),
newestSortOrder: json['newest_sort_order'].toDouble(),
oldestSortOrder: json['oldest_sort_order'].toDouble(),
);
/// 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<void> postLoad() async {
if (!partial) {
return;
}
final allStates = await client.database
?.getUnimportantRoomEventStatesForRoom(
client.importantStateEvents.toList(), this);
if (allStates != null) {
for (final state in allStates) {
setState(state);
}
}
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[typeKey]?[stateKey];
/// 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) ?? state;
} catch (e, s) {
Logs().e('[LibOlm] Could not decrypt room state', e, s);
}
}
// We ignore room verification events for lastEvents
if (state.type == EventTypes.Message &&
state.messageType.startsWith('m.room.verification.')) {
return;
}
final isMessageEvent = client.roomPreviewLastEvents.contains(state.type);
// We ignore events editing events older than the current-latest here so
// i.e. newly sent edits for older events don't show up in room preview
final lastEvent = this.lastEvent;
if (isMessageEvent &&
state.relationshipEventId != null &&
state.relationshipType == RelationshipTypes.edit &&
lastEvent != null &&
!state.matchesEventOrTransactionId(lastEvent.eventId) &&
lastEvent.eventId != state.relationshipEventId &&
!(lastEvent.relationshipType == RelationshipTypes.edit &&
lastEvent.relationshipEventId == state.relationshipEventId)) {
return;
}
// Ignore other non-state events
final stateKey = isMessageEvent ? '' : state.stateKey;
final roomId = state.roomId;
if (stateKey == null || roomId == null) {
return;
}
// Do not set old events as state events
final prevEvent = getState(state.type, stateKey);
if (prevEvent != null &&
prevEvent.eventId != state.eventId &&
prevEvent.originServerTs.millisecondsSinceEpoch >
state.originServerTs.millisecondsSinceEpoch) {
return;
}
(states[state.type] ??= {})[stateKey] = state;
}
/// ID of the fully read marker event.
String get fullyRead =>
roomAccountData['m.fully_read']?.content['event_id'] ?? '';
/// If something changes, this callback will be triggered. Will return the
/// room id.
final StreamController<String> onUpdate = StreamController.broadcast();
/// If there is a new session key received, this will be triggered with
/// the session ID.
final StreamController<String> onSessionKeyReceived =
StreamController.broadcast();
/// The name of the room if set by a participant.
String get name {
final n = getState(EventTypes.RoomName)?.content['name'];
return (n is String) ? n : '';
}
/// The pinned events for this room. If there are none this returns an empty
/// list.
List<String> get pinnedEventIds {
final pinned = getState(EventTypes.RoomPinnedEvents)?.content['pinned'];
return pinned is Iterable ? pinned.map((e) => e.toString()).toList() : [];
}
/// 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 &&
canonicalAlias.isEmpty &&
!isDirectChat &&
(summary.mHeroes != null && summary.mHeroes?.isNotEmpty == true)) {
return i18n.groupWith(displayname);
}
if (displayname.isNotEmpty) {
return displayname;
}
return i18n.emptyChat;
}
/// The topic of the room if set by a participant.
String get topic {
final t = getState(EventTypes.RoomTopic)?.content['topic'];
return t is String ? t : '';
}
/// The avatar of the room if set by a participant.
Uri? get avatar {
final avatarUrl = getState(EventTypes.RoomAvatar)?.content['url'];
if (avatarUrl is String) {
return Uri.tryParse(avatarUrl);
}
final heroes = summary.mHeroes;
if (heroes != null && heroes.length == 1) {
final hero = getState(EventTypes.RoomMember, heroes.first);
if (hero != null) {
return hero.asUser.avatarUrl;
}
}
if (isDirectChat) {
final user = directChatMatrixID;
if (user != null) {
return getUserByMXIDSync(user).avatarUrl;
}
}
if (membership == Membership.invite) {
return getState(EventTypes.RoomMember, client.userID!)?.sender.avatarUrl;
}
return null;
}
/// The address in the format: #roomname:homeserver.org.
String get canonicalAlias {
final alias = getState(EventTypes.RoomCanonicalAlias)?.content['alias'];
return (alias is String) ? alias : '';
}
/// Sets the canonical alias. If the [canonicalAlias] is not yet an alias of
/// this room, it will create one.
Future<void> setCanonicalAlias(String canonicalAlias) async {
final aliases = await client.getLocalAliases(id);
if (!aliases.contains(canonicalAlias)) {
await client.setRoomAlias(canonicalAlias, id);
}
await client.setRoomStateWithKey(id, EventTypes.RoomCanonicalAlias, '', {
'alias': canonicalAlias,
});
}
/// If this room is a direct chat, this is the matrix ID of the user.
/// Returns null otherwise.
String? get directChatMatrixID {
if (membership == Membership.invite) {
final invitation = getState(EventTypes.RoomMember, client.userID!);
if (invitation != null && invitation.content['is_direct'] == true) {
return invitation.senderId;
}
}
return client.directChats.entries
.firstWhereOrNull((MapEntry<String, dynamic> e) {
final roomIds = e.value;
return roomIds is List<dynamic> && roomIds.contains(id);
})?.key;
}
/// 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 =
client.roomPreviewLastEvents.map(getState).whereType<Event>();
var lastEvent = lastEvents.isEmpty
? null
: lastEvents.reduce((a, b) {
if (a.originServerTs == b.originServerTs) {
// 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.originServerTs.millisecondsSinceEpoch >
b.originServerTs.millisecondsSinceEpoch
? a
: b;
});
if (lastEvent == null) {
states.forEach((final String key, final entry) {
final state = entry[''];
if (state == null) return;
if (state.originServerTs.millisecondsSinceEpoch >
lastTime.millisecondsSinceEpoch) {
lastTime = state.originServerTs;
lastEvent = state;
}
});
}
return lastEvent;
}
/// Returns a list of all current typing users.
List<User> get typingUsers {
final typingMxid = ephemerals['m.typing']?.content['user_ids'];
return (typingMxid is List)
? typingMxid.cast<String>().map(getUserByMXIDSync).toList()
: [];
}
/// Returns all present Widgets in the room.
List<MatrixWidget> get widgets => {
...states['m.widget'] ?? {},
...states['im.vector.modular.widgets'] ?? {},
}.values.expand((e) {
try {
return [MatrixWidget.fromJson(e.content, this)];
} catch (_) {
return <MatrixWidget>[];
}
}).toList();
/// Your current client instance.
final Client client;
Room({
required this.id,
this.membership = Membership.join,
this.notificationCount = 0,
this.highlightCount = 0,
this.prev_batch,
required this.client,
this.notificationSettings,
Map<String, BasicRoomEvent>? roomAccountData,
double newestSortOrder = 0.0,
double oldestSortOrder = 0.0,
RoomSummary? summary,
}) : roomAccountData = roomAccountData ?? <String, BasicRoomEvent>{},
summary = summary ??
RoomSummary.fromJson({
'm.joined_member_count': 0,
'm.invited_member_count': 0,
'm.heroes': [],
});
/// The default count of how much events should be requested when requesting the
/// history of this room.
static const int defaultHistoryCount = 30;
/// 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.isNotEmpty) return name;
final canonicalAlias = this.canonicalAlias.localpart;
if (canonicalAlias != null && canonicalAlias.isNotEmpty) {
return canonicalAlias;
}
final heroes = summary.mHeroes;
if (heroes != null && heroes.isNotEmpty) {
return heroes
.where((hero) => hero.isNotEmpty)
.map((hero) => getUserByMXIDSync(hero).calcDisplayname())
.join(', ');
}
if (isDirectChat) {
final user = directChatMatrixID;
if (user != null) {
return getUserByMXIDSync(user).calcDisplayname();
}
}
if (membership == Membership.invite) {
final sender = getState(EventTypes.RoomMember, client.userID!)
?.sender
.calcDisplayname();
if (sender != null) return sender;
}
return 'Empty chat';
}
@Deprecated('Use [lastEvent.body] instead')
String get lastMessage => lastEvent?.body ?? '';
/// When the last message received.
DateTime get timeCreated => lastEvent?.originServerTs ?? 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<String> setName(String newName) => client.setRoomStateWithKey(
id,
EventTypes.RoomName,
'',
{'name': newName},
);
/// Call the Matrix API to change the topic of this room.
Future<String> setDescription(String newName) => client.setRoomStateWithKey(
id,
EventTypes.RoomTopic,
'',
{'topic': newName},
);
/// Add a tag to the room.
Future<void> addTag(String tag, {double? order}) => client.setRoomTag(
client.userID!,
id,
tag,
order: order,
);
/// Removes a tag from the room.
Future<void> removeTag(String tag) => client.deleteRoomTag(
client.userID!,
id,
tag,
);
// Tag is part of client-to-server-API, so it uses strict parsing.
// For roomAccountData, permissive parsing is more suitable,
// so it is implemented here.
static Tag _tryTagFromJson(Object o) {
if (o is Map<String, dynamic>) {
return Tag(
order: o.tryGet<num>('order', TryGet.silent)?.toDouble(),
additionalProperties: Map.from(o)..remove('order'));
}
return Tag();
}
/// Returns all tags for this room.
Map<String, Tag> get tags {
final tags = roomAccountData['m.tag']?.content['tags'];
if (tags is Map) {
final parsedTags =
tags.map((k, v) => MapEntry<String, Tag>(k, _tryTagFromJson(v)));
parsedTags.removeWhere((k, v) => !TagType.isValid(k));
return parsedTags;
}
return {};
}
bool get markedUnread {
return MarkedUnread.fromJson(
roomAccountData[EventType.markedUnread]?.content ?? {})
.unread;
}
/// Checks if the last event has a read marker of the user.
/// Warning: This compares the origin server timestamp which might not map
/// to the real sort order of the timeline.
bool get hasNewMessages {
final lastEvent = this.lastEvent;
// There is no known event or the last event is only a state fallback event,
// we assume there is no new messages.
if (lastEvent == null ||
!client.roomPreviewLastEvents.contains(lastEvent.type)) return false;
// Read marker is on the last event so no new messages.
if (lastEvent.receipts
.any((receipt) => receipt.user.senderId == client.userID!)) {
return false;
}
// If the last event is sent, we mark the room as read.
if (lastEvent.senderId == client.userID) return false;
// Get the timestamp of read marker and compare
final readAtMilliseconds = roomAccountData['m.receipt']
?.content
.tryGetMap<String, dynamic>(client.userID!)
?.tryGet<int>('ts') ??
0;
return readAtMilliseconds < lastEvent.originServerTs.millisecondsSinceEpoch;
}
/// Returns true if this room is unread. To check if there are new messages
/// in muted rooms, use [hasNewMessages].
bool get isUnread => notificationCount > 0 || markedUnread;
@Deprecated('Use [markUnread] instead')
Future<void> setUnread(bool unread) => markUnread(unread);
/// Sets an unread flag manually for this room. This changes the local account
/// data model before syncing it to make sure
/// this works if there is no connection to the homeserver. This does **not**
/// set a read marker!
Future<void> markUnread(bool unread) async {
final content = MarkedUnread(unread).toJson();
await _handleFakeSync(
SyncUpdate(
nextBatch: '',
rooms: RoomsUpdate(
join: {
id: JoinedRoomUpdate(
accountData: [
BasicRoomEvent(
content: content,
roomId: id,
type: EventType.markedUnread,
),
],
)
},
),
),
);
await client.setAccountDataPerRoom(
client.userID!,
id,
EventType.markedUnread,
content,
);
}
/// Returns true if this room has a m.favourite tag.
bool get isFavourite =>
tags[TagType.favourite] != null ||
(client.pinInvitedRooms && membership == Membership.invite);
/// Sets the m.favourite tag for this room.
Future<void> setFavourite(bool favourite) =>
favourite ? addTag(TagType.favourite) : removeTag(TagType.favourite);
/// Call the Matrix API to change the pinned events of this room.
Future<String> setPinnedEvents(List<String> pinnedEventIds) =>
client.setRoomStateWithKey(
id,
EventTypes.RoomPinnedEvents,
'',
{'pinned': pinnedEventIds},
);
/// return all current emote packs for this room
@deprecated
Map<String, Map<String, String>> get emotePacks =>
getImagePacksFlat(ImagePackUsage.emoticon);
/// returns the resolved mxid for a mention string, or null if none found
String? getMention(String mention) => getParticipants()
.firstWhereOrNull((u) => u.mentionFragments.contains(mention))
?.id;
/// Sends a normal text message to this room. Returns the event ID generated
/// by the server for this message.
Future<String?> sendTextEvent(String message,
{String? txid,
Event? inReplyTo,
String? editEventId,
bool parseMarkdown = true,
@deprecated Map<String, Map<String, String>>? emotePacks,
bool parseCommands = true,
String msgtype = MessageTypes.Text}) {
if (parseCommands) {
return client.parseAndRunCommand(this, message,
inReplyTo: inReplyTo, editEventId: editEventId, txid: txid);
}
final event = <String, dynamic>{
'msgtype': msgtype,
'body': message,
};
if (parseMarkdown) {
final html = markdown(event['body'],
getEmotePacks: () => getImagePacksFlat(ImagePackUsage.emoticon),
getMention: getMention);
// 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'<br />\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<String?> 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<String?> sendLocation(String body, String geoUri, {String? txid}) {
final event = <String, dynamic>{
'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.
/// Optionally specify [extraContent] to tack on to the event.
///
/// In case [file] is a [MatrixImageFile], [thumbnail] is automatically
/// computed unless it is explicitly provided.
Future<Uri> sendFileEvent(
MatrixFile file, {
String? txid,
Event? inReplyTo,
String? editEventId,
bool waitUntilSent = false,
MatrixImageFile? thumbnail,
Map<String, dynamic>? extraContent,
}) async {
MatrixFile uploadFile = file; // ignore: omit_local_variable_types
// computing the thumbnail in case we can
thumbnail ??= (file is MatrixImageFile && encrypted
? await file.generateThumbnail(compute: client.runInBackground)
: null);
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.uploadContent(
uploadFile.bytes,
filename: uploadFile.name,
contentType: uploadFile.mimeType,
);
final thumbnailUploadResp = uploadThumbnail != null
? await client.uploadContent(
uploadThumbnail.bytes,
filename: uploadThumbnail.name,
contentType: uploadThumbnail.mimeType,
)
: null;
// Send event
final content = <String, dynamic>{
'msgtype': file.msgType,
'body': file.name,
'filename': file.name,
if (encryptedFile == null) 'url': uploadResp.toString(),
if (encryptedFile != null)
'file': {
'url': uploadResp.toString(),
'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.toString(),
if (thumbnail != null && encryptedThumbnail != null)
'thumbnail_file': {
'url': thumbnailUploadResp.toString(),
'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,
},
if (extraContent != null) ...extraContent,
};
final sendResponse = sendEvent(
content,
txid: txid,
inReplyTo: inReplyTo,
editEventId: editEventId,
);
if (waitUntilSent) {
await sendResponse;
}
return uploadResp;
}
Future<String?> _sendContent(
String type,
Map<String, dynamic> 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,
sendMessageContent.containsKey('ciphertext')
? EventTypes.Encrypted
: type,
txid,
sendMessageContent,
);
}
String _stripBodyFallback(String body) {
if (body.startsWith('> <@')) {
var temp = '';
var inPrefix = true;
for (final l in body.split('\n')) {
if (inPrefix && (l.isEmpty || l.startsWith('> '))) {
continue;
}
inPrefix = false;
temp += temp.isEmpty ? l : ('\n' + l);
}
return temp;
} else {
return body;
}
}
/// Sends an event to this room with this json as a content. Returns the
/// event ID generated from the server.
Future<String?> sendEvent(
Map<String, dynamic> content, {
String type = EventTypes.Message,
String? txid,
Event? inReplyTo,
String? editEventId,
}) async {
// Create new transaction id
String messageID;
if (txid == null) {
messageID = client.generateUniqueTransactionId();
} else {
messageID = txid;
}
if (inReplyTo != null) {
var replyText =
'<${inReplyTo.senderId}> ' + _stripBodyFallback(inReplyTo.body);
replyText = replyText.split('\n').map((line) => '> $line').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', '<br>'))
.replaceAll(
RegExp(r'<mx-reply>.*<\/mx-reply>',
caseSensitive: false, multiLine: false, dotAll: true),
'');
final repliedHtml = content.tryGet<String>('formatted_body') ??
htmlEscape
.convert(content.tryGet<String>('body') ?? '')
.replaceAll('\n', '<br>');
content['formatted_body'] =
'<mx-reply><blockquote><a href="https://matrix.to/#/${inReplyTo.roomId!}/${inReplyTo.eventId}">In reply to</a> <a href="https://matrix.to/#/${inReplyTo.senderId}">${inReplyTo.senderId}</a><br>$replyHtml</blockquote></mx-reply>$repliedHtml';
// We escape all @room-mentions here to prevent accidental room pings when an admin
// replies to a message containing that!
content['body'] =
'${replyText.replaceAll('@room', '@\u200broom')}\n\n${content.tryGet<String>('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(
nextBatch: '',
rooms: RoomsUpdate(
join: {
id: JoinedRoomUpdate(
timeline: TimelineUpdate(
events: [
MatrixEvent(
content: content,
type: type,
eventId: messageID,
senderId: client.userID!,
originServerTs: sentDate,
unsigned: {
messageSendingStatusKey: EventStatus.sending.intValue,
'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] = EventStatus.error.intValue;
await _handleFakeSync(syncUpdate);
return null;
}
}
}
syncUpdate.rooms!.join!.values.first.timeline!.events!.first
.unsigned![messageSendingStatusKey] = EventStatus.sent.intValue;
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<void> join({bool leaveIfNotFound = true}) async {
try {
await client.joinRoomById(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<void> 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(
nextBatch: '',
rooms: RoomsUpdate(
leave: {
id: LeftRoomUpdate(),
},
),
),
);
}
rethrow;
}
return;
}
/// Call the Matrix API to forget this room if you already left it.
Future<void> forget() async {
await client.database?.forgetRoom(id);
await client.forgetRoom(id);
return;
}
/// Call the Matrix API to kick a user from this room.
Future<void> kick(String userID) => client.kick(id, userID);
/// Call the Matrix API to ban a user from this room.
Future<void> ban(String userID) => client.ban(id, userID);
/// Call the Matrix API to unban a banned user from this room.
Future<void> unban(String userID) => client.unban(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<String> setPower(String userID, int power) async {
var powerMap = getState(EventTypes.RoomPowerLevels)?.content;
if (!(powerMap is Map<String, dynamic>)) {
powerMap = <String, dynamic>{};
}
(powerMap['users'] ??= {})[userID] = power;
return await client.setRoomStateWithKey(
id,
EventTypes.RoomPowerLevels,
'',
powerMap,
);
}
/// Call the Matrix API to invite a user to this room.
Future<void> invite(String userID) => client.inviteUser(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.
/// Returns the actual count of received timeline events.
Future<int> requestHistory(
{int historyCount = defaultHistoryCount,
void Function()? onHistoryReceived}) async {
final prev_batch = this.prev_batch;
if (prev_batch == null) {
throw 'Tried to request history without a prev_batch token';
}
final resp = await client.getRoomEvents(
id,
prev_batch,
Direction.b,
limit: historyCount,
filter: jsonEncode(StateFilter(lazyLoadMembers: true).toJson()),
);
if (onHistoryReceived != null) onHistoryReceived();
this.prev_batch = resp.end;
final loadFn = () async {
if (!((resp.chunk?.isNotEmpty ?? false) && resp.end != null)) return;
await client.handleSync(
SyncUpdate(
nextBatch: '',
rooms: RoomsUpdate(
join: membership == Membership.join
? {
id: JoinedRoomUpdate(
state: resp.state,
timeline: TimelineUpdate(
limited: false,
events: resp.chunk,
prevBatch: resp.end,
),
)
}
: null,
leave: membership != Membership.join
? {
id: LeftRoomUpdate(
state: resp.state,
timeline: TimelineUpdate(
limited: false,
events: resp.chunk,
prevBatch: resp.end,
),
),
}
: null),
),
sortAtTheEnd: true);
};
if (client.database != null) {
await client.database?.transaction(() async {
await client.database?.setRoomPrevBatch(resp.end!, id, client);
await loadFn();
});
} else {
await loadFn();
}
return resp.chunk?.length ?? 0;
}
/// Sets this room as a direct chat for this user if not already.
Future<void> addToDirectChat(String userID) async {
final 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<void> removeFromDirectChat() async {
final directChats = client.directChats;
if (directChats[directChatMatrixID] is List &&
directChats[directChatMatrixID].contains(id)) {
directChats[directChatMatrixID].remove(id);
} else {
return;
} // Nothing to do here
await client.setAccountDataPerRoom(
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<void> setReadMarker(String eventId, {String? mRead}) async {
if (mRead != null) {
notificationCount = 0;
await client.database?.resetNotificationCount(id);
}
await client.setReadMarker(
id,
eventId,
mRead: mRead,
);
return;
}
/// This API updates the marker for the given receipt type to the event ID
/// specified.
Future<void> postReceipt(String eventId) async {
notificationCount = 0;
await client.database?.resetNotificationCount(id);
await client.postReceipt(
id,
ReceiptType.mRead,
eventId,
{},
);
return;
}
/// Sends *m.fully_read* and *m.read* for the given event ID.
@Deprecated('Use sendReadMarker instead')
Future<void> sendReadReceipt(String eventID) async {
notificationCount = 0;
await client.database?.resetNotificationCount(id);
await client.setReadMarker(
id,
eventID,
mRead: eventID,
);
return;
}
/// Creates a timeline from the store. Returns a [Timeline] object. If you
/// just want to update the whole timeline on every change, use the [onUpdate]
/// callback. For updating only the parts that have changed, use the
/// [onChange], [onRemove], [onInsert] and the [onHistoryReceived] callbacks.
Future<Timeline> getTimeline({
void Function(int index)? onChange,
void Function(int index)? onRemove,
void Function(int insertID)? onInsert,
void Function()? onUpdate,
}) async {
await postLoad();
var events;
events = await client.database?.getEventList(
this,
limit: defaultHistoryCount,
) ??
<Event>[];
// 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);
}
}
});
}
final timeline = Timeline(
room: this,
events: events,
onChange: onChange,
onRemove: onRemove,
onInsert: onInsert,
onUpdate: onUpdate,
);
if (client.database == null) {
await requestHistory(historyCount: 10);
}
return timeline;
}
/// Returns all participants for this room. With lazy loading this
/// list may not be complete. Use [requestParticipants] in this
/// case.
/// List `membershipFilter` defines with what membership do you want the
/// participants, default set to
/// [[Membership.join, Membership.invite, Membership.knock]]
List<User> getParticipants(
[List<Membership> membershipFilter = const [
Membership.join,
Membership.invite,
Membership.knock,
]]) {
final members = states[EventTypes.RoomMember];
if (members != null) {
return members.entries
.where((entry) => entry.value.type == EventTypes.RoomMember)
.map((entry) => entry.value.asUser)
.where((user) => membershipFilter.contains(user.membership))
.toList();
}
return <User>[];
}
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.
/// List `membershipFilter` defines with what membership do you want the
/// participants, default set to
/// [[Membership.join, Membership.invite, Membership.knock]]
Future<List<User>> requestParticipants(
[List<Membership> membershipFilter = const [
Membership.join,
Membership.invite,
Membership.knock,
]]) async {
if (!participantListComplete && partial) {
// we aren't fully loaded, maybe the users are in the database
final users = await client.database?.getUsers(this) ?? [];
for (final user in users) {
setState(user);
}
}
if (_requestedParticipants || participantListComplete) {
return getParticipants();
}
final matrixEvents = await client.getMembersByRoom(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) => !membershipFilter.contains(u.membership));
return users;
}
/// Checks if the local participant list of joined and invited users is complete.
bool get participantListComplete {
final knownParticipants = getParticipants();
knownParticipants.removeWhere(
(u) => ![Membership.join, Membership.invite].contains(u.membership));
return knownParticipants.length ==
(summary.mJoinedMemberCount ?? 0) + (summary.mInvitedMemberCount ?? 0);
}
/// Returns the [User] object for the given [mxID] or requests it from
/// the homeserver and waits for a response.
@Deprecated('Use [requestUser] instead')
Future<User?> getUserByMXID(String mxID) async =>
getState(EventTypes.RoomMember, mxID)?.asUser ?? await 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) {
final user = getState(EventTypes.RoomMember, mxID);
if (user != null) {
return user.asUser;
} else {
requestUser(mxID, ignoreErrors: true);
return User(mxID, room: this);
}
}
final Set<String> _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<User?> requestUser(
String mxID, {
bool ignoreErrors = false,
bool requestProfile = true,
}) async {
// Checks if the user is really missing
final stateUser = getState(EventTypes.RoomMember, mxID);
if (stateUser != null) {
return stateUser.asUser;
}
// it may be in the database
final dbuser = await client.database?.getUser(mxID, this);
if (dbuser != null) {
setState(dbuser);
onUpdate.add(id);
return dbuser;
}
if (!_requestingMatrixIds.add(mxID)) return null;
Map<String, dynamic>? resp;
try {
Logs().v(
'Request missing user $mxID in room $displayname from the server...');
resp = await client.getRoomStateWithKey(
id,
EventTypes.RoomMember,
mxID,
);
} catch (e, s) {
if (!ignoreErrors) {
_requestingMatrixIds.remove(mxID);
rethrow;
} else {
Logs().w('Unable to request the user $mxID from the server', e, s);
}
}
if (resp == null && requestProfile) {
try {
final profile = await client.getUserProfile(mxID);
resp = {
'displayname': profile.displayname,
'avatar_url': profile.avatarUrl.toString(),
};
} catch (e, s) {
_requestingMatrixIds.remove(mxID);
if (!ignoreErrors) {
rethrow;
} else {
Logs().w('Unable to request the profile $mxID from the server', e, s);
}
}
}
if (resp == null) {
return null;
}
final user = User(mxID,
displayName: resp['displayname'],
avatarUrl: resp['avatar_url'],
room: this);
setState(user);
await client.database?.transaction(() async {
final fakeEventId = String.fromCharCodes(
await sha256(
Uint8List.fromList(
(id + mxID + client.generateUniqueTransactionId()).codeUnits),
),
);
await client.database?.storeEventUpdate(
EventUpdate(
content: MatrixEvent(
type: EventTypes.RoomMember,
content: resp!,
stateKey: mxID,
originServerTs: DateTime.now(),
senderId: mxID,
eventId: fakeEventId,
).toJson(),
roomID: id,
type: EventUpdateType.state,
),
client,
);
});
onUpdate.add(id);
_requestingMatrixIds.remove(mxID);
return user;
}
/// Searches for the event on the server. Returns null if not found.
Future<Event?> getEventById(String eventID) async {
try {
final matrixEvent = await client.getOneRoomEvent(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;
final powerLevelState = getState(EventTypes.RoomPowerLevels);
if (powerLevelState == null) return powerLevel;
if (powerLevelState.content['users_default'] is int) {
powerLevel = powerLevelState.content['users_default'];
}
if (powerLevelState.content
.tryGet<Map<String, dynamic>>('users')
?.tryGet<int>(userId) !=
null) {
powerLevel = powerLevelState.content['users'][userId];
}
return powerLevel;
}
/// Returns the user's own power level.
int get ownPowerLevel => getPowerLevelByUserId(client.userID!);
/// Returns the power levels from all users for this room or null if not given.
Map<String, int>? get powerLevels {
final powerLevelState =
getState(EventTypes.RoomPowerLevels)?.content['users'];
return (powerLevelState is Map<String, int>) ? powerLevelState : null;
}
/// Uploads a new user avatar for this room. Returns the event ID of the new
/// m.room.avatar event. Leave empty to remove the current avatar.
Future<String> setAvatar(MatrixFile? file) async {
final uploadResp = file == null
? null
: await client.uploadContent(file.bytes, filename: file.name);
return await client.setRoomStateWithKey(
id,
EventTypes.RoomAvatar,
'',
{
if (uploadResp != null) 'url': uploadResp.toString(),
},
);
}
bool _hasPermissionFor(String action) {
final pl = getState(EventTypes.RoomPowerLevels)?.content[action];
if (pl == null) {
return true;
}
return ownPowerLevel >= pl;
}
/// The level required to ban a user.
bool get canBan => _hasPermissionFor('ban');
/// The default level required to send message events. Can be overridden by the events key.
bool get canSendDefaultMessages =>
_hasPermissionFor('events_default') &&
(!encrypted || client.encryptionEnabled);
/// The level required to invite a user.
bool get canInvite => _hasPermissionFor('invite');
/// The level required to kick a user.
bool get canKick => _hasPermissionFor('kick');
/// The level required to redact an event.
bool get canRedact => _hasPermissionFor('redact');
/// The default level required to send state events. Can be overridden by the events key.
bool get canSendDefaultStates => _hasPermissionFor('state_default');
bool get canChangePowerLevel => canSendEvent(EventTypes.RoomPowerLevels);
bool canSendEvent(String eventType) {
final pl =
getState(EventTypes.RoomPowerLevels)?.content['events']?[eventType];
if (pl == null) {
return eventType == EventTypes.Message
? canSendDefaultMessages
: canSendDefaultStates;
}
return ownPowerLevel >= pl;
}
/// Returns the [PushRuleState] for this room, based on the m.push_rules stored in
/// the account_data.
PushRuleState get pushRuleState {
final globalPushRules =
client.accountData['m.push_rules']?.content['global'];
if (!(globalPushRules is Map)) {
return PushRuleState.notify;
}
if (globalPushRules['override'] is List) {
for (final pushRule in globalPushRules['override']) {
if (pushRule['rule_id'] == id) {
if (pushRule['actions'].indexOf('dont_notify') != -1) {
return PushRuleState.dontNotify;
}
break;
}
}
}
if (globalPushRules['room'] is List) {
for (final pushRule in globalPushRules['room']) {
if (pushRule['rule_id'] == id) {
if (pushRule['actions'].indexOf('dont_notify') != -1) {
return PushRuleState.mentionsOnly;
}
break;
}
}
}
return PushRuleState.notify;
}
/// Sends a request to the homeserver to set the [PushRuleState] for this room.
/// Returns ErrorResponse if something goes wrong.
Future<void> setPushRuleState(PushRuleState newState) async {
if (newState == pushRuleState) return null;
dynamic resp;
switch (newState) {
// All push notifications should be sent to the user
case PushRuleState.notify:
if (pushRuleState == PushRuleState.dontNotify) {
await client.deletePushRule('global', PushRuleKind.override, id);
} else if (pushRuleState == PushRuleState.mentionsOnly) {
await client.deletePushRule('global', PushRuleKind.room, id);
}
break;
// Only when someone mentions the user, a push notification should be sent
case PushRuleState.mentionsOnly:
if (pushRuleState == PushRuleState.dontNotify) {
await client.deletePushRule('global', PushRuleKind.override, id);
await client.setPushRule(
'global',
PushRuleKind.room,
id,
[PushRuleAction.dontNotify],
);
} else if (pushRuleState == PushRuleState.notify) {
await client.setPushRule(
'global',
PushRuleKind.room,
id,
[PushRuleAction.dontNotify],
);
}
break;
// No push notification should be ever sent for this room.
case PushRuleState.dontNotify:
if (pushRuleState == PushRuleState.mentionsOnly) {
await client.deletePushRule('global', PushRuleKind.room, id);
}
await client.setPushRule(
'global',
PushRuleKind.override,
id,
[PushRuleAction.dontNotify],
conditions: [
PushCondition(kind: 'event_match', key: 'room_id', pattern: id)
],
);
}
return resp;
}
/// Redacts this event. Throws `ErrorResponse` on error.
Future<String?> redactEvent(String eventId,
{String? reason, String? txid}) async {
// Create new transaction id
String messageID;
final now = DateTime.now().millisecondsSinceEpoch;
if (txid == null) {
messageID = 'msg$now';
} else {
messageID = txid;
}
final data = <String, dynamic>{};
if (reason != null) data['reason'] = reason;
return await client.redactEvent(
id,
eventId,
messageID,
reason: reason,
);
}
/// This tells the server that the user is typing for the next N milliseconds
/// where N is the value specified in the timeout key. Alternatively, if typing is false,
/// it tells the server that the user has stopped typing.
Future<void> setTyping(bool isTyping, {int? timeout}) =>
client.setTyping(client.userID!, id, isTyping, timeout: timeout);
@Deprecated('Use sendTypingNotification instead')
Future<void> sendTypingInfo(bool isTyping, {int? timeout}) =>
setTyping(isTyping, timeout: timeout);
/// This is sent by the caller when they wish to establish a call.
/// [callId] is a unique identifier for the call.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [lifetime] is the time in milliseconds that the invite is valid for. Once the invite age exceeds this value,
/// clients should discard it. They should also no longer show the call as awaiting an answer in the UI.
/// [type] The type of session description. Must be 'offer'.
/// [sdp] The SDP text of the session description.
/// [invitee] The user ID of the person who is being invited. Invites without an invitee field are defined to be
/// intended for any member of the room other than the sender of the event.
/// [party_id] The party ID for call, Can be set to client.deviceId.
Future<String?> inviteToCall(
String callId, int lifetime, String party_id, String? invitee, String sdp,
{String type = 'offer',
String version = voipProtoVersion,
String? txid,
CallCapabilities? capabilities,
SDPStreamMetadata? metadata}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
'lifetime': lifetime,
'offer': {'sdp': sdp, 'type': type},
if (invitee != null) 'invitee': invitee,
if (capabilities != null) 'capabilities': capabilities.toJson(),
if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
};
return await _sendContent(
EventTypes.CallInvite,
content,
txid: txid,
);
}
/// The calling party sends the party_id of the first selected answer.
///
/// Usually after receiving the first answer sdp in the client.onCallAnswer event,
/// save the `party_id`, and then send `CallSelectAnswer` to others peers that the call has been picked up.
///
/// [callId] is a unique identifier for the call.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [party_id] The party ID for call, Can be set to client.deviceId.
/// [selected_party_id] The party ID for the selected answer.
Future<String?> selectCallAnswer(
String callId, int lifetime, String party_id, String selected_party_id,
{String version = voipProtoVersion, String? txid}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
'lifetime': lifetime,
'selected_party_id': selected_party_id,
};
return await _sendContent(
EventTypes.CallSelectAnswer,
content,
txid: txid,
);
}
/// Reject a call
/// [callId] is a unique identifier for the call.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [party_id] The party ID for call, Can be set to client.deviceId.
Future<String?> sendCallReject(String callId, int lifetime, String party_id,
{String version = voipProtoVersion, String? txid}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
'lifetime': lifetime,
};
return await _sendContent(
EventTypes.CallReject,
content,
txid: txid,
);
}
/// When local audio/video tracks are added/deleted or hold/unhold,
/// need to createOffer and renegotiation.
/// [callId] is a unique identifier for the call.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [party_id] The party ID for call, Can be set to client.deviceId.
Future<String?> sendCallNegotiate(
String callId, int lifetime, String party_id, String sdp,
{String type = 'offer',
String version = voipProtoVersion,
String? txid,
CallCapabilities? capabilities,
SDPStreamMetadata? metadata}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
'lifetime': lifetime,
'description': {'sdp': sdp, 'type': type},
if (capabilities != null) 'capabilities': capabilities.toJson(),
if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
};
return await _sendContent(
EventTypes.CallNegotiate,
content,
txid: txid,
);
}
/// This is sent by callers after sending an invite and by the callee after answering.
/// Its purpose is to give the other party additional ICE candidates to try using to communicate.
///
/// [callId] The ID of the call this event relates to.
///
/// [version] The version of the VoIP specification this messages adheres to. This specification is version 1.
///
/// [party_id] The party ID for call, Can be set to client.deviceId.
///
/// [candidates] Array of objects describing the candidates. Example:
///
/// ```
/// [
/// {
/// "candidate": "candidate:863018703 1 udp 2122260223 10.9.64.156 43670 typ host generation 0",
/// "sdpMLineIndex": 0,
/// "sdpMid": "audio"
/// }
/// ],
/// ```
Future<String?> sendCallCandidates(
String callId,
String party_id,
List<Map<String, dynamic>> candidates, {
String version = voipProtoVersion,
String? txid,
}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
'candidates': candidates,
};
return await _sendContent(
EventTypes.CallCandidates,
content,
txid: txid,
);
}
/// This event is sent by the callee when they wish to answer the call.
/// [callId] is a unique identifier for the call.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [type] The type of session description. Must be 'answer'.
/// [sdp] The SDP text of the session description.
/// [party_id] The party ID for call, Can be set to client.deviceId.
Future<String?> answerCall(String callId, String sdp, String party_id,
{String type = 'answer',
String version = voipProtoVersion,
String? txid,
CallCapabilities? capabilities,
SDPStreamMetadata? metadata}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
'answer': {'sdp': sdp, 'type': type},
if (capabilities != null) 'capabilities': capabilities.toJson(),
if (metadata != null) sdpStreamMetadataKey: metadata.toJson(),
};
return await _sendContent(
EventTypes.CallAnswer,
content,
txid: txid,
);
}
/// This event is sent by the callee when they wish to answer the call.
/// [callId] The ID of the call this event relates to.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [party_id] The party ID for call, Can be set to client.deviceId.
Future<String?> hangupCall(
String callId, String party_id, String? hangupCause,
{String version = voipProtoVersion, String? txid}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
if (hangupCause != null) 'reason': hangupCause,
};
return await _sendContent(
EventTypes.CallHangup,
content,
txid: txid,
);
}
/// Send SdpStreamMetadata Changed event.
///
/// This MSC also adds a new call event m.call.sdp_stream_metadata_changed,
/// which has the common VoIP fields as specified in
/// MSC2746 (version, call_id, party_id) and a sdp_stream_metadata object which
/// is the same thing as sdp_stream_metadata in m.call.negotiate, m.call.invite
/// and m.call.answer. The client sends this event the when sdp_stream_metadata
/// has changed but no negotiation is required
/// (e.g. the user mutes their camera/microphone).
///
/// [callId] The ID of the call this event relates to.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [party_id] The party ID for call, Can be set to client.deviceId.
/// [metadata] The sdp_stream_metadata object.
Future<String?> sendSDPStreamMetadataChanged(
String callId, String party_id, SDPStreamMetadata metadata,
{String version = voipProtoVersion, String? txid}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
sdpStreamMetadataKey: metadata.toJson(),
};
return await _sendContent(
EventTypes.CallSDPStreamMetadataChangedPrefix,
content,
txid: txid,
);
}
/// CallReplacesEvent for Transfered calls
///
/// [callId] The ID of the call this event relates to.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [party_id] The party ID for call, Can be set to client.deviceId.
/// [callReplaces] transfer info
Future<String?> sendCallReplaces(
String callId, String party_id, CallReplaces callReplaces,
{String version = voipProtoVersion, String? txid}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
...callReplaces.toJson(),
};
return await _sendContent(
EventTypes.CallReplaces,
content,
txid: txid,
);
}
/// send AssertedIdentity event
///
/// [callId] The ID of the call this event relates to.
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 1.
/// [party_id] The party ID for call, Can be set to client.deviceId.
/// [assertedIdentity] the asserted identity
Future<String?> sendAssertedIdentity(
String callId, String party_id, AssertedIdentity assertedIdentity,
{String version = voipProtoVersion, String? txid}) async {
txid ??= 'txid${DateTime.now().millisecondsSinceEpoch}';
final content = {
'call_id': callId,
'party_id': party_id,
'version': version,
'asserted_identity': assertedIdentity.toJson(),
};
return await _sendContent(
EventTypes.CallAssertedIdentity,
content,
txid: txid,
);
}
/// A room may be public meaning anyone can join the room without any prior action. Alternatively,
/// it can be invite meaning that a user who wishes to join the room must first receive an invite
/// to the room from someone already inside of the room. Currently, knock and private are reserved
/// keywords which are not implemented.
JoinRules? get joinRules {
final joinRule = getState(EventTypes.RoomJoinRules)?.content['join_rule'];
return joinRule != null
? JoinRules.values.firstWhereOrNull(
(r) => r.toString().replaceAll('JoinRules.', '') == joinRule)
: null;
}
/// Changes the join rules. You should check first if the user is able to change it.
Future<void> setJoinRules(JoinRules joinRules) async {
await client.setRoomStateWithKey(
id,
EventTypes.RoomJoinRules,
'',
{
'join_rule': joinRules.toString().replaceAll('JoinRules.', ''),
},
);
return;
}
/// Whether the user has the permission to change the join rules.
bool get canChangeJoinRules => canSendEvent(EventTypes.RoomJoinRules);
/// This event controls whether guest users are allowed to join rooms. If this event
/// is absent, servers should act as if it is present and has the guest_access value "forbidden".
GuestAccess get guestAccess {
final ga = getState(EventTypes.GuestAccess)?.content['guest_access'];
return ga != null
? (_guestAccessMap.map((k, v) => MapEntry(v, k))[ga] ??
GuestAccess.forbidden)
: GuestAccess.forbidden;
}
/// Changes the guest access. You should check first if the user is able to change it.
Future<void> setGuestAccess(GuestAccess guestAccess) async {
await client.setRoomStateWithKey(
id,
EventTypes.GuestAccess,
'',
{
'guest_access': _guestAccessMap[guestAccess],
},
);
return;
}
/// Whether the user has the permission to change the guest access.
bool get canChangeGuestAccess => canSendEvent(EventTypes.GuestAccess);
/// This event controls whether a user can see the events that happened in a room from before they joined.
HistoryVisibility? get historyVisibility {
final hv =
getState(EventTypes.HistoryVisibility)?.content['history_visibility'];
return hv != null
? _historyVisibilityMap.map((k, v) => MapEntry(v, k))[hv]
: null;
}
/// Changes the history visibility. You should check first if the user is able to change it.
Future<void> setHistoryVisibility(HistoryVisibility historyVisibility) async {
await client.setRoomStateWithKey(
id,
EventTypes.HistoryVisibility,
'',
{
'history_visibility': _historyVisibilityMap[historyVisibility],
},
);
return;
}
/// Whether the user has the permission to change the history visibility.
bool get canChangeHistoryVisibility =>
canSendEvent(EventTypes.HistoryVisibility);
/// Returns the encryption algorithm. Currently only `m.megolm.v1.aes-sha2` is supported.
/// Returns null if there is no encryption algorithm.
String? get encryptionAlgorithm =>
getState(EventTypes.Encryption)?.parsedRoomEncryptionContent.algorithm;
/// Checks if this room is encrypted.
bool get encrypted => encryptionAlgorithm != null;
Future<void> enableEncryption({int algorithmIndex = 0}) async {
if (encrypted) throw ('Encryption is already enabled!');
final algorithm = Client.supportedGroupEncryptionAlgorithms[algorithmIndex];
await client.setRoomStateWithKey(
id,
EventTypes.Encryption,
'',
{
'algorithm': algorithm,
},
);
return;
}
/// Returns all known device keys for all participants in this room.
Future<List<DeviceKeys>> getUserDeviceKeys() async {
await client.userDeviceKeysLoading;
final deviceKeys = <DeviceKeys>[];
final users = await requestParticipants();
for (final user in users) {
final userDeviceKeys = client.userDeviceKeys[user.id]?.deviceKeys.values;
if ([Membership.invite, Membership.join].contains(user.membership) &&
userDeviceKeys != null) {
for (final deviceKeyEntry in userDeviceKeys) {
deviceKeys.add(deviceKeyEntry);
}
}
}
return deviceKeys;
}
Future<void> requestSessionKey(String sessionId, String senderKey) async {
if (!client.encryptionEnabled) {
return;
}
await client.encryption?.keyManager.request(this, sessionId, senderKey);
}
Future<void> _handleFakeSync(SyncUpdate syncUpdate,
{bool sortAtTheEnd = false}) async {
if (client.database != null) {
await client.database?.transaction(() async {
await client.handleSync(syncUpdate, sortAtTheEnd: sortAtTheEnd);
});
} else {
await client.handleSync(syncUpdate, sortAtTheEnd: sortAtTheEnd);
}
}
/// Whether this is an extinct room which has been archived in favor of a new
/// room which replaces this. Use `getLegacyRoomInformations()` to get more
/// informations about it if this is true.
bool get isExtinct => getState(EventTypes.RoomTombstone) != null;
/// Returns informations about how this room is
TombstoneContent? get extinctInformations =>
getState(EventTypes.RoomTombstone)?.parsedTombstoneContent;
/// Checks if the `m.room.create` state has a `type` key with the value
/// `m.space`.
bool get isSpace =>
getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
RoomCreationTypes.mSpace; // TODO: Magic string!
/// The parents of this room. Currently this SDK doesn't yet set the canonical
/// flag and is not checking if this room is in fact a child of this space.
/// You should therefore not rely on this and always check the children of
/// the space.
List<SpaceParent> get spaceParents =>
states[EventTypes.spaceParent]
?.values
.map((state) => SpaceParent.fromState(state))
.where((child) => child.via?.isNotEmpty ?? false)
.toList() ??
[];
/// List all children of this space. Children without a `via` domain will be
/// ignored.
/// Children are sorted by the `order` while those without this field will be
/// sorted at the end of the list.
List<SpaceChild> get spaceChildren => !isSpace
? throw Exception('Room is not a space!')
: (states[EventTypes.spaceChild]
?.values
.map((state) => SpaceChild.fromState(state))
.where((child) => child.via?.isNotEmpty ?? false)
.toList() ??
[])
..sort((a, b) => a.order.isEmpty || b.order.isEmpty
? b.order.compareTo(a.order)
: a.order.compareTo(b.order));
/// Adds or edits a child of this space.
Future<void> setSpaceChild(
String roomId, {
List<String>? via,
String? order,
bool? suggested,
}) async {
if (!isSpace) throw Exception('Room is not a space!');
via ??= [client.userID!.domain!];
await client.setRoomStateWithKey(id, EventTypes.spaceChild, roomId, {
'via': via,
if (order != null) 'order': order,
if (suggested != null) 'suggested': suggested,
});
await client.setRoomStateWithKey(roomId, EventTypes.spaceParent, id, {
'via': via,
});
return;
}
/// Remove a child from this space by setting the `via` to an empty list.
Future<void> removeSpaceChild(String roomId) => !isSpace
? throw Exception('Room is not a space!')
: setSpaceChild(roomId, via: const []);
@override
bool operator ==(dynamic other) => (other is Room && other.id == id);
}