Line data Source code
1 : /*
2 : * Famedly Matrix SDK
3 : * Copyright (C) 2019, 2020, 2021 Famedly GmbH
4 : *
5 : * This program is free software: you can redistribute it and/or modify
6 : * it under the terms of the GNU Affero General Public License as
7 : * published by the Free Software Foundation, either version 3 of the
8 : * License, or (at your option) any later version.
9 : *
10 : * This program is distributed in the hope that it will be useful,
11 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 : * GNU Affero General Public License for more details.
14 : *
15 : * You should have received a copy of the GNU Affero General Public License
16 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 : */
18 :
19 : import 'dart:async';
20 : import 'dart:convert';
21 : import 'dart:math';
22 :
23 : import 'package:async/async.dart';
24 : import 'package:collection/collection.dart';
25 : import 'package:html_unescape/html_unescape.dart';
26 :
27 : import 'package:matrix/matrix.dart';
28 : import 'package:matrix/src/models/timeline_chunk.dart';
29 : import 'package:matrix/src/utils/cached_stream_controller.dart';
30 : import 'package:matrix/src/utils/file_send_request_credentials.dart';
31 : import 'package:matrix/src/utils/markdown.dart';
32 : import 'package:matrix/src/utils/marked_unread.dart';
33 : import 'package:matrix/src/utils/space_child.dart';
34 :
35 : /// max PDU size for server to accept the event with some buffer incase the server adds unsigned data f.ex age
36 : /// https://spec.matrix.org/v1.9/client-server-api/#size-limits
37 : const int maxPDUSize = 60000;
38 :
39 : const String messageSendingStatusKey =
40 : 'com.famedly.famedlysdk.message_sending_status';
41 :
42 : const String fileSendingStatusKey =
43 : 'com.famedly.famedlysdk.file_sending_status';
44 :
45 : /// Represents a Matrix room.
46 : class Room {
47 : /// The full qualified Matrix ID for the room in the format '!localid:server.abc'.
48 : final String id;
49 :
50 : /// Membership status of the user for this room.
51 : Membership membership;
52 :
53 : /// The count of unread notifications.
54 : int notificationCount;
55 :
56 : /// The count of highlighted notifications.
57 : int highlightCount;
58 :
59 : /// A token that can be supplied to the from parameter of the rooms/{roomId}/messages endpoint.
60 : String? prev_batch;
61 :
62 : RoomSummary summary;
63 :
64 : /// The room states are a key value store of the key (`type`,`state_key`) => State(event).
65 : /// In a lot of cases the `state_key` might be an empty string. You **should** use the
66 : /// methods `getState()` and `setState()` to interact with the room states.
67 : Map<String, Map<String, StrippedStateEvent>> states = {};
68 :
69 : /// Key-Value store for ephemerals.
70 : Map<String, BasicRoomEvent> ephemerals = {};
71 :
72 : /// Key-Value store for private account data only visible for this user.
73 : Map<String, BasicRoomEvent> roomAccountData = {};
74 :
75 : final _sendingQueue = <Completer>[];
76 :
77 : Timer? _clearTypingIndicatorTimer;
78 :
79 62 : Map<String, dynamic> toJson() => {
80 31 : 'id': id,
81 124 : 'membership': membership.toString().split('.').last,
82 31 : 'highlight_count': highlightCount,
83 31 : 'notification_count': notificationCount,
84 31 : 'prev_batch': prev_batch,
85 62 : 'summary': summary.toJson(),
86 61 : 'last_event': lastEvent?.toJson(),
87 : };
88 :
89 12 : factory Room.fromJson(Map<String, dynamic> json, Client client) {
90 12 : final room = Room(
91 : client: client,
92 12 : id: json['id'],
93 12 : membership: Membership.values.singleWhere(
94 60 : (m) => m.toString() == 'Membership.${json['membership']}',
95 0 : orElse: () => Membership.join,
96 : ),
97 12 : notificationCount: json['notification_count'],
98 12 : highlightCount: json['highlight_count'],
99 12 : prev_batch: json['prev_batch'],
100 36 : summary: RoomSummary.fromJson(Map<String, dynamic>.from(json['summary'])),
101 : );
102 12 : if (json['last_event'] != null) {
103 33 : room.lastEvent = Event.fromJson(json['last_event'], room);
104 : }
105 : return room;
106 : }
107 :
108 : /// Flag if the room is partial, meaning not all state events have been loaded yet
109 : bool partial = true;
110 :
111 : /// Post-loads the room.
112 : /// This load all the missing state events for the room from the database
113 : /// If the room has already been loaded, this does nothing.
114 5 : Future<void> postLoad() async {
115 5 : if (!partial) {
116 : return;
117 : }
118 10 : final allStates = await client.database
119 5 : ?.getUnimportantRoomEventStatesForRoom(
120 15 : client.importantStateEvents.toList(), this);
121 :
122 : if (allStates != null) {
123 8 : for (final state in allStates) {
124 3 : setState(state);
125 : }
126 : }
127 5 : partial = false;
128 : }
129 :
130 : /// Returns the [Event] for the given [typeKey] and optional [stateKey].
131 : /// If no [stateKey] is provided, it defaults to an empty string.
132 : /// This returns either a `StrippedStateEvent` for rooms with membership
133 : /// "invite" or a `User`/`Event`. If you need additional information like
134 : /// the Event ID or originServerTs you need to do a type check like:
135 : /// ```dart
136 : /// if (state is Event) { /*...*/ }
137 : /// ```
138 33 : StrippedStateEvent? getState(String typeKey, [String stateKey = '']) =>
139 97 : states[typeKey]?[stateKey];
140 :
141 : /// Adds the [state] to this room and overwrites a state with the same
142 : /// typeKey/stateKey key pair if there is one.
143 33 : void setState(StrippedStateEvent state) {
144 : // Ignore other non-state events
145 33 : final stateKey = state.stateKey;
146 :
147 : // For non invite rooms this is usually an Event and we should validate
148 : // the room ID:
149 33 : if (state is Event) {
150 33 : final roomId = state.roomId;
151 66 : if (roomId != id) {
152 0 : Logs().wtf('Tried to set state event for wrong room!');
153 0 : assert(roomId == id);
154 : return;
155 : }
156 : }
157 :
158 : if (stateKey == null) {
159 6 : Logs().w(
160 6 : 'Tried to set a non state event with type "${state.type}" as state event for a room',
161 : );
162 3 : assert(stateKey != null);
163 : return;
164 : }
165 :
166 165 : (states[state.type] ??= {})[stateKey] = state;
167 :
168 132 : client.onRoomState.add((roomId: id, state: state));
169 : }
170 :
171 : /// ID of the fully read marker event.
172 3 : String get fullyRead =>
173 10 : roomAccountData['m.fully_read']?.content.tryGet<String>('event_id') ?? '';
174 :
175 : /// If something changes, this callback will be triggered. Will return the
176 : /// room id.
177 : @Deprecated('Use `client.onSync` instead and filter for this room ID')
178 : final CachedStreamController<String> onUpdate = CachedStreamController();
179 :
180 : /// If there is a new session key received, this will be triggered with
181 : /// the session ID.
182 : final CachedStreamController<String> onSessionKeyReceived =
183 : CachedStreamController();
184 :
185 : /// The name of the room if set by a participant.
186 8 : String get name {
187 20 : final n = getState(EventTypes.RoomName)?.content['name'];
188 8 : return (n is String) ? n : '';
189 : }
190 :
191 : /// The pinned events for this room. If there are none this returns an empty
192 : /// list.
193 2 : List<String> get pinnedEventIds {
194 6 : final pinned = getState(EventTypes.RoomPinnedEvents)?.content['pinned'];
195 12 : return pinned is Iterable ? pinned.map((e) => e.toString()).toList() : [];
196 : }
197 :
198 : /// Returns the heroes as `User` objects.
199 : /// This is very useful if you want to make sure that all users are loaded
200 : /// from the database, that you need to correctly calculate the displayname
201 : /// and the avatar of the room.
202 2 : Future<List<User>> loadHeroUsers() async {
203 : // For invite rooms request own user and invitor.
204 4 : if (membership == Membership.invite) {
205 0 : final ownUser = await requestUser(client.userID!, requestProfile: false);
206 0 : if (ownUser != null) await requestUser(ownUser.senderId);
207 : }
208 :
209 4 : var heroes = summary.mHeroes;
210 : if (heroes == null) {
211 0 : final directChatMatrixID = this.directChatMatrixID;
212 : if (directChatMatrixID != null) {
213 0 : heroes = [directChatMatrixID];
214 : }
215 : }
216 :
217 0 : if (heroes == null) return [];
218 :
219 6 : return await Future.wait(heroes.map((hero) async =>
220 2 : (await requestUser(
221 : hero,
222 : ignoreErrors: true,
223 : )) ??
224 0 : User(hero, room: this)));
225 : }
226 :
227 : /// Returns a localized displayname for this server. If the room is a groupchat
228 : /// without a name, then it will return the localized version of 'Group with Alice' instead
229 : /// of just 'Alice' to make it different to a direct chat.
230 : /// Empty chats will become the localized version of 'Empty Chat'.
231 : /// Please note, that necessary room members are lazy loaded. To be sure
232 : /// that you have the room members, call and await `Room.loadHeroUsers()`
233 : /// before.
234 : /// This method requires a localization class which implements [MatrixLocalizations]
235 4 : String getLocalizedDisplayname([
236 : MatrixLocalizations i18n = const MatrixDefaultLocalizations(),
237 : ]) {
238 10 : if (name.isNotEmpty) return name;
239 :
240 8 : final canonicalAlias = this.canonicalAlias.localpart;
241 2 : if (canonicalAlias != null && canonicalAlias.isNotEmpty) {
242 : return canonicalAlias;
243 : }
244 :
245 4 : final directChatMatrixID = this.directChatMatrixID;
246 8 : final heroes = summary.mHeroes ??
247 0 : (directChatMatrixID == null ? [] : [directChatMatrixID]);
248 4 : if (heroes.isNotEmpty) {
249 : final result = heroes
250 2 : .where(
251 : // removing oneself from the hero list
252 10 : (hero) => hero.isNotEmpty && hero != client.userID,
253 : )
254 6 : .map((hero) => unsafeGetUserFromMemoryOrFallback(hero)
255 2 : .calcDisplayname(i18n: i18n))
256 2 : .join(', ');
257 2 : if (isAbandonedDMRoom) {
258 0 : return i18n.wasDirectChatDisplayName(result);
259 : }
260 :
261 4 : return isDirectChat ? result : i18n.groupWith(result);
262 : }
263 4 : if (membership == Membership.invite) {
264 0 : final ownMember = unsafeGetUserFromMemoryOrFallback(client.userID!);
265 :
266 0 : if (ownMember.senderId != ownMember.stateKey) {
267 0 : return i18n.invitedBy(
268 0 : unsafeGetUserFromMemoryOrFallback(ownMember.senderId)
269 0 : .calcDisplayname(i18n: i18n),
270 : );
271 : }
272 : }
273 4 : if (membership == Membership.leave) {
274 : if (directChatMatrixID != null) {
275 0 : return i18n.wasDirectChatDisplayName(
276 0 : unsafeGetUserFromMemoryOrFallback(directChatMatrixID)
277 0 : .calcDisplayname(i18n: i18n));
278 : }
279 : }
280 2 : return i18n.emptyChat;
281 : }
282 :
283 : /// The topic of the room if set by a participant.
284 2 : String get topic {
285 6 : final t = getState(EventTypes.RoomTopic)?.content['topic'];
286 2 : return t is String ? t : '';
287 : }
288 :
289 : /// The avatar of the room if set by a participant.
290 : /// Please note, that necessary room members are lazy loaded. To be sure
291 : /// that you have the room members, call and await `Room.loadHeroUsers()`
292 : /// before.
293 4 : Uri? get avatar {
294 : // Check content of `m.room.avatar`
295 : final avatarUrl =
296 8 : getState(EventTypes.RoomAvatar)?.content.tryGet<String>('url');
297 : if (avatarUrl != null) {
298 2 : return Uri.tryParse(avatarUrl);
299 : }
300 :
301 : // Room has no avatar and is not a direct chat
302 4 : final directChatMatrixID = this.directChatMatrixID;
303 : if (directChatMatrixID != null) {
304 0 : return unsafeGetUserFromMemoryOrFallback(directChatMatrixID).avatarUrl;
305 : }
306 :
307 : return null;
308 : }
309 :
310 : /// The address in the format: #roomname:homeserver.org.
311 5 : String get canonicalAlias {
312 11 : final alias = getState(EventTypes.RoomCanonicalAlias)?.content['alias'];
313 5 : return (alias is String) ? alias : '';
314 : }
315 :
316 : /// Sets the canonical alias. If the [canonicalAlias] is not yet an alias of
317 : /// this room, it will create one.
318 0 : Future<void> setCanonicalAlias(String canonicalAlias) async {
319 0 : final aliases = await client.getLocalAliases(id);
320 0 : if (!aliases.contains(canonicalAlias)) {
321 0 : await client.setRoomAlias(canonicalAlias, id);
322 : }
323 0 : await client.setRoomStateWithKey(id, EventTypes.RoomCanonicalAlias, '', {
324 : 'alias': canonicalAlias,
325 : });
326 : }
327 :
328 : String? _cachedDirectChatMatrixId;
329 :
330 : /// If this room is a direct chat, this is the matrix ID of the user.
331 : /// Returns null otherwise.
332 33 : String? get directChatMatrixID {
333 : // Calculating the directChatMatrixId can be expensive. We cache it and
334 : // validate the cache instead every time.
335 33 : final cache = _cachedDirectChatMatrixId;
336 : if (cache != null) {
337 12 : final roomIds = client.directChats[cache];
338 12 : if (roomIds is List && roomIds.contains(id)) {
339 : return cache;
340 : }
341 : }
342 :
343 66 : if (membership == Membership.invite) {
344 0 : final userID = client.userID;
345 : if (userID == null) return null;
346 0 : final invitation = getState(EventTypes.RoomMember, userID);
347 0 : if (invitation != null && invitation.content['is_direct'] == true) {
348 0 : return _cachedDirectChatMatrixId = invitation.senderId;
349 : }
350 : }
351 :
352 99 : final mxId = client.directChats.entries
353 46 : .firstWhereOrNull((MapEntry<String, dynamic> e) {
354 13 : final roomIds = e.value;
355 39 : return roomIds is List<dynamic> && roomIds.contains(id);
356 6 : })?.key;
357 43 : if (mxId?.isValidMatrixId == true) return _cachedDirectChatMatrixId = mxId;
358 33 : return _cachedDirectChatMatrixId = null;
359 : }
360 :
361 : /// Wheither this is a direct chat or not
362 66 : bool get isDirectChat => directChatMatrixID != null;
363 :
364 : Event? lastEvent;
365 :
366 32 : void setEphemeral(BasicRoomEvent ephemeral) {
367 96 : ephemerals[ephemeral.type] = ephemeral;
368 64 : if (ephemeral.type == 'm.typing') {
369 32 : _clearTypingIndicatorTimer?.cancel();
370 140 : _clearTypingIndicatorTimer = Timer(client.typingIndicatorTimeout, () {
371 24 : ephemerals.remove('m.typing');
372 : });
373 : }
374 : }
375 :
376 : /// Returns a list of all current typing users.
377 1 : List<User> get typingUsers {
378 4 : final typingMxid = ephemerals['m.typing']?.content['user_ids'];
379 1 : return (typingMxid is List)
380 : ? typingMxid
381 1 : .cast<String>()
382 2 : .map(unsafeGetUserFromMemoryOrFallback)
383 1 : .toList()
384 0 : : [];
385 : }
386 :
387 : /// Your current client instance.
388 : final Client client;
389 :
390 36 : Room({
391 : required this.id,
392 : this.membership = Membership.join,
393 : this.notificationCount = 0,
394 : this.highlightCount = 0,
395 : this.prev_batch,
396 : required this.client,
397 : Map<String, BasicRoomEvent>? roomAccountData,
398 : RoomSummary? summary,
399 : this.lastEvent,
400 36 : }) : roomAccountData = roomAccountData ?? <String, BasicRoomEvent>{},
401 : summary = summary ??
402 72 : RoomSummary.fromJson({
403 : 'm.joined_member_count': 0,
404 : 'm.invited_member_count': 0,
405 36 : 'm.heroes': [],
406 : });
407 :
408 : /// The default count of how much events should be requested when requesting the
409 : /// history of this room.
410 : static const int defaultHistoryCount = 30;
411 :
412 : /// Checks if this is an abandoned DM room where the other participant has
413 : /// left the room. This is false when there are still other users in the room
414 : /// or the room is not marked as a DM room.
415 2 : bool get isAbandonedDMRoom {
416 2 : final directChatMatrixID = this.directChatMatrixID;
417 :
418 : if (directChatMatrixID == null) return false;
419 : final dmPartnerMembership =
420 0 : unsafeGetUserFromMemoryOrFallback(directChatMatrixID).membership;
421 0 : return dmPartnerMembership == Membership.leave &&
422 0 : summary.mJoinedMemberCount == 1 &&
423 0 : summary.mInvitedMemberCount == 0;
424 : }
425 :
426 : /// Calculates the displayname. First checks if there is a name, then checks for a canonical alias and
427 : /// then generates a name from the heroes.
428 0 : @Deprecated('Use `getLocalizedDisplayname()` instead')
429 0 : String get displayname => getLocalizedDisplayname();
430 :
431 : /// When the last message received.
432 128 : DateTime get timeCreated => lastEvent?.originServerTs ?? DateTime.now();
433 :
434 : /// Call the Matrix API to change the name of this room. Returns the event ID of the
435 : /// new m.room.name event.
436 6 : Future<String> setName(String newName) => client.setRoomStateWithKey(
437 2 : id,
438 : EventTypes.RoomName,
439 : '',
440 2 : {'name': newName},
441 : );
442 :
443 : /// Call the Matrix API to change the topic of this room.
444 6 : Future<String> setDescription(String newName) => client.setRoomStateWithKey(
445 2 : id,
446 : EventTypes.RoomTopic,
447 : '',
448 2 : {'topic': newName},
449 : );
450 :
451 : /// Add a tag to the room.
452 6 : Future<void> addTag(String tag, {double? order}) => client.setRoomTag(
453 4 : client.userID!,
454 2 : id,
455 : tag,
456 2 : Tag(
457 : order: order,
458 : ));
459 :
460 : /// Removes a tag from the room.
461 6 : Future<void> removeTag(String tag) => client.deleteRoomTag(
462 4 : client.userID!,
463 2 : id,
464 : tag,
465 : );
466 :
467 : // Tag is part of client-to-server-API, so it uses strict parsing.
468 : // For roomAccountData, permissive parsing is more suitable,
469 : // so it is implemented here.
470 32 : static Tag _tryTagFromJson(Object o) {
471 32 : if (o is Map<String, dynamic>) {
472 32 : return Tag(
473 64 : order: o.tryGet<num>('order', TryGet.silent)?.toDouble(),
474 64 : additionalProperties: Map.from(o)..remove('order'),
475 : );
476 : }
477 0 : return Tag();
478 : }
479 :
480 : /// Returns all tags for this room.
481 32 : Map<String, Tag> get tags {
482 128 : final tags = roomAccountData['m.tag']?.content['tags'];
483 :
484 32 : if (tags is Map) {
485 : final parsedTags =
486 128 : tags.map((k, v) => MapEntry<String, Tag>(k, _tryTagFromJson(v)));
487 96 : parsedTags.removeWhere((k, v) => !TagType.isValid(k));
488 : return parsedTags;
489 : }
490 :
491 32 : return {};
492 : }
493 :
494 2 : bool get markedUnread {
495 2 : return MarkedUnread.fromJson(
496 8 : roomAccountData[EventType.markedUnread]?.content ?? {})
497 2 : .unread;
498 : }
499 :
500 : /// Checks if the last event has a read marker of the user.
501 : /// Warning: This compares the origin server timestamp which might not map
502 : /// to the real sort order of the timeline.
503 2 : bool get hasNewMessages {
504 2 : final lastEvent = this.lastEvent;
505 :
506 : // There is no known event or the last event is only a state fallback event,
507 : // we assume there is no new messages.
508 : if (lastEvent == null ||
509 8 : !client.roomPreviewLastEvents.contains(lastEvent.type)) return false;
510 :
511 : // Read marker is on the last event so no new messages.
512 2 : if (lastEvent.receipts
513 2 : .any((receipt) => receipt.user.senderId == client.userID!)) {
514 : return false;
515 : }
516 :
517 : // If the last event is sent, we mark the room as read.
518 8 : if (lastEvent.senderId == client.userID) return false;
519 :
520 : // Get the timestamp of read marker and compare
521 6 : final readAtMilliseconds = receiptState.global.latestOwnReceipt?.ts ?? 0;
522 6 : return readAtMilliseconds < lastEvent.originServerTs.millisecondsSinceEpoch;
523 : }
524 :
525 64 : LatestReceiptState get receiptState => LatestReceiptState.fromJson(
526 66 : roomAccountData[LatestReceiptState.eventType]?.content ??
527 32 : <String, dynamic>{});
528 :
529 : /// Returns true if this room is unread. To check if there are new messages
530 : /// in muted rooms, use [hasNewMessages].
531 8 : bool get isUnread => notificationCount > 0 || markedUnread;
532 :
533 : /// Returns true if this room is to be marked as unread. This extends
534 : /// [isUnread] to rooms with [Membership.invite].
535 8 : bool get isUnreadOrInvited => isUnread || membership == Membership.invite;
536 :
537 0 : @Deprecated('Use waitForRoomInSync() instead')
538 0 : Future<SyncUpdate> get waitForSync => waitForRoomInSync();
539 :
540 : /// Wait for the room to appear in join, leave or invited section of the
541 : /// sync.
542 0 : Future<SyncUpdate> waitForRoomInSync() async {
543 0 : return await client.waitForRoomInSync(id);
544 : }
545 :
546 : /// Sets an unread flag manually for this room. This changes the local account
547 : /// data model before syncing it to make sure
548 : /// this works if there is no connection to the homeserver. This does **not**
549 : /// set a read marker!
550 2 : Future<void> markUnread(bool unread) async {
551 4 : final content = MarkedUnread(unread).toJson();
552 2 : await _handleFakeSync(
553 2 : SyncUpdate(
554 : nextBatch: '',
555 2 : rooms: RoomsUpdate(
556 2 : join: {
557 4 : id: JoinedRoomUpdate(
558 2 : accountData: [
559 2 : BasicRoomEvent(
560 : content: content,
561 2 : roomId: id,
562 : type: EventType.markedUnread,
563 : ),
564 : ],
565 : )
566 : },
567 : ),
568 : ),
569 : );
570 4 : await client.setAccountDataPerRoom(
571 4 : client.userID!,
572 2 : id,
573 : EventType.markedUnread,
574 : content,
575 : );
576 : }
577 :
578 : /// Returns true if this room has a m.favourite tag.
579 96 : bool get isFavourite => tags[TagType.favourite] != null;
580 :
581 : /// Sets the m.favourite tag for this room.
582 2 : Future<void> setFavourite(bool favourite) =>
583 2 : favourite ? addTag(TagType.favourite) : removeTag(TagType.favourite);
584 :
585 : /// Call the Matrix API to change the pinned events of this room.
586 0 : Future<String> setPinnedEvents(List<String> pinnedEventIds) =>
587 0 : client.setRoomStateWithKey(
588 0 : id,
589 : EventTypes.RoomPinnedEvents,
590 : '',
591 0 : {'pinned': pinnedEventIds},
592 : );
593 :
594 : /// returns the resolved mxid for a mention string, or null if none found
595 4 : String? getMention(String mention) => getParticipants()
596 8 : .firstWhereOrNull((u) => u.mentionFragments.contains(mention))
597 2 : ?.id;
598 :
599 : /// Sends a normal text message to this room. Returns the event ID generated
600 : /// by the server for this message.
601 5 : Future<String?> sendTextEvent(String message,
602 : {String? txid,
603 : Event? inReplyTo,
604 : String? editEventId,
605 : bool parseMarkdown = true,
606 : bool parseCommands = true,
607 : String msgtype = MessageTypes.Text,
608 : String? threadRootEventId,
609 : String? threadLastEventId}) {
610 : if (parseCommands) {
611 10 : return client.parseAndRunCommand(this, message,
612 : inReplyTo: inReplyTo,
613 : editEventId: editEventId,
614 : txid: txid,
615 : threadRootEventId: threadRootEventId,
616 : threadLastEventId: threadLastEventId);
617 : }
618 5 : final event = <String, dynamic>{
619 : 'msgtype': msgtype,
620 : 'body': message,
621 : };
622 : if (parseMarkdown) {
623 10 : final html = markdown(event['body'],
624 0 : getEmotePacks: () => getImagePacksFlat(ImagePackUsage.emoticon),
625 5 : getMention: getMention);
626 : // if the decoded html is the same as the body, there is no need in sending a formatted message
627 25 : if (HtmlUnescape().convert(html.replaceAll(RegExp(r'<br />\n?'), '\n')) !=
628 5 : event['body']) {
629 3 : event['format'] = 'org.matrix.custom.html';
630 3 : event['formatted_body'] = html;
631 : }
632 : }
633 5 : return sendEvent(
634 : event,
635 : txid: txid,
636 : inReplyTo: inReplyTo,
637 : editEventId: editEventId,
638 : threadRootEventId: threadRootEventId,
639 : threadLastEventId: threadLastEventId,
640 : );
641 : }
642 :
643 : /// Sends a reaction to an event with an [eventId] and the content [key] into a room.
644 : /// Returns the event ID generated by the server for this reaction.
645 3 : Future<String?> sendReaction(String eventId, String key, {String? txid}) {
646 6 : return sendEvent({
647 3 : 'm.relates_to': {
648 : 'rel_type': RelationshipTypes.reaction,
649 : 'event_id': eventId,
650 : 'key': key,
651 : },
652 : }, type: EventTypes.Reaction, txid: txid);
653 : }
654 :
655 : /// Sends the location with description [body] and geo URI [geoUri] into a room.
656 : /// Returns the event ID generated by the server for this message.
657 2 : Future<String?> sendLocation(String body, String geoUri, {String? txid}) {
658 2 : final event = <String, dynamic>{
659 : 'msgtype': 'm.location',
660 : 'body': body,
661 : 'geo_uri': geoUri,
662 : };
663 2 : return sendEvent(event, txid: txid);
664 : }
665 :
666 : final Map<String, MatrixFile> sendingFilePlaceholders = {};
667 : final Map<String, MatrixImageFile> sendingFileThumbnails = {};
668 :
669 : /// Sends a [file] to this room after uploading it. Returns the mxc uri of
670 : /// the uploaded file. If [waitUntilSent] is true, the future will wait until
671 : /// the message event has received the server. Otherwise the future will only
672 : /// wait until the file has been uploaded.
673 : /// Optionally specify [extraContent] to tack on to the event.
674 : ///
675 : /// In case [file] is a [MatrixImageFile], [thumbnail] is automatically
676 : /// computed unless it is explicitly provided.
677 : /// Set [shrinkImageMaxDimension] to for example `1600` if you want to shrink
678 : /// your image before sending. This is ignored if the File is not a
679 : /// [MatrixImageFile].
680 3 : Future<String?> sendFileEvent(
681 : MatrixFile file, {
682 : String? txid,
683 : Event? inReplyTo,
684 : String? editEventId,
685 : int? shrinkImageMaxDimension,
686 : MatrixImageFile? thumbnail,
687 : Map<String, dynamic>? extraContent,
688 : String? threadRootEventId,
689 : String? threadLastEventId,
690 : }) async {
691 2 : txid ??= client.generateUniqueTransactionId();
692 6 : sendingFilePlaceholders[txid] = file;
693 : if (thumbnail != null) {
694 0 : sendingFileThumbnails[txid] = thumbnail;
695 : }
696 :
697 : // Create a fake Event object as a placeholder for the uploading file:
698 3 : final syncUpdate = SyncUpdate(
699 : nextBatch: '',
700 3 : rooms: RoomsUpdate(
701 3 : join: {
702 6 : id: JoinedRoomUpdate(
703 3 : timeline: TimelineUpdate(
704 3 : events: [
705 3 : MatrixEvent(
706 3 : content: {
707 3 : 'msgtype': file.msgType,
708 3 : 'body': file.name,
709 3 : 'filename': file.name,
710 : },
711 : type: EventTypes.Message,
712 : eventId: txid,
713 6 : senderId: client.userID!,
714 3 : originServerTs: DateTime.now(),
715 3 : unsigned: {
716 6 : messageSendingStatusKey: EventStatus.sending.intValue,
717 3 : 'transaction_id': txid,
718 3 : ...FileSendRequestCredentials(
719 0 : inReplyTo: inReplyTo?.eventId,
720 : editEventId: editEventId,
721 : shrinkImageMaxDimension: shrinkImageMaxDimension,
722 : extraContent: extraContent,
723 3 : ).toJson(),
724 : },
725 : ),
726 : ],
727 : ),
728 : ),
729 : },
730 : ),
731 : );
732 :
733 : MatrixFile uploadFile = file; // ignore: omit_local_variable_types
734 : // computing the thumbnail in case we can
735 3 : if (file is MatrixImageFile &&
736 : (thumbnail == null || shrinkImageMaxDimension != null)) {
737 0 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first
738 0 : .unsigned![fileSendingStatusKey] =
739 0 : FileSendingStatus.generatingThumbnail.name;
740 0 : await _handleFakeSync(syncUpdate);
741 0 : thumbnail ??= await file.generateThumbnail(
742 0 : nativeImplementations: client.nativeImplementations,
743 0 : customImageResizer: client.customImageResizer,
744 : );
745 : if (shrinkImageMaxDimension != null) {
746 0 : file = await MatrixImageFile.shrink(
747 0 : bytes: file.bytes,
748 0 : name: file.name,
749 : maxDimension: shrinkImageMaxDimension,
750 0 : customImageResizer: client.customImageResizer,
751 0 : nativeImplementations: client.nativeImplementations,
752 : );
753 : }
754 :
755 0 : if (thumbnail != null && file.size < thumbnail.size) {
756 : thumbnail = null; // in this case, the thumbnail is not usefull
757 : }
758 : }
759 :
760 : // Check media config of the server before sending the file. Stop if the
761 : // Media config is unreachable or the file is bigger than the given maxsize.
762 : try {
763 6 : final mediaConfig = await client.getConfig();
764 3 : final maxMediaSize = mediaConfig.mUploadSize;
765 9 : if (maxMediaSize != null && maxMediaSize < file.bytes.lengthInBytes) {
766 0 : throw FileTooBigMatrixException(file.bytes.lengthInBytes, maxMediaSize);
767 : }
768 : } catch (e) {
769 0 : Logs().d('Config error while sending file', e);
770 0 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first
771 0 : .unsigned![messageSendingStatusKey] = EventStatus.error.intValue;
772 0 : await _handleFakeSync(syncUpdate);
773 : rethrow;
774 : }
775 :
776 : MatrixFile? uploadThumbnail =
777 : thumbnail; // ignore: omit_local_variable_types
778 : EncryptedFile? encryptedFile;
779 : EncryptedFile? encryptedThumbnail;
780 3 : if (encrypted && client.fileEncryptionEnabled) {
781 0 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first
782 0 : .unsigned![fileSendingStatusKey] = FileSendingStatus.encrypting.name;
783 0 : await _handleFakeSync(syncUpdate);
784 0 : encryptedFile = await file.encrypt(client.nativeImplementations);
785 0 : uploadFile = encryptedFile.toMatrixFile();
786 :
787 : if (thumbnail != null) {
788 : encryptedThumbnail =
789 0 : await thumbnail.encrypt(client.nativeImplementations);
790 0 : uploadThumbnail = encryptedThumbnail.toMatrixFile();
791 : }
792 : }
793 : Uri? uploadResp, thumbnailUploadResp;
794 :
795 12 : final timeoutDate = DateTime.now().add(client.sendTimelineEventTimeout);
796 :
797 21 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first
798 9 : .unsigned![fileSendingStatusKey] = FileSendingStatus.uploading.name;
799 : while (uploadResp == null ||
800 : (uploadThumbnail != null && thumbnailUploadResp == null)) {
801 : try {
802 6 : uploadResp = await client.uploadContent(
803 3 : uploadFile.bytes,
804 3 : filename: uploadFile.name,
805 3 : contentType: uploadFile.mimeType,
806 : );
807 : thumbnailUploadResp = uploadThumbnail != null
808 0 : ? await client.uploadContent(
809 0 : uploadThumbnail.bytes,
810 0 : filename: uploadThumbnail.name,
811 0 : contentType: uploadThumbnail.mimeType,
812 : )
813 : : null;
814 0 : } on MatrixException catch (_) {
815 0 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first
816 0 : .unsigned![messageSendingStatusKey] = EventStatus.error.intValue;
817 0 : await _handleFakeSync(syncUpdate);
818 : rethrow;
819 : } catch (_) {
820 0 : if (DateTime.now().isAfter(timeoutDate)) {
821 0 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first
822 0 : .unsigned![messageSendingStatusKey] = EventStatus.error.intValue;
823 0 : await _handleFakeSync(syncUpdate);
824 : rethrow;
825 : }
826 0 : Logs().v('Send File into room failed. Try again...');
827 0 : await Future.delayed(Duration(seconds: 1));
828 : }
829 : }
830 :
831 : // Send event
832 3 : final content = <String, dynamic>{
833 6 : 'msgtype': file.msgType,
834 6 : 'body': file.name,
835 6 : 'filename': file.name,
836 6 : if (encryptedFile == null) 'url': uploadResp.toString(),
837 : if (encryptedFile != null)
838 0 : 'file': {
839 0 : 'url': uploadResp.toString(),
840 0 : 'mimetype': file.mimeType,
841 : 'v': 'v2',
842 0 : 'key': {
843 : 'alg': 'A256CTR',
844 : 'ext': true,
845 0 : 'k': encryptedFile.k,
846 0 : 'key_ops': ['encrypt', 'decrypt'],
847 : 'kty': 'oct'
848 : },
849 0 : 'iv': encryptedFile.iv,
850 0 : 'hashes': {'sha256': encryptedFile.sha256}
851 : },
852 6 : 'info': {
853 3 : ...file.info,
854 : if (thumbnail != null && encryptedThumbnail == null)
855 0 : 'thumbnail_url': thumbnailUploadResp.toString(),
856 : if (thumbnail != null && encryptedThumbnail != null)
857 0 : 'thumbnail_file': {
858 0 : 'url': thumbnailUploadResp.toString(),
859 0 : 'mimetype': thumbnail.mimeType,
860 : 'v': 'v2',
861 0 : 'key': {
862 : 'alg': 'A256CTR',
863 : 'ext': true,
864 0 : 'k': encryptedThumbnail.k,
865 0 : 'key_ops': ['encrypt', 'decrypt'],
866 : 'kty': 'oct'
867 : },
868 0 : 'iv': encryptedThumbnail.iv,
869 0 : 'hashes': {'sha256': encryptedThumbnail.sha256}
870 : },
871 0 : if (thumbnail != null) 'thumbnail_info': thumbnail.info,
872 0 : if (thumbnail?.blurhash != null &&
873 0 : file is MatrixImageFile &&
874 0 : file.blurhash == null)
875 0 : 'xyz.amorgan.blurhash': thumbnail!.blurhash
876 : },
877 0 : if (extraContent != null) ...extraContent,
878 : };
879 3 : final eventId = await sendEvent(
880 : content,
881 : txid: txid,
882 : inReplyTo: inReplyTo,
883 : editEventId: editEventId,
884 : threadRootEventId: threadRootEventId,
885 : threadLastEventId: threadLastEventId,
886 : );
887 6 : sendingFilePlaceholders.remove(txid);
888 6 : sendingFileThumbnails.remove(txid);
889 : return eventId;
890 : }
891 :
892 : /// Calculates how secure the communication is. When all devices are blocked or
893 : /// verified, then this returns [EncryptionHealthState.allVerified]. When at
894 : /// least one device is not verified, then it returns
895 : /// [EncryptionHealthState.unverifiedDevices]. Apps should display this health
896 : /// state next to the input text field to inform the user about the current
897 : /// encryption security level.
898 2 : Future<EncryptionHealthState> calcEncryptionHealthState() async {
899 2 : final users = await requestParticipants();
900 4 : users.removeWhere((u) =>
901 8 : !{Membership.invite, Membership.join}.contains(u.membership) ||
902 8 : !client.userDeviceKeys.containsKey(u.id));
903 :
904 4 : if (users.any((u) =>
905 12 : client.userDeviceKeys[u.id]!.verified != UserVerifiedStatus.verified)) {
906 : return EncryptionHealthState.unverifiedDevices;
907 : }
908 :
909 : return EncryptionHealthState.allVerified;
910 : }
911 :
912 8 : Future<String?> _sendContent(
913 : String type,
914 : Map<String, dynamic> content, {
915 : String? txid,
916 : }) async {
917 0 : txid ??= client.generateUniqueTransactionId();
918 :
919 12 : final mustEncrypt = encrypted && client.encryptionEnabled;
920 :
921 : final sendMessageContent = mustEncrypt
922 2 : ? await client.encryption!
923 2 : .encryptGroupMessagePayload(id, content, type: type)
924 : : content;
925 :
926 16 : return await client.sendMessage(
927 8 : id,
928 8 : sendMessageContent.containsKey('ciphertext')
929 : ? EventTypes.Encrypted
930 : : type,
931 : txid,
932 : sendMessageContent,
933 : );
934 : }
935 :
936 3 : String _stripBodyFallback(String body) {
937 3 : if (body.startsWith('> <@')) {
938 : var temp = '';
939 : var inPrefix = true;
940 4 : for (final l in body.split('\n')) {
941 4 : if (inPrefix && (l.isEmpty || l.startsWith('> '))) {
942 : continue;
943 : }
944 :
945 : inPrefix = false;
946 4 : temp += temp.isEmpty ? l : ('\n$l');
947 : }
948 :
949 : return temp;
950 : } else {
951 : return body;
952 : }
953 : }
954 :
955 : /// Sends an event to this room with this json as a content. Returns the
956 : /// event ID generated from the server.
957 : /// It uses list of completer to make sure events are sending in a row.
958 8 : Future<String?> sendEvent(
959 : Map<String, dynamic> content, {
960 : String type = EventTypes.Message,
961 : String? txid,
962 : Event? inReplyTo,
963 : String? editEventId,
964 : String? threadRootEventId,
965 : String? threadLastEventId,
966 : }) async {
967 : // Create new transaction id
968 : final String messageID;
969 : if (txid == null) {
970 6 : messageID = client.generateUniqueTransactionId();
971 : } else {
972 : messageID = txid;
973 : }
974 :
975 : if (inReplyTo != null) {
976 : var replyText =
977 12 : '<${inReplyTo.senderId}> ${_stripBodyFallback(inReplyTo.body)}';
978 15 : replyText = replyText.split('\n').map((line) => '> $line').join('\n');
979 3 : content['format'] = 'org.matrix.custom.html';
980 : // be sure that we strip any previous reply fallbacks
981 6 : final replyHtml = (inReplyTo.formattedText.isNotEmpty
982 2 : ? inReplyTo.formattedText
983 9 : : htmlEscape.convert(inReplyTo.body).replaceAll('\n', '<br>'))
984 3 : .replaceAll(
985 3 : RegExp(r'<mx-reply>.*</mx-reply>',
986 : caseSensitive: false, multiLine: false, dotAll: true),
987 : '');
988 3 : final repliedHtml = content.tryGet<String>('formatted_body') ??
989 : htmlEscape
990 6 : .convert(content.tryGet<String>('body') ?? '')
991 3 : .replaceAll('\n', '<br>');
992 3 : content['formatted_body'] =
993 15 : '<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';
994 : // We escape all @room-mentions here to prevent accidental room pings when an admin
995 : // replies to a message containing that!
996 3 : content['body'] =
997 9 : '${replyText.replaceAll('@room', '@\u200broom')}\n\n${content.tryGet<String>('body') ?? ''}';
998 6 : content['m.relates_to'] = {
999 3 : 'm.in_reply_to': {
1000 3 : 'event_id': inReplyTo.eventId,
1001 : },
1002 : };
1003 : }
1004 :
1005 : if (threadRootEventId != null) {
1006 2 : content['m.relates_to'] = {
1007 1 : 'event_id': threadRootEventId,
1008 1 : 'rel_type': RelationshipTypes.thread,
1009 1 : 'is_falling_back': inReplyTo == null,
1010 1 : if (inReplyTo != null) ...{
1011 1 : 'm.in_reply_to': {
1012 1 : 'event_id': inReplyTo.eventId,
1013 : },
1014 1 : } else ...{
1015 : if (threadLastEventId != null)
1016 2 : 'm.in_reply_to': {
1017 : 'event_id': threadLastEventId,
1018 : },
1019 : }
1020 : };
1021 : }
1022 :
1023 : if (editEventId != null) {
1024 2 : final newContent = content.copy();
1025 2 : content['m.new_content'] = newContent;
1026 4 : content['m.relates_to'] = {
1027 : 'event_id': editEventId,
1028 : 'rel_type': RelationshipTypes.edit,
1029 : };
1030 4 : if (content['body'] is String) {
1031 6 : content['body'] = '* ${content['body']}';
1032 : }
1033 4 : if (content['formatted_body'] is String) {
1034 0 : content['formatted_body'] = '* ${content['formatted_body']}';
1035 : }
1036 : }
1037 8 : final sentDate = DateTime.now();
1038 8 : final syncUpdate = SyncUpdate(
1039 : nextBatch: '',
1040 8 : rooms: RoomsUpdate(
1041 8 : join: {
1042 16 : id: JoinedRoomUpdate(
1043 8 : timeline: TimelineUpdate(
1044 8 : events: [
1045 8 : MatrixEvent(
1046 : content: content,
1047 : type: type,
1048 : eventId: messageID,
1049 16 : senderId: client.userID!,
1050 : originServerTs: sentDate,
1051 8 : unsigned: {
1052 8 : messageSendingStatusKey: EventStatus.sending.intValue,
1053 : 'transaction_id': messageID,
1054 : },
1055 : ),
1056 : ],
1057 : ),
1058 : ),
1059 : },
1060 : ),
1061 : );
1062 8 : await _handleFakeSync(syncUpdate);
1063 8 : final completer = Completer();
1064 16 : _sendingQueue.add(completer);
1065 24 : while (_sendingQueue.first != completer) {
1066 0 : await _sendingQueue.first.future;
1067 : }
1068 :
1069 32 : final timeoutDate = DateTime.now().add(client.sendTimelineEventTimeout);
1070 : // Send the text and on success, store and display a *sent* event.
1071 : String? res;
1072 :
1073 : while (res == null) {
1074 : try {
1075 8 : res = await _sendContent(
1076 : type,
1077 : content,
1078 : txid: messageID,
1079 : );
1080 : } catch (e, s) {
1081 4 : if (e is MatrixException &&
1082 4 : e.retryAfterMs != null &&
1083 0 : !DateTime.now()
1084 0 : .add(Duration(milliseconds: e.retryAfterMs!))
1085 0 : .isAfter(timeoutDate)) {
1086 0 : Logs().w(
1087 0 : 'Ratelimited while sending message, waiting for ${e.retryAfterMs}ms');
1088 0 : await Future.delayed(Duration(milliseconds: e.retryAfterMs!));
1089 4 : } else if (e is MatrixException ||
1090 2 : e is EventTooLarge ||
1091 0 : DateTime.now().isAfter(timeoutDate)) {
1092 8 : Logs().w('Problem while sending message', e, s);
1093 28 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first
1094 12 : .unsigned![messageSendingStatusKey] = EventStatus.error.intValue;
1095 4 : await _handleFakeSync(syncUpdate);
1096 4 : completer.complete();
1097 8 : _sendingQueue.remove(completer);
1098 4 : if (e is EventTooLarge) rethrow;
1099 : return null;
1100 : } else {
1101 0 : Logs()
1102 0 : .w('Problem while sending message: $e Try again in 1 seconds...');
1103 0 : await Future.delayed(Duration(seconds: 1));
1104 : }
1105 : }
1106 : }
1107 56 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first
1108 24 : .unsigned![messageSendingStatusKey] = EventStatus.sent.intValue;
1109 64 : syncUpdate.rooms!.join!.values.first.timeline!.events!.first.eventId = res;
1110 8 : await _handleFakeSync(syncUpdate);
1111 8 : completer.complete();
1112 16 : _sendingQueue.remove(completer);
1113 :
1114 : return res;
1115 : }
1116 :
1117 : /// Call the Matrix API to join this room if the user is not already a member.
1118 : /// If this room is intended to be a direct chat, the direct chat flag will
1119 : /// automatically be set.
1120 0 : Future<void> join({bool leaveIfNotFound = true}) async {
1121 : try {
1122 : // If this is a DM, mark it as a DM first, because otherwise the current member
1123 : // event might be the join event already and there is also a race condition there for SDK users.
1124 0 : final dmId = directChatMatrixID;
1125 : if (dmId != null) {
1126 0 : await addToDirectChat(dmId);
1127 : }
1128 :
1129 : // now join
1130 0 : await client.joinRoomById(id);
1131 0 : } on MatrixException catch (exception) {
1132 : if (leaveIfNotFound &&
1133 0 : [MatrixError.M_NOT_FOUND, MatrixError.M_UNKNOWN]
1134 0 : .contains(exception.error)) {
1135 0 : await leave();
1136 : }
1137 : rethrow;
1138 : }
1139 : return;
1140 : }
1141 :
1142 : /// Call the Matrix API to leave this room. If this room is set as a direct
1143 : /// chat, this will be removed too.
1144 1 : Future<void> leave() async {
1145 : try {
1146 3 : await client.leaveRoom(id);
1147 0 : } on MatrixException catch (exception) {
1148 0 : if ([MatrixError.M_NOT_FOUND, MatrixError.M_UNKNOWN]
1149 0 : .contains(exception.error)) {
1150 0 : await _handleFakeSync(
1151 0 : SyncUpdate(
1152 : nextBatch: '',
1153 0 : rooms: RoomsUpdate(
1154 0 : leave: {
1155 0 : id: LeftRoomUpdate(),
1156 : },
1157 : ),
1158 : ),
1159 : );
1160 : }
1161 : rethrow;
1162 : }
1163 : return;
1164 : }
1165 :
1166 : /// Call the Matrix API to forget this room if you already left it.
1167 0 : Future<void> forget() async {
1168 0 : await client.database?.forgetRoom(id);
1169 0 : await client.forgetRoom(id);
1170 : // Update archived rooms, otherwise an archived room may still be in the
1171 : // list after a forget room call
1172 0 : final roomIndex = client.archivedRooms.indexWhere((r) => r.room.id == id);
1173 0 : if (roomIndex != -1) {
1174 0 : client.archivedRooms.removeAt(roomIndex);
1175 : }
1176 : return;
1177 : }
1178 :
1179 : /// Call the Matrix API to kick a user from this room.
1180 20 : Future<void> kick(String userID) => client.kick(id, userID);
1181 :
1182 : /// Call the Matrix API to ban a user from this room.
1183 20 : Future<void> ban(String userID) => client.ban(id, userID);
1184 :
1185 : /// Call the Matrix API to unban a banned user from this room.
1186 20 : Future<void> unban(String userID) => client.unban(id, userID);
1187 :
1188 : /// Set the power level of the user with the [userID] to the value [power].
1189 : /// Returns the event ID of the new state event. If there is no known
1190 : /// power level event, there might something broken and this returns null.
1191 5 : Future<String> setPower(String userID, int power) async {
1192 5 : final powerMap = Map<String, Object?>.from(
1193 10 : getState(EventTypes.RoomPowerLevels)?.content ?? {},
1194 : );
1195 :
1196 10 : final usersPowerMap = powerMap['users'] is Map<String, Object?>
1197 0 : ? powerMap['users'] as Map<String, Object?>
1198 10 : : (powerMap['users'] = <String, Object?>{});
1199 :
1200 5 : usersPowerMap[userID] = power;
1201 :
1202 10 : return await client.setRoomStateWithKey(
1203 5 : id,
1204 : EventTypes.RoomPowerLevels,
1205 : '',
1206 : powerMap,
1207 : );
1208 : }
1209 :
1210 : /// Call the Matrix API to invite a user to this room.
1211 3 : Future<void> invite(
1212 : String userID, {
1213 : String? reason,
1214 : }) =>
1215 6 : client.inviteUser(
1216 3 : id,
1217 : userID,
1218 : reason: reason,
1219 : );
1220 :
1221 : /// Request more previous events from the server. [historyCount] defines how much events should
1222 : /// be received maximum. When the request is answered, [onHistoryReceived] will be triggered **before**
1223 : /// the historical events will be published in the onEvent stream.
1224 : /// Returns the actual count of received timeline events.
1225 3 : Future<int> requestHistory(
1226 : {int historyCount = defaultHistoryCount,
1227 : void Function()? onHistoryReceived,
1228 : direction = Direction.b}) async {
1229 3 : final prev_batch = this.prev_batch;
1230 :
1231 3 : final storeInDatabase = !isArchived;
1232 :
1233 : if (prev_batch == null) {
1234 : throw 'Tried to request history without a prev_batch token';
1235 : }
1236 6 : final resp = await client.getRoomEvents(
1237 3 : id,
1238 : direction,
1239 : from: prev_batch,
1240 : limit: historyCount,
1241 9 : filter: jsonEncode(StateFilter(lazyLoadMembers: true).toJson()),
1242 : );
1243 :
1244 2 : if (onHistoryReceived != null) onHistoryReceived();
1245 6 : this.prev_batch = resp.end;
1246 :
1247 3 : Future<void> loadFn() async {
1248 9 : if (!((resp.chunk.isNotEmpty) && resp.end != null)) return;
1249 :
1250 6 : await client.handleSync(
1251 3 : SyncUpdate(
1252 : nextBatch: '',
1253 3 : rooms: RoomsUpdate(
1254 6 : join: membership == Membership.join
1255 1 : ? {
1256 2 : id: JoinedRoomUpdate(
1257 1 : state: resp.state,
1258 1 : timeline: TimelineUpdate(
1259 : limited: false,
1260 1 : events: direction == Direction.b
1261 1 : ? resp.chunk
1262 0 : : resp.chunk.reversed.toList(),
1263 1 : prevBatch: direction == Direction.b
1264 1 : ? resp.end
1265 0 : : resp.start,
1266 : ),
1267 : )
1268 : }
1269 : : null,
1270 6 : leave: membership != Membership.join
1271 2 : ? {
1272 4 : id: LeftRoomUpdate(
1273 2 : state: resp.state,
1274 2 : timeline: TimelineUpdate(
1275 : limited: false,
1276 2 : events: direction == Direction.b
1277 2 : ? resp.chunk
1278 0 : : resp.chunk.reversed.toList(),
1279 2 : prevBatch: direction == Direction.b
1280 2 : ? resp.end
1281 0 : : resp.start,
1282 : ),
1283 : ),
1284 : }
1285 : : null),
1286 : ),
1287 : direction: Direction.b);
1288 : }
1289 :
1290 6 : if (client.database != null) {
1291 12 : await client.database?.transaction(() async {
1292 : if (storeInDatabase) {
1293 6 : await client.database?.setRoomPrevBatch(resp.end, id, client);
1294 : }
1295 3 : await loadFn();
1296 : });
1297 : } else {
1298 0 : await loadFn();
1299 : }
1300 :
1301 6 : return resp.chunk.length;
1302 : }
1303 :
1304 : /// Sets this room as a direct chat for this user if not already.
1305 8 : Future<void> addToDirectChat(String userID) async {
1306 16 : final directChats = client.directChats;
1307 16 : if (directChats[userID] is List) {
1308 0 : if (!directChats[userID].contains(id)) {
1309 0 : directChats[userID].add(id);
1310 : } else {
1311 : return;
1312 : } // Is already in direct chats
1313 : } else {
1314 24 : directChats[userID] = [id];
1315 : }
1316 :
1317 16 : await client.setAccountData(
1318 16 : client.userID!,
1319 : 'm.direct',
1320 : directChats,
1321 : );
1322 : return;
1323 : }
1324 :
1325 : /// Removes this room from all direct chat tags.
1326 1 : Future<void> removeFromDirectChat() async {
1327 3 : final directChats = client.directChats.copy();
1328 2 : for (final k in directChats.keys) {
1329 1 : final directChat = directChats[k];
1330 3 : if (directChat is List && directChat.contains(id)) {
1331 2 : directChat.remove(id);
1332 : }
1333 : }
1334 :
1335 4 : directChats.removeWhere((_, v) => v is List && v.isEmpty);
1336 :
1337 3 : if (directChats == client.directChats) {
1338 : return;
1339 : }
1340 :
1341 2 : await client.setAccountData(
1342 2 : client.userID!,
1343 : 'm.direct',
1344 : directChats,
1345 : );
1346 : return;
1347 : }
1348 :
1349 : /// Get the user fully read marker
1350 0 : @Deprecated('Use fullyRead marker')
1351 0 : String? get userFullyReadMarker => fullyRead;
1352 :
1353 2 : bool get isFederated =>
1354 6 : getState(EventTypes.RoomCreate)?.content.tryGet<bool>('m.federate') ??
1355 : true;
1356 :
1357 : /// Sets the position of the read marker for a given room, and optionally the
1358 : /// read receipt's location.
1359 : /// If you set `public` to false, only a private receipt will be sent. A private receipt is always sent if `mRead` is set. If no value is provided, the default from the `client` is used.
1360 : /// You can leave out the `eventId`, which will not update the read marker but just send receipts, but there are few cases where that makes sense.
1361 4 : Future<void> setReadMarker(String? eventId,
1362 : {String? mRead, bool? public}) async {
1363 8 : await client.setReadMarker(
1364 4 : id,
1365 : mFullyRead: eventId,
1366 8 : mRead: (public ?? client.receiptsPublicByDefault) ? mRead : null,
1367 : // we always send the private receipt, because there is no reason not to.
1368 : mReadPrivate: mRead,
1369 : );
1370 : return;
1371 : }
1372 :
1373 0 : Future<TimelineChunk?> getEventContext(String eventId) async {
1374 0 : final resp = await client.getEventContext(id, eventId,
1375 : limit: Room.defaultHistoryCount
1376 : // filter: jsonEncode(StateFilter(lazyLoadMembers: true).toJson()),
1377 : );
1378 :
1379 0 : final events = [
1380 0 : if (resp.eventsAfter != null) ...resp.eventsAfter!.reversed,
1381 0 : if (resp.event != null) resp.event!,
1382 0 : if (resp.eventsBefore != null) ...resp.eventsBefore!
1383 0 : ].map((e) => Event.fromMatrixEvent(e, this)).toList();
1384 :
1385 : // Try again to decrypt encrypted events but don't update the database.
1386 0 : if (encrypted && client.database != null && client.encryptionEnabled) {
1387 0 : for (var i = 0; i < events.length; i++) {
1388 0 : if (events[i].type == EventTypes.Encrypted &&
1389 0 : events[i].content['can_request_session'] == true) {
1390 0 : events[i] = await client.encryption!.decryptRoomEvent(
1391 0 : id,
1392 0 : events[i],
1393 : );
1394 : }
1395 : }
1396 : }
1397 :
1398 0 : final chunk = TimelineChunk(
1399 0 : nextBatch: resp.end ?? '', prevBatch: resp.start ?? '', events: events);
1400 :
1401 : return chunk;
1402 : }
1403 :
1404 : /// This API updates the marker for the given receipt type to the event ID
1405 : /// specified. In general you want to use `setReadMarker` instead to set private
1406 : /// and public receipt as well as the marker at the same time.
1407 0 : @Deprecated(
1408 : 'Use setReadMarker with mRead set instead. That allows for more control and there are few cases to not send a marker at the same time.')
1409 : Future<void> postReceipt(String eventId,
1410 : {ReceiptType type = ReceiptType.mRead}) async {
1411 0 : await client.postReceipt(
1412 0 : id,
1413 : ReceiptType.mRead,
1414 : eventId,
1415 : );
1416 : return;
1417 : }
1418 :
1419 : /// Is the room archived
1420 15 : bool get isArchived => membership == Membership.leave;
1421 :
1422 : /// Creates a timeline from the store. Returns a [Timeline] object. If you
1423 : /// just want to update the whole timeline on every change, use the [onUpdate]
1424 : /// callback. For updating only the parts that have changed, use the
1425 : /// [onChange], [onRemove], [onInsert] and the [onHistoryReceived] callbacks.
1426 : /// This method can also retrieve the timeline at a specific point by setting
1427 : /// the [eventContextId]
1428 4 : Future<Timeline> getTimeline(
1429 : {void Function(int index)? onChange,
1430 : void Function(int index)? onRemove,
1431 : void Function(int insertID)? onInsert,
1432 : void Function()? onNewEvent,
1433 : void Function()? onUpdate,
1434 : String? eventContextId}) async {
1435 4 : await postLoad();
1436 :
1437 : List<Event> events;
1438 :
1439 4 : if (!isArchived) {
1440 6 : events = await client.database?.getEventList(
1441 : this,
1442 : limit: defaultHistoryCount,
1443 : ) ??
1444 0 : <Event>[];
1445 : } else {
1446 6 : final archive = client.getArchiveRoomFromCache(id);
1447 6 : events = archive?.timeline.events.toList() ?? [];
1448 6 : for (var i = 0; i < events.length; i++) {
1449 : // Try to decrypt encrypted events but don't update the database.
1450 2 : if (encrypted && client.encryptionEnabled) {
1451 0 : if (events[i].type == EventTypes.Encrypted) {
1452 0 : events[i] = await client.encryption!.decryptRoomEvent(
1453 0 : id,
1454 0 : events[i],
1455 : );
1456 : }
1457 : }
1458 : }
1459 : }
1460 :
1461 4 : var chunk = TimelineChunk(events: events);
1462 : // Load the timeline arround eventContextId if set
1463 : if (eventContextId != null) {
1464 0 : if (!events.any((Event event) => event.eventId == eventContextId)) {
1465 : chunk =
1466 0 : await getEventContext(eventContextId) ?? TimelineChunk(events: []);
1467 : }
1468 : }
1469 :
1470 4 : final timeline = Timeline(
1471 : room: this,
1472 : chunk: chunk,
1473 : onChange: onChange,
1474 : onRemove: onRemove,
1475 : onInsert: onInsert,
1476 : onNewEvent: onNewEvent,
1477 : onUpdate: onUpdate);
1478 :
1479 : // Fetch all users from database we have got here.
1480 : if (eventContextId == null) {
1481 16 : final userIds = events.map((event) => event.senderId).toSet();
1482 8 : for (final userId in userIds) {
1483 4 : if (getState(EventTypes.RoomMember, userId) != null) continue;
1484 12 : final dbUser = await client.database?.getUser(userId, this);
1485 0 : if (dbUser != null) setState(dbUser);
1486 : }
1487 : }
1488 :
1489 : // Try again to decrypt encrypted events and update the database.
1490 4 : if (encrypted && client.encryptionEnabled) {
1491 : // decrypt messages
1492 0 : for (var i = 0; i < chunk.events.length; i++) {
1493 0 : if (chunk.events[i].type == EventTypes.Encrypted) {
1494 : if (eventContextId != null) {
1495 : // for the fragmented timeline, we don't cache the decrypted
1496 : //message in the database
1497 0 : chunk.events[i] = await client.encryption!.decryptRoomEvent(
1498 0 : id,
1499 0 : chunk.events[i],
1500 : );
1501 0 : } else if (client.database != null) {
1502 : // else, we need the database
1503 0 : await client.database?.transaction(() async {
1504 0 : for (var i = 0; i < chunk.events.length; i++) {
1505 0 : if (chunk.events[i].content['can_request_session'] == true) {
1506 0 : chunk.events[i] = await client.encryption!.decryptRoomEvent(
1507 0 : id,
1508 0 : chunk.events[i],
1509 0 : store: !isArchived,
1510 : updateType: EventUpdateType.history,
1511 : );
1512 : }
1513 : }
1514 : });
1515 : }
1516 : }
1517 : }
1518 : }
1519 :
1520 : return timeline;
1521 : }
1522 :
1523 : /// Returns all participants for this room. With lazy loading this
1524 : /// list may not be complete. Use [requestParticipants] in this
1525 : /// case.
1526 : /// List `membershipFilter` defines with what membership do you want the
1527 : /// participants, default set to
1528 : /// [[Membership.join, Membership.invite, Membership.knock]]
1529 32 : List<User> getParticipants(
1530 : [List<Membership> membershipFilter = const [
1531 : Membership.join,
1532 : Membership.invite,
1533 : Membership.knock,
1534 : ]]) {
1535 64 : final members = states[EventTypes.RoomMember];
1536 : if (members != null) {
1537 32 : return members.entries
1538 160 : .where((entry) => entry.value.type == EventTypes.RoomMember)
1539 128 : .map((entry) => entry.value.asUser(this))
1540 128 : .where((user) => membershipFilter.contains(user.membership))
1541 32 : .toList();
1542 : }
1543 6 : return <User>[];
1544 : }
1545 :
1546 : /// Request the full list of participants from the server. The local list
1547 : /// from the store is not complete if the client uses lazy loading.
1548 : /// List `membershipFilter` defines with what membership do you want the
1549 : /// participants, default set to
1550 : /// [[Membership.join, Membership.invite, Membership.knock]]
1551 : /// Set [cache] to `false` if you do not want to cache the users in memory
1552 : /// for this session which is highly recommended for large public rooms.
1553 30 : Future<List<User>> requestParticipants(
1554 : [List<Membership> membershipFilter = const [
1555 : Membership.join,
1556 : Membership.invite,
1557 : Membership.knock,
1558 : ],
1559 : bool suppressWarning = false,
1560 : bool cache = true]) async {
1561 60 : if (!participantListComplete || partial) {
1562 : // we aren't fully loaded, maybe the users are in the database
1563 : // We always need to check the database in the partial case, since state
1564 : // events won't get written to memory in this case and someone new could
1565 : // have joined, while someone else left, which might lead to the same
1566 : // count in the completeness check.
1567 91 : final users = await client.database?.getUsers(this) ?? [];
1568 31 : for (final user in users) {
1569 1 : setState(user);
1570 : }
1571 : }
1572 :
1573 : // Do not request users from the server if we have already have a complete list locally.
1574 30 : if (participantListComplete) {
1575 30 : return getParticipants(membershipFilter);
1576 : }
1577 :
1578 2 : final memberCount = summary.mJoinedMemberCount;
1579 1 : if (!suppressWarning && cache && memberCount != null && memberCount > 100) {
1580 0 : Logs().w('''
1581 0 : Loading a list of $memberCount participants for the room $id.
1582 : This may affect the performance. Please make sure to not unnecessary
1583 : request so many participants or suppress this warning.
1584 0 : ''');
1585 : }
1586 :
1587 3 : final matrixEvents = await client.getMembersByRoom(id);
1588 : final users = matrixEvents
1589 4 : ?.map((e) => Event.fromMatrixEvent(e, this).asUser)
1590 1 : .toList() ??
1591 0 : [];
1592 :
1593 : if (cache) {
1594 2 : for (final user in users) {
1595 1 : setState(user); // at *least* cache this in-memory
1596 : }
1597 : }
1598 :
1599 4 : users.removeWhere((u) => !membershipFilter.contains(u.membership));
1600 : return users;
1601 : }
1602 :
1603 : /// Checks if the local participant list of joined and invited users is complete.
1604 30 : bool get participantListComplete {
1605 30 : final knownParticipants = getParticipants();
1606 : final joinedCount =
1607 150 : knownParticipants.where((u) => u.membership == Membership.join).length;
1608 : final invitedCount = knownParticipants
1609 120 : .where((u) => u.membership == Membership.invite)
1610 30 : .length;
1611 :
1612 90 : return (summary.mJoinedMemberCount ?? 0) == joinedCount &&
1613 90 : (summary.mInvitedMemberCount ?? 0) == invitedCount;
1614 : }
1615 :
1616 0 : @Deprecated(
1617 : 'The method was renamed unsafeGetUserFromMemoryOrFallback. Please prefer requestParticipants.')
1618 : User getUserByMXIDSync(String mxID) {
1619 0 : return unsafeGetUserFromMemoryOrFallback(mxID);
1620 : }
1621 :
1622 : /// Returns the [User] object for the given [mxID] or return
1623 : /// a fallback [User] and start a request to get the user
1624 : /// from the homeserver.
1625 7 : User unsafeGetUserFromMemoryOrFallback(String mxID) {
1626 7 : final user = getState(EventTypes.RoomMember, mxID);
1627 : if (user != null) {
1628 6 : return user.asUser(this);
1629 : } else {
1630 4 : if (mxID.isValidMatrixId) {
1631 : // ignore: discarded_futures
1632 4 : requestUser(
1633 : mxID,
1634 : ignoreErrors: true,
1635 : );
1636 : }
1637 4 : return User(mxID, room: this);
1638 : }
1639 : }
1640 :
1641 : // Internal helper to implement requestUser
1642 7 : Future<User?> _requestSingleParticipantViaState(
1643 : String mxID, {
1644 : required bool ignoreErrors,
1645 : }) async {
1646 : try {
1647 28 : Logs().v('Request missing user $mxID in room $id from the server...');
1648 14 : final resp = await client.getRoomStateWithKey(
1649 7 : id,
1650 : EventTypes.RoomMember,
1651 : mxID,
1652 : );
1653 :
1654 : // valid member events require a valid membership key
1655 6 : final membership = resp.tryGet<String>('membership', TryGet.required);
1656 6 : assert(membership != null);
1657 :
1658 6 : final foundUser = User(
1659 : mxID,
1660 : room: this,
1661 6 : displayName: resp.tryGet<String>('displayname', TryGet.silent),
1662 6 : avatarUrl: resp.tryGet<String>('avatar_url', TryGet.silent),
1663 : membership: membership,
1664 : );
1665 :
1666 : // Store user in database:
1667 24 : await client.database?.transaction(() async {
1668 18 : await client.database?.storeEventUpdate(
1669 6 : EventUpdate(
1670 6 : content: foundUser.toJson(),
1671 6 : roomID: id,
1672 : type: EventUpdateType.state,
1673 : ),
1674 6 : client,
1675 : );
1676 : });
1677 :
1678 : return foundUser;
1679 4 : } on MatrixException catch (_) {
1680 : // Ignore if we have no permission
1681 : return null;
1682 : } catch (e, s) {
1683 : if (!ignoreErrors) {
1684 : rethrow;
1685 : } else {
1686 3 : Logs().w('Unable to request the user $mxID from the server', e, s);
1687 : return null;
1688 : }
1689 : }
1690 : }
1691 :
1692 : // Internal helper to implement requestUser
1693 8 : Future<User?> _requestUser(
1694 : String mxID, {
1695 : required bool ignoreErrors,
1696 : required bool requestState,
1697 : required bool requestProfile,
1698 : }) async {
1699 : // Is user already in cache?
1700 :
1701 : // If not in cache, try the database
1702 11 : User? foundUser = getState(EventTypes.RoomMember, mxID)?.asUser(this);
1703 :
1704 : // If the room is not postloaded, check the database
1705 8 : if (partial && foundUser == null) {
1706 14 : foundUser = await client.database?.getUser(mxID, this);
1707 : }
1708 :
1709 : // If not in the database, try fetching the member from the server
1710 : if (requestState && foundUser == null) {
1711 7 : foundUser = await _requestSingleParticipantViaState(
1712 : mxID,
1713 : ignoreErrors: ignoreErrors,
1714 : );
1715 : }
1716 :
1717 : // If the user isn't found or they have left and no displayname set anymore, request their profile from the server
1718 : if (requestProfile) {
1719 : if (foundUser
1720 : case null ||
1721 : User(
1722 14 : membership: Membership.ban || Membership.leave,
1723 6 : displayName: null
1724 : )) {
1725 : try {
1726 8 : final profile = await client.getUserProfile(mxID);
1727 2 : foundUser = User(
1728 : mxID,
1729 2 : displayName: profile.displayname,
1730 4 : avatarUrl: profile.avatarUrl?.toString(),
1731 6 : membership: foundUser?.membership.name ?? Membership.leave.name,
1732 : room: this,
1733 : );
1734 : } catch (e, s) {
1735 : if (!ignoreErrors) {
1736 : rethrow;
1737 : } else {
1738 1 : Logs()
1739 2 : .w('Unable to request the profile $mxID from the server', e, s);
1740 : }
1741 : }
1742 : }
1743 : }
1744 :
1745 : if (foundUser == null) return null;
1746 : // make sure we didn't actually store anything by the time we did those requests
1747 : final userFromCurrentState =
1748 11 : getState(EventTypes.RoomMember, mxID)?.asUser(this);
1749 :
1750 : // Set user in the local state if the state changed.
1751 : // If we set the state unconditionally, we might end up with a client calling this over and over thinking the user changed.
1752 : if (userFromCurrentState == null ||
1753 12 : userFromCurrentState.displayName != foundUser.displayName) {
1754 6 : setState(foundUser);
1755 : // ignore: deprecated_member_use_from_same_package
1756 18 : onUpdate.add(id);
1757 : }
1758 :
1759 : return foundUser;
1760 : }
1761 :
1762 : final Map<
1763 : ({
1764 : String mxID,
1765 : bool ignoreErrors,
1766 : bool requestState,
1767 : bool requestProfile,
1768 : }),
1769 : AsyncCache<User?>> _inflightUserRequests = {};
1770 :
1771 : /// Requests a missing [User] for this room. Important for clients using
1772 : /// lazy loading. If the user can't be found this method tries to fetch
1773 : /// the displayname and avatar from the server if [requestState] is true.
1774 : /// If that fails, it falls back to requesting the global profile if
1775 : /// [requestProfile] is true.
1776 8 : Future<User?> requestUser(
1777 : String mxID, {
1778 : bool ignoreErrors = false,
1779 : bool requestState = true,
1780 : bool requestProfile = true,
1781 : }) async {
1782 16 : assert(mxID.isValidMatrixId);
1783 :
1784 : final parameters = (
1785 : mxID: mxID,
1786 : ignoreErrors: ignoreErrors,
1787 : requestState: requestState,
1788 : requestProfile: requestProfile,
1789 : );
1790 :
1791 24 : final cache = _inflightUserRequests[parameters] ??= AsyncCache.ephemeral();
1792 :
1793 : try {
1794 24 : final user = await cache.fetch(() => _requestUser(
1795 : mxID,
1796 : ignoreErrors: ignoreErrors,
1797 : requestState: requestState,
1798 : requestProfile: requestProfile,
1799 : ));
1800 16 : _inflightUserRequests.remove(parameters);
1801 : return user;
1802 : } catch (_) {
1803 2 : _inflightUserRequests.remove(parameters);
1804 : rethrow;
1805 : }
1806 : }
1807 :
1808 : /// Searches for the event in the local cache and then on the server if not
1809 : /// found. Returns null if not found anywhere.
1810 4 : Future<Event?> getEventById(String eventID) async {
1811 : try {
1812 12 : final dbEvent = await client.database?.getEventById(eventID, this);
1813 : if (dbEvent != null) return dbEvent;
1814 12 : final matrixEvent = await client.getOneRoomEvent(id, eventID);
1815 4 : final event = Event.fromMatrixEvent(matrixEvent, this);
1816 12 : if (event.type == EventTypes.Encrypted && client.encryptionEnabled) {
1817 : // attempt decryption
1818 6 : return await client.encryption?.decryptRoomEvent(
1819 2 : id,
1820 : event,
1821 : );
1822 : }
1823 : return event;
1824 2 : } on MatrixException catch (err) {
1825 4 : if (err.errcode == 'M_NOT_FOUND') {
1826 : return null;
1827 : }
1828 : rethrow;
1829 : }
1830 : }
1831 :
1832 : /// Returns the power level of the given user ID.
1833 : /// If a user_id is in the users list, then that user_id has the associated
1834 : /// power level. Otherwise they have the default level users_default.
1835 : /// If users_default is not supplied, it is assumed to be 0. If the room
1836 : /// contains no m.room.power_levels event, the room’s creator has a power
1837 : /// level of 100, and all other users have a power level of 0.
1838 8 : int getPowerLevelByUserId(String userId) {
1839 14 : final powerLevelMap = getState(EventTypes.RoomPowerLevels)?.content;
1840 :
1841 : final userSpecificPowerLevel =
1842 12 : powerLevelMap?.tryGetMap<String, Object?>('users')?.tryGet<int>(userId);
1843 :
1844 6 : final defaultUserPowerLevel = powerLevelMap?.tryGet<int>('users_default');
1845 :
1846 : final fallbackPowerLevel =
1847 18 : getState(EventTypes.RoomCreate)?.senderId == userId ? 100 : 0;
1848 :
1849 : return userSpecificPowerLevel ??
1850 : defaultUserPowerLevel ??
1851 : fallbackPowerLevel;
1852 : }
1853 :
1854 : /// Returns the user's own power level.
1855 24 : int get ownPowerLevel => getPowerLevelByUserId(client.userID!);
1856 :
1857 : /// Returns the power levels from all users for this room or null if not given.
1858 0 : @Deprecated('Use `getPowerLevelByUserId(String userId)` instead')
1859 : Map<String, int>? get powerLevels {
1860 : final powerLevelState =
1861 0 : getState(EventTypes.RoomPowerLevels)?.content['users'];
1862 0 : return (powerLevelState is Map<String, int>) ? powerLevelState : null;
1863 : }
1864 :
1865 : /// Uploads a new user avatar for this room. Returns the event ID of the new
1866 : /// m.room.avatar event. Leave empty to remove the current avatar.
1867 2 : Future<String> setAvatar(MatrixFile? file) async {
1868 : final uploadResp = file == null
1869 : ? null
1870 8 : : await client.uploadContent(file.bytes, filename: file.name);
1871 4 : return await client.setRoomStateWithKey(
1872 2 : id,
1873 : EventTypes.RoomAvatar,
1874 : '',
1875 2 : {
1876 4 : if (uploadResp != null) 'url': uploadResp.toString(),
1877 : },
1878 : );
1879 : }
1880 :
1881 : /// The level required to ban a user.
1882 4 : bool get canBan =>
1883 8 : (getState(EventTypes.RoomPowerLevels)?.content.tryGet<int>('ban') ??
1884 4 : 50) <=
1885 4 : ownPowerLevel;
1886 :
1887 : /// returns if user can change a particular state event by comparing `ownPowerLevel`
1888 : /// with possible overrides in `events`, if not present compares `ownPowerLevel`
1889 : /// with state_default
1890 6 : bool canChangeStateEvent(String action) {
1891 18 : return powerForChangingStateEvent(action) <= ownPowerLevel;
1892 : }
1893 :
1894 : /// returns the powerlevel required for changing the `action` defaults to
1895 : /// state_default if `action` isn't specified in events override.
1896 : /// If there is no state_default in the m.room.power_levels event, the
1897 : /// state_default is 50. If the room contains no m.room.power_levels event,
1898 : /// the state_default is 0.
1899 6 : int powerForChangingStateEvent(String action) {
1900 10 : final powerLevelMap = getState(EventTypes.RoomPowerLevels)?.content;
1901 : if (powerLevelMap == null) return 0;
1902 : return powerLevelMap
1903 4 : .tryGetMap<String, Object?>('events')
1904 4 : ?.tryGet<int>(action) ??
1905 4 : powerLevelMap.tryGet<int>('state_default') ??
1906 : 50;
1907 : }
1908 :
1909 : /// if returned value is not null `EventTypes.GroupCallMember` is present
1910 : /// and group calls can be used
1911 2 : bool get groupCallsEnabledForEveryone {
1912 4 : final powerLevelMap = getState(EventTypes.RoomPowerLevels)?.content;
1913 : if (powerLevelMap == null) return false;
1914 4 : return powerForChangingStateEvent(EventTypes.GroupCallMember) <=
1915 2 : getDefaultPowerLevel(powerLevelMap);
1916 : }
1917 :
1918 4 : bool get canJoinGroupCall => canChangeStateEvent(EventTypes.GroupCallMember);
1919 :
1920 : /// sets the `EventTypes.GroupCallMember` power level to users default for
1921 : /// group calls, needs permissions to change power levels
1922 2 : Future<void> enableGroupCalls() async {
1923 2 : if (!canChangePowerLevel) return;
1924 4 : final currentPowerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content;
1925 : if (currentPowerLevelsMap != null) {
1926 : final newPowerLevelMap = currentPowerLevelsMap;
1927 2 : final eventsMap = newPowerLevelMap.tryGetMap<String, Object?>('events') ??
1928 2 : <String, Object?>{};
1929 4 : eventsMap.addAll({
1930 2 : EventTypes.GroupCallMember: getDefaultPowerLevel(currentPowerLevelsMap)
1931 : });
1932 4 : newPowerLevelMap.addAll({'events': eventsMap});
1933 4 : await client.setRoomStateWithKey(
1934 2 : id,
1935 : EventTypes.RoomPowerLevels,
1936 : '',
1937 : newPowerLevelMap,
1938 : );
1939 : }
1940 : }
1941 :
1942 : /// Takes in `[m.room.power_levels].content` and returns the default power level
1943 2 : int getDefaultPowerLevel(Map<String, dynamic> powerLevelMap) {
1944 2 : return powerLevelMap.tryGet('users_default') ?? 0;
1945 : }
1946 :
1947 : /// The default level required to send message events. This checks if the
1948 : /// user is capable of sending `m.room.message` events.
1949 : /// Please be aware that this also returns false
1950 : /// if the room is encrypted but the client is not able to use encryption.
1951 : /// If you do not want this check or want to check other events like
1952 : /// `m.sticker` use `canSendEvent('<event-type>')`.
1953 2 : bool get canSendDefaultMessages {
1954 2 : if (encrypted && !client.encryptionEnabled) return false;
1955 :
1956 4 : return canSendEvent(encrypted ? EventTypes.Encrypted : EventTypes.Message);
1957 : }
1958 :
1959 : /// The level required to invite a user.
1960 2 : bool get canInvite =>
1961 6 : (getState(EventTypes.RoomPowerLevels)?.content.tryGet<int>('invite') ??
1962 2 : 0) <=
1963 2 : ownPowerLevel;
1964 :
1965 : /// The level required to kick a user.
1966 4 : bool get canKick =>
1967 8 : (getState(EventTypes.RoomPowerLevels)?.content.tryGet<int>('kick') ??
1968 4 : 50) <=
1969 4 : ownPowerLevel;
1970 :
1971 : /// The level required to redact an event.
1972 2 : bool get canRedact =>
1973 6 : (getState(EventTypes.RoomPowerLevels)?.content.tryGet<int>('redact') ??
1974 2 : 50) <=
1975 2 : ownPowerLevel;
1976 :
1977 : /// The default level required to send state events. Can be overridden by the events key.
1978 0 : bool get canSendDefaultStates {
1979 0 : final powerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content;
1980 0 : if (powerLevelsMap == null) return 0 <= ownPowerLevel;
1981 0 : return (getState(EventTypes.RoomPowerLevels)
1982 0 : ?.content
1983 0 : .tryGet<int>('state_default') ??
1984 0 : 50) <=
1985 0 : ownPowerLevel;
1986 : }
1987 :
1988 6 : bool get canChangePowerLevel =>
1989 6 : canChangeStateEvent(EventTypes.RoomPowerLevels);
1990 :
1991 : /// The level required to send a certain event. Defaults to 0 if there is no
1992 : /// events_default set or there is no power level state in the room.
1993 2 : bool canSendEvent(String eventType) {
1994 4 : final powerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content;
1995 :
1996 : final pl = powerLevelsMap
1997 2 : ?.tryGetMap<String, Object?>('events')
1998 2 : ?.tryGet<int>(eventType) ??
1999 2 : powerLevelsMap?.tryGet<int>('events_default') ??
2000 : 0;
2001 :
2002 4 : return ownPowerLevel >= pl;
2003 : }
2004 :
2005 : /// The power level requirements for specific notification types.
2006 2 : bool canSendNotification(String userid, {String notificationType = 'room'}) {
2007 2 : final userLevel = getPowerLevelByUserId(userid);
2008 2 : final notificationLevel = getState(EventTypes.RoomPowerLevels)
2009 2 : ?.content
2010 2 : .tryGetMap<String, Object?>('notifications')
2011 2 : ?.tryGet<int>(notificationType) ??
2012 : 50;
2013 :
2014 2 : return userLevel >= notificationLevel;
2015 : }
2016 :
2017 : /// Returns the [PushRuleState] for this room, based on the m.push_rules stored in
2018 : /// the account_data.
2019 2 : PushRuleState get pushRuleState {
2020 : final globalPushRules =
2021 10 : client.accountData['m.push_rules']?.content['global'];
2022 2 : if (globalPushRules is! Map) {
2023 : return PushRuleState.notify;
2024 : }
2025 :
2026 4 : if (globalPushRules['override'] is List) {
2027 4 : for (final pushRule in globalPushRules['override']) {
2028 6 : if (pushRule['rule_id'] == id) {
2029 8 : if (pushRule['actions'].indexOf('dont_notify') != -1) {
2030 : return PushRuleState.dontNotify;
2031 : }
2032 : break;
2033 : }
2034 : }
2035 : }
2036 :
2037 4 : if (globalPushRules['room'] is List) {
2038 4 : for (final pushRule in globalPushRules['room']) {
2039 6 : if (pushRule['rule_id'] == id) {
2040 8 : if (pushRule['actions'].indexOf('dont_notify') != -1) {
2041 : return PushRuleState.mentionsOnly;
2042 : }
2043 : break;
2044 : }
2045 : }
2046 : }
2047 :
2048 : return PushRuleState.notify;
2049 : }
2050 :
2051 : /// Sends a request to the homeserver to set the [PushRuleState] for this room.
2052 : /// Returns ErrorResponse if something goes wrong.
2053 2 : Future<void> setPushRuleState(PushRuleState newState) async {
2054 4 : if (newState == pushRuleState) return;
2055 : dynamic resp;
2056 : switch (newState) {
2057 : // All push notifications should be sent to the user
2058 2 : case PushRuleState.notify:
2059 4 : if (pushRuleState == PushRuleState.dontNotify) {
2060 6 : await client.deletePushRule('global', PushRuleKind.override, id);
2061 0 : } else if (pushRuleState == PushRuleState.mentionsOnly) {
2062 0 : await client.deletePushRule('global', PushRuleKind.room, id);
2063 : }
2064 : break;
2065 : // Only when someone mentions the user, a push notification should be sent
2066 2 : case PushRuleState.mentionsOnly:
2067 4 : if (pushRuleState == PushRuleState.dontNotify) {
2068 6 : await client.deletePushRule('global', PushRuleKind.override, id);
2069 4 : await client.setPushRule(
2070 : 'global',
2071 : PushRuleKind.room,
2072 2 : id,
2073 2 : [PushRuleAction.dontNotify],
2074 : );
2075 0 : } else if (pushRuleState == PushRuleState.notify) {
2076 0 : await client.setPushRule(
2077 : 'global',
2078 : PushRuleKind.room,
2079 0 : id,
2080 0 : [PushRuleAction.dontNotify],
2081 : );
2082 : }
2083 : break;
2084 : // No push notification should be ever sent for this room.
2085 0 : case PushRuleState.dontNotify:
2086 0 : if (pushRuleState == PushRuleState.mentionsOnly) {
2087 0 : await client.deletePushRule('global', PushRuleKind.room, id);
2088 : }
2089 0 : await client.setPushRule(
2090 : 'global',
2091 : PushRuleKind.override,
2092 0 : id,
2093 0 : [PushRuleAction.dontNotify],
2094 0 : conditions: [
2095 0 : PushCondition(kind: 'event_match', key: 'room_id', pattern: id)
2096 : ],
2097 : );
2098 : }
2099 : return resp;
2100 : }
2101 :
2102 : /// Redacts this event. Throws `ErrorResponse` on error.
2103 1 : Future<String?> redactEvent(String eventId,
2104 : {String? reason, String? txid}) async {
2105 : // Create new transaction id
2106 : String messageID;
2107 2 : final now = DateTime.now().millisecondsSinceEpoch;
2108 : if (txid == null) {
2109 0 : messageID = 'msg$now';
2110 : } else {
2111 : messageID = txid;
2112 : }
2113 1 : final data = <String, dynamic>{};
2114 1 : if (reason != null) data['reason'] = reason;
2115 2 : return await client.redactEvent(
2116 1 : id,
2117 : eventId,
2118 : messageID,
2119 : reason: reason,
2120 : );
2121 : }
2122 :
2123 : /// This tells the server that the user is typing for the next N milliseconds
2124 : /// where N is the value specified in the timeout key. Alternatively, if typing is false,
2125 : /// it tells the server that the user has stopped typing.
2126 0 : Future<void> setTyping(bool isTyping, {int? timeout}) =>
2127 0 : client.setTyping(client.userID!, id, isTyping, timeout: timeout);
2128 :
2129 : /// A room may be public meaning anyone can join the room without any prior action. Alternatively,
2130 : /// it can be invite meaning that a user who wishes to join the room must first receive an invite
2131 : /// to the room from someone already inside of the room. Currently, knock and private are reserved
2132 : /// keywords which are not implemented.
2133 2 : JoinRules? get joinRules {
2134 : final joinRulesString =
2135 6 : getState(EventTypes.RoomJoinRules)?.content.tryGet<String>('join_rule');
2136 : return JoinRules.values
2137 8 : .singleWhereOrNull((element) => element.text == joinRulesString);
2138 : }
2139 :
2140 : /// Changes the join rules. You should check first if the user is able to change it.
2141 2 : Future<void> setJoinRules(JoinRules joinRules) async {
2142 4 : await client.setRoomStateWithKey(
2143 2 : id,
2144 : EventTypes.RoomJoinRules,
2145 : '',
2146 2 : {
2147 4 : 'join_rule': joinRules.toString().replaceAll('JoinRules.', ''),
2148 : },
2149 : );
2150 : return;
2151 : }
2152 :
2153 : /// Whether the user has the permission to change the join rules.
2154 4 : bool get canChangeJoinRules => canChangeStateEvent(EventTypes.RoomJoinRules);
2155 :
2156 : /// This event controls whether guest users are allowed to join rooms. If this event
2157 : /// is absent, servers should act as if it is present and has the guest_access value "forbidden".
2158 2 : GuestAccess get guestAccess {
2159 2 : final guestAccessString = getState(EventTypes.GuestAccess)
2160 2 : ?.content
2161 2 : .tryGet<String>('guest_access');
2162 2 : return GuestAccess.values.singleWhereOrNull(
2163 6 : (element) => element.text == guestAccessString) ??
2164 : GuestAccess.forbidden;
2165 : }
2166 :
2167 : /// Changes the guest access. You should check first if the user is able to change it.
2168 2 : Future<void> setGuestAccess(GuestAccess guestAccess) async {
2169 4 : await client.setRoomStateWithKey(
2170 2 : id,
2171 : EventTypes.GuestAccess,
2172 : '',
2173 2 : {
2174 2 : 'guest_access': guestAccess.text,
2175 : },
2176 : );
2177 : return;
2178 : }
2179 :
2180 : /// Whether the user has the permission to change the guest access.
2181 4 : bool get canChangeGuestAccess => canChangeStateEvent(EventTypes.GuestAccess);
2182 :
2183 : /// This event controls whether a user can see the events that happened in a room from before they joined.
2184 2 : HistoryVisibility? get historyVisibility {
2185 2 : final historyVisibilityString = getState(EventTypes.HistoryVisibility)
2186 2 : ?.content
2187 2 : .tryGet<String>('history_visibility');
2188 2 : return HistoryVisibility.values.singleWhereOrNull(
2189 6 : (element) => element.text == historyVisibilityString);
2190 : }
2191 :
2192 : /// Changes the history visibility. You should check first if the user is able to change it.
2193 2 : Future<void> setHistoryVisibility(HistoryVisibility historyVisibility) async {
2194 4 : await client.setRoomStateWithKey(
2195 2 : id,
2196 : EventTypes.HistoryVisibility,
2197 : '',
2198 2 : {
2199 2 : 'history_visibility': historyVisibility.text,
2200 : },
2201 : );
2202 : return;
2203 : }
2204 :
2205 : /// Whether the user has the permission to change the history visibility.
2206 2 : bool get canChangeHistoryVisibility =>
2207 2 : canChangeStateEvent(EventTypes.HistoryVisibility);
2208 :
2209 : /// Returns the encryption algorithm. Currently only `m.megolm.v1.aes-sha2` is supported.
2210 : /// Returns null if there is no encryption algorithm.
2211 32 : String? get encryptionAlgorithm =>
2212 92 : getState(EventTypes.Encryption)?.parsedRoomEncryptionContent.algorithm;
2213 :
2214 : /// Checks if this room is encrypted.
2215 64 : bool get encrypted => encryptionAlgorithm != null;
2216 :
2217 2 : Future<void> enableEncryption({int algorithmIndex = 0}) async {
2218 2 : if (encrypted) throw ('Encryption is already enabled!');
2219 2 : final algorithm = Client.supportedGroupEncryptionAlgorithms[algorithmIndex];
2220 4 : await client.setRoomStateWithKey(
2221 2 : id,
2222 : EventTypes.Encryption,
2223 : '',
2224 2 : {
2225 : 'algorithm': algorithm,
2226 : },
2227 : );
2228 : return;
2229 : }
2230 :
2231 : /// Returns all known device keys for all participants in this room.
2232 7 : Future<List<DeviceKeys>> getUserDeviceKeys() async {
2233 14 : await client.userDeviceKeysLoading;
2234 7 : final deviceKeys = <DeviceKeys>[];
2235 7 : final users = await requestParticipants();
2236 11 : for (final user in users) {
2237 24 : final userDeviceKeys = client.userDeviceKeys[user.id]?.deviceKeys.values;
2238 12 : if ([Membership.invite, Membership.join].contains(user.membership) &&
2239 : userDeviceKeys != null) {
2240 8 : for (final deviceKeyEntry in userDeviceKeys) {
2241 4 : deviceKeys.add(deviceKeyEntry);
2242 : }
2243 : }
2244 : }
2245 : return deviceKeys;
2246 : }
2247 :
2248 1 : Future<void> requestSessionKey(String sessionId, String senderKey) async {
2249 2 : if (!client.encryptionEnabled) {
2250 : return;
2251 : }
2252 4 : await client.encryption?.keyManager.request(this, sessionId, senderKey);
2253 : }
2254 :
2255 8 : Future<void> _handleFakeSync(SyncUpdate syncUpdate,
2256 : {Direction? direction}) async {
2257 16 : if (client.database != null) {
2258 28 : await client.database?.transaction(() async {
2259 14 : await client.handleSync(syncUpdate, direction: direction);
2260 : });
2261 : } else {
2262 2 : await client.handleSync(syncUpdate, direction: direction);
2263 : }
2264 : }
2265 :
2266 : /// Whether this is an extinct room which has been archived in favor of a new
2267 : /// room which replaces this. Use `getLegacyRoomInformations()` to get more
2268 : /// informations about it if this is true.
2269 0 : bool get isExtinct => getState(EventTypes.RoomTombstone) != null;
2270 :
2271 : /// Returns informations about how this room is
2272 0 : TombstoneContent? get extinctInformations =>
2273 0 : getState(EventTypes.RoomTombstone)?.parsedTombstoneContent;
2274 :
2275 : /// Checks if the `m.room.create` state has a `type` key with the value
2276 : /// `m.space`.
2277 2 : bool get isSpace =>
2278 8 : getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
2279 : RoomCreationTypes.mSpace;
2280 :
2281 : /// The parents of this room. Currently this SDK doesn't yet set the canonical
2282 : /// flag and is not checking if this room is in fact a child of this space.
2283 : /// You should therefore not rely on this and always check the children of
2284 : /// the space.
2285 2 : List<SpaceParent> get spaceParents =>
2286 4 : states[EventTypes.SpaceParent]
2287 2 : ?.values
2288 6 : .map((state) => SpaceParent.fromState(state))
2289 8 : .where((child) => child.via.isNotEmpty)
2290 2 : .toList() ??
2291 2 : [];
2292 :
2293 : /// List all children of this space. Children without a `via` domain will be
2294 : /// ignored.
2295 : /// Children are sorted by the `order` while those without this field will be
2296 : /// sorted at the end of the list.
2297 4 : List<SpaceChild> get spaceChildren => !isSpace
2298 0 : ? throw Exception('Room is not a space!')
2299 4 : : (states[EventTypes.SpaceChild]
2300 2 : ?.values
2301 6 : .map((state) => SpaceChild.fromState(state))
2302 8 : .where((child) => child.via.isNotEmpty)
2303 2 : .toList() ??
2304 2 : [])
2305 12 : ..sort((a, b) => a.order.isEmpty || b.order.isEmpty
2306 6 : ? b.order.compareTo(a.order)
2307 6 : : a.order.compareTo(b.order));
2308 :
2309 : /// Adds or edits a child of this space.
2310 0 : Future<void> setSpaceChild(
2311 : String roomId, {
2312 : List<String>? via,
2313 : String? order,
2314 : bool? suggested,
2315 : }) async {
2316 0 : if (!isSpace) throw Exception('Room is not a space!');
2317 0 : via ??= [client.userID!.domain!];
2318 0 : await client.setRoomStateWithKey(id, EventTypes.SpaceChild, roomId, {
2319 0 : 'via': via,
2320 0 : if (order != null) 'order': order,
2321 0 : if (suggested != null) 'suggested': suggested,
2322 : });
2323 0 : await client.setRoomStateWithKey(roomId, EventTypes.SpaceParent, id, {
2324 : 'via': via,
2325 : });
2326 : return;
2327 : }
2328 :
2329 : /// Generates a matrix.to link with appropriate routing info to share the room
2330 2 : Future<Uri> matrixToInviteLink() async {
2331 4 : if (canonicalAlias.isNotEmpty) {
2332 2 : return Uri.parse(
2333 6 : 'https://matrix.to/#/${Uri.encodeComponent(canonicalAlias)}');
2334 : }
2335 2 : final List queryParameters = [];
2336 4 : final users = await requestParticipants([Membership.join]);
2337 4 : final currentPowerLevelsMap = getState(EventTypes.RoomPowerLevels)?.content;
2338 :
2339 2 : final temp = List<User>.from(users);
2340 8 : temp.removeWhere((user) => user.powerLevel < 50);
2341 : if (currentPowerLevelsMap != null) {
2342 : // just for weird rooms
2343 2 : temp.removeWhere((user) =>
2344 0 : user.powerLevel < getDefaultPowerLevel(currentPowerLevelsMap));
2345 : }
2346 :
2347 2 : if (temp.isNotEmpty) {
2348 0 : temp.sort((a, b) => a.powerLevel.compareTo(b.powerLevel));
2349 0 : if (temp.last.id.domain != null) {
2350 0 : queryParameters.add(temp.last.id.domain!);
2351 : }
2352 : }
2353 :
2354 2 : final Map<String, int> servers = {};
2355 4 : for (final user in users) {
2356 4 : if (user.id.domain != null) {
2357 6 : if (servers.containsKey(user.id.domain!)) {
2358 0 : servers[user.id.domain!] = servers[user.id.domain!]! + 1;
2359 : } else {
2360 6 : servers[user.id.domain!] = 1;
2361 : }
2362 : }
2363 : }
2364 6 : final sortedServers = Map.fromEntries(servers.entries.toList()
2365 10 : ..sort((e1, e2) => e2.value.compareTo(e1.value)))
2366 2 : .keys
2367 2 : .take(3);
2368 4 : for (final server in sortedServers) {
2369 2 : if (!queryParameters.contains(server)) {
2370 2 : queryParameters.add(server);
2371 : }
2372 : }
2373 :
2374 : var queryString = '?';
2375 8 : for (var i = 0; i < min(queryParameters.length, 3); i++) {
2376 2 : if (i != 0) {
2377 2 : queryString += '&';
2378 : }
2379 6 : queryString += 'via=${queryParameters[i]}';
2380 : }
2381 2 : return Uri.parse(
2382 6 : 'https://matrix.to/#/${Uri.encodeComponent(id)}$queryString');
2383 : }
2384 :
2385 : /// Remove a child from this space by setting the `via` to an empty list.
2386 0 : Future<void> removeSpaceChild(String roomId) => !isSpace
2387 0 : ? throw Exception('Room is not a space!')
2388 0 : : setSpaceChild(roomId, via: const []);
2389 :
2390 1 : @override
2391 4 : bool operator ==(Object other) => (other is Room && other.id == id);
2392 :
2393 0 : @override
2394 0 : int get hashCode => Object.hashAll([id]);
2395 : }
2396 :
2397 : enum EncryptionHealthState {
2398 : allVerified,
2399 : unverifiedDevices,
2400 : }
|