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