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

Generated by: LCOV version 1.14