LCOV - code coverage report
Current view: top level - lib/src - room.dart (source / functions) Hit Total Coverage
Test: merged.info Lines: 727 987 73.7 %
Date: 2024-12-27 12:56:30 Functions: 0 0 -

          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             : }

Generated by: LCOV version 1.14