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