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