LCOV - code coverage report
Current view: top level - lib/src - room.dart (source / functions) Coverage Total Hit
Test: merged.info Lines: 76.4 % 1050 802
Test Date: 2025-10-13 02:23:18 Functions: - 0 0

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

Generated by: LCOV version 2.0-1