From ac16724841dbe43fddc9a66f06e1e33af06ee78a Mon Sep 17 00:00:00 2001 From: Lanna Michalke Date: Wed, 27 Apr 2022 10:13:22 +0200 Subject: [PATCH 1/2] chore: make Client.accountData read-only - for external access, Client.accountData should be read only - added corresponding getter and private Map Signed-off-by: Lanna Michalke --- lib/matrix.dart | 3 ++ lib/src/client.dart | 38 +++++++------ lib/src/room.dart | 37 ------------- lib/widget.dart | 128 -------------------------------------------- 4 files changed, 26 insertions(+), 180 deletions(-) delete mode 100644 lib/widget.dart diff --git a/lib/matrix.dart b/lib/matrix.dart index a8bd09db..bfacf431 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -49,3 +49,6 @@ export 'src/utils/to_device_event.dart'; export 'src/utils/uia_request.dart'; export 'src/utils/uri_extension.dart'; export 'src/voip_content.dart'; + +export 'msc_extensions/extension_recent_emoji/recent_emoji.dart'; +export 'msc_extensions/msc_1236_widgets/msc_1236_widgets.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 2e8a78f8..c320dad5 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -224,6 +224,7 @@ class Client extends MatrixApi { /// Returns the current login state. LoginState get loginState => __loginState; LoginState __loginState; + set _loginState(LoginState state) { __loginState = state; onLoginStateChanged.add(state); @@ -256,7 +257,9 @@ class Client extends MatrixApi { } /// Key/Value store of account data. - Map accountData = {}; + Map _accountData = {}; + + Map get accountData => _accountData; /// Presences of users by a given matrix ID Map presences = {}; @@ -287,12 +290,12 @@ class Client extends MatrixApi { } Map get directChats => - accountData['m.direct']?.content ?? {}; + _accountData['m.direct']?.content ?? {}; /// Returns the (first) room ID from the store which is a private chat with the user [userId]. /// Returns null if there is none. String? getDirectChatFromUserId(String userId) { - final directChats = accountData['m.direct']?.content[userId]; + final directChats = _accountData['m.direct']?.content[userId]; if (directChats is List && directChats.isNotEmpty) { final potentialRooms = directChats .cast() @@ -834,13 +837,13 @@ class Client extends MatrixApi { /// Returns the global push rules for the logged in user. PushRuleSet? get globalPushRules { - final pushrules = accountData['m.push_rules']?.content['global']; + final pushrules = _accountData['m.push_rules']?.content['global']; return pushrules != null ? PushRuleSet.fromJson(pushrules) : null; } /// Returns the device push rules for the logged in user. PushRuleSet? get devicePushRules { - final pushrules = accountData['m.push_rules']?.content['device']; + final pushrules = _accountData['m.push_rules']?.content['device']; return pushrules != null ? PushRuleSet.fromJson(pushrules) : null; } @@ -1104,7 +1107,7 @@ class Client extends MatrixApi { /// get them from the database. /// /// Set [waitForFirstSync] and [waitUntilLoadCompletedLoaded] to false to speed this - /// up. You can then wait for `roomsLoading`, `accountDataLoading` and + /// up. You can then wait for `roomsLoading`, `_accountDataLoading` and /// `userDeviceKeysLoading` where it is necessary. Future init({ String? newToken, @@ -1232,13 +1235,13 @@ class Client extends MatrixApi { _rooms = rooms; _sortRooms(); }); - accountDataLoading = - database.getAccountData().then((data) => accountData = data); + _accountDataLoading = + database.getAccountData().then((data) => _accountData = data); presences.clear(); if (waitUntilLoadCompletedLoaded) { await userDeviceKeysLoading; await roomsLoading; - await accountDataLoading; + await _accountDataLoading; } } _initLock = false; @@ -1379,7 +1382,7 @@ class Client extends MatrixApi { if (database != null) { await userDeviceKeysLoading; await roomsLoading; - await accountDataLoading; + await _accountDataLoading; _currentTransaction = database.transaction(() async { await _handleSync(syncResp); if (prevBatch != syncResp.nextBatch) { @@ -1888,7 +1891,9 @@ class Client extends MatrixApi { Future? userDeviceKeysLoading; Future? roomsLoading; - Future? accountDataLoading; + Future? _accountDataLoading; + + Future? get accountDataLoading => _accountDataLoading; /// A map of known device keys per user. Map get userDeviceKeys => _userDeviceKeys; @@ -2338,7 +2343,7 @@ class Client extends MatrixApi { /// rule of the push rules: https://matrix.org/docs/spec/client_server/r0.6.0#m-rule-master bool get allPushNotificationsMuted { final Map? globalPushRules = - accountData['m.push_rules']?.content['global']; + _accountData['m.push_rules']?.content['global']; if (globalPushRules == null) return false; if (globalPushRules['override'] is List) { @@ -2418,11 +2423,11 @@ class Client extends MatrixApi { } /// A list of mxids of users who are ignored. - List get ignoredUsers => (accountData + List get ignoredUsers => (_accountData .containsKey('m.ignored_user_list') && - accountData['m.ignored_user_list']?.content['ignored_users'] is Map) + _accountData['m.ignored_user_list']?.content['ignored_users'] is Map) ? List.from( - accountData['m.ignored_user_list']?.content['ignored_users'].keys) + _accountData['m.ignored_user_list']?.content['ignored_users'].keys) : []; /// Ignore another user. This will clear the local cached messages to @@ -2630,6 +2635,7 @@ class SyncStatusUpdate { final SyncStatus status; final SdkError? error; final double? progress; + const SyncStatusUpdate(this.status, {this.error, this.progress}); } @@ -2643,6 +2649,7 @@ enum SyncStatus { class BadServerVersionsException implements Exception { final Set serverVersions, supportedVersions; + BadServerVersionsException(this.serverVersions, this.supportedVersions); @override @@ -2652,6 +2659,7 @@ class BadServerVersionsException implements Exception { class BadServerLoginTypesException implements Exception { final Set serverLoginTypes, supportedLoginTypes; + BadServerLoginTypesException(this.serverLoginTypes, this.supportedLoginTypes); @override diff --git a/lib/src/room.dart b/lib/src/room.dart index 757dde11..04fe508f 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -25,7 +25,6 @@ import 'package:html_unescape/html_unescape.dart'; import 'package:matrix/src/utils/crypto/crypto.dart'; import 'package:matrix/src/utils/file_send_request_credentials.dart'; import 'package:matrix/src/utils/space_child.dart'; -import 'package:matrix/widget.dart'; import '../matrix.dart'; import 'utils/markdown.dart'; @@ -357,42 +356,6 @@ class Room { : []; } - /// Returns all present Widgets in the room. - List get widgets => { - ...states['m.widget'] ?? states['im.vector.modular.widgets'] ?? {}, - }.values.expand((e) { - try { - return [MatrixWidget.fromJson(e.content, this)]; - } catch (_) { - return []; - } - }).toList(); - - Future addWidget(MatrixWidget widget) { - final user = client.userID; - final widgetId = - widget.name!.toLowerCase().replaceAll(RegExp(r'\W'), '_') + '_' + user!; - - final json = widget.toJson(); - json['creatorUserId'] = user; - json['id'] = widgetId; - return client.setRoomStateWithKey( - id, - 'im.vector.modular.widgets', - widgetId, - json, - ); - } - - Future deleteWidget(String widgetId) { - return client.setRoomStateWithKey( - id, - 'im.vector.modular.widgets', - widgetId, - {}, - ); - } - /// Your current client instance. final Client client; diff --git a/lib/widget.dart b/lib/widget.dart deleted file mode 100644 index bc97ec30..00000000 --- a/lib/widget.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:matrix/src/room.dart'; - -class MatrixWidget { - final Room room; - final String? creatorUserId; - final Map? data; - final String? id; - final String? name; - final String type; - - /// use [buildWidgetUrl] instead - final String url; - final bool waitForIframeLoad; - - MatrixWidget({ - required this.room, - this.creatorUserId, - this.data = const {}, - this.id, - required this.name, - required this.type, - required this.url, - this.waitForIframeLoad = false, - }); - - factory MatrixWidget.fromJson(Map json, Room room) => - MatrixWidget( - room: room, - creatorUserId: - json.containsKey('creatorUserId') ? json['creatorUserId'] : null, - data: json.containsKey('data') ? json['data'] : {}, - id: json.containsKey('id') ? json['id'] : null, - name: json['name'], - type: json['type'], - url: json['url'], - waitForIframeLoad: json.containsKey('waitForIframeLoad') - ? json['waitForIframeLoad'] - : false, - ); - - /// creates an `m.etherpad` [MatrixWidget] - factory MatrixWidget.etherpad(Room room, String name, Uri url) => - MatrixWidget( - room: room, - name: name, - type: 'm.etherpad', - url: url.toString(), - data: { - 'url': url.toString(), - }, - ); - - /// creates an `m.jitsi` [MatrixWidget] - factory MatrixWidget.jitsi(Room room, String name, Uri url, - {bool isAudioOnly = false}) => - MatrixWidget( - room: room, - name: name, - type: 'm.jitsi', - url: url.toString(), - data: { - 'domain': url.host, - 'conferenceId': url.pathSegments.last, - 'isAudioOnly': isAudioOnly, - }, - ); - - /// creates an `m.video` [MatrixWidget] - factory MatrixWidget.video(Room room, String name, Uri url) => MatrixWidget( - room: room, - name: name, - type: 'm.video', - url: url.toString(), - data: { - 'url': url.toString(), - }, - ); - - /// creates an `m.custom` [MatrixWidget] - factory MatrixWidget.custom(Room room, String name, Uri url) => MatrixWidget( - room: room, - name: name, - type: 'm.custom', - url: url.toString(), - data: { - 'url': url.toString(), - }, - ); - - Future buildWidgetUrl() async { - // See https://github.com/matrix-org/matrix-doc/issues/1236 for a - // description, specifically the section - // `What does the other stuff in content mean?` - final userProfile = await room.client.ownProfile; - var parsedUri = url; - - // a key-value map with the strings to be replaced - final replaceMap = { - r'$matrix_user_id': userProfile.userId, - r'$matrix_room_id': room.id, - r'$matrix_display_name': userProfile.displayName ?? '', - r'$matrix_avatar_url': userProfile.avatarUrl?.toString() ?? '', - // removing potentially dangerous keys containing anything but - // `[a-zA-Z0-9_-]` as well as non string values - if (data != null) - ...Map.from(data!) - ..removeWhere((key, value) => - !RegExp(r'^[\w-]+$').hasMatch(key) || !value is String) - ..map((key, value) => MapEntry('\$key', value)), - }; - - replaceMap.forEach((key, value) { - parsedUri = parsedUri.replaceAll(key, Uri.encodeComponent(value)); - }); - - return Uri.parse(parsedUri); - } - - Map toJson() => { - 'creatorUserId': creatorUserId, - 'data': data, - 'id': id, - 'name': name, - 'type': type, - 'url': url, - 'waitForIframeLoad': waitForIframeLoad, - }; -} From ecdbb06118b0075dc23d52e400eece1802cec2dc Mon Sep 17 00:00:00 2001 From: Lanna Michalke Date: Wed, 27 Apr 2022 10:14:13 +0200 Subject: [PATCH 2/2] feat: introduce new MSC library architecture - migrated to more useful MSC directory structure - migrate Widgets API into new structure - add recent emoji API into new structure The recent emoji API is non-standard and should be compatible with Element. Signed-off-by: Lanna Michalke --- lib/msc_extensions/README.md | 21 +++ .../extension_recent_emoji/recent_emoji.dart | 51 +++++++ .../msc_1236_widgets/msc_1236_widgets.dart | 43 ++++++ .../msc_1236_widgets/src/widget.dart | 128 ++++++++++++++++++ test/client_test.dart | 9 ++ test/fake_matrix_api.dart | 2 + 6 files changed, 254 insertions(+) create mode 100644 lib/msc_extensions/README.md create mode 100644 lib/msc_extensions/extension_recent_emoji/recent_emoji.dart create mode 100644 lib/msc_extensions/msc_1236_widgets/msc_1236_widgets.dart create mode 100644 lib/msc_extensions/msc_1236_widgets/src/widget.dart diff --git a/lib/msc_extensions/README.md b/lib/msc_extensions/README.md new file mode 100644 index 00000000..44934b35 --- /dev/null +++ b/lib/msc_extensions/README.md @@ -0,0 +1,21 @@ +# MSC extensions + +This folder contains non-spec feature implementations, usually proposed in Matrix Specification Changes (MSCs). + +Please try to cover the following conventions: + +- name your implementation `/lib/msc_extensions/msc_NUMER_short_name/whatsoever.dart`, + e.g. `/lib/msc_extensions/msc_3588_stories/stories.dart` +- please link the MSC in a comment in the first line: + ```dart + /// MSC3588: Stories As Rooms (https://github.com/matrix-org/matrix-spec-proposals/blob/d818877504cfda00ac52430ba5b9e8423c878b77/proposals/3588-stories-as-rooms.md) + ``` +- the implementation should provide an `extension NAME on ...` (usually `Client`) +- proprietary implementations without MSC should be given a useful name and + corresponding, useful documentation comments, e.g. `/lib/msc_extensions/extension_recent_emoji/recent_emoji.dart` +- Moreover, all implemented non-spec features should be listed below: + +## Implemented non-spec features + +- MSC 1236 - Widget API V2 +- `io.element.recent_emoji` - recent emoji sync in account data \ No newline at end of file diff --git a/lib/msc_extensions/extension_recent_emoji/recent_emoji.dart b/lib/msc_extensions/extension_recent_emoji/recent_emoji.dart new file mode 100644 index 00000000..255bf55d --- /dev/null +++ b/lib/msc_extensions/extension_recent_emoji/recent_emoji.dart @@ -0,0 +1,51 @@ +library extension_recent_emoji; + +import 'package:matrix/matrix.dart'; + +/// Syncs recent emojis in account data +/// +/// Keeps recently used emojis stored in account data by +/// +/// ```js +/// { // the account data +/// "io.element.recent_emoji": { +/// "recent_emoji" : { +/// "emoji character": n, // number used +/// } +/// } +/// } +/// ``` +/// +/// Proprietary extension by New Vector Ltd. +extension RecentEmojiExtension on Client { + /// returns the recently used emojis from the account data + /// + /// There's no corresponding standard or MSC, it's just the reverse-engineered + /// API from New Vector Ltd. + Map get recentEmojis => Map.fromEntries( + (accountData['io.element.recent_emoji']?.content['recent_emoji'] + as List? ?? + []) + .map( + (e) => MapEntry(e[0] as String, e[1] as int), + ), + ); + + /// +1 the stated emoji in the account data + Future addRecentEmoji(String emoji) async { + final data = recentEmojis; + if (data.containsKey(emoji)) { + data[emoji] = data[emoji]! + 1; + } else { + data[emoji] = 1; + } + return setRecentEmojiData(data); + } + + /// sets the raw recent emoji account data. Use [addRecentEmoji] instead + Future setRecentEmojiData(Map data) async { + final content = List.from(data.entries.map((e) => [e.key, e.value])); + return setAccountData( + userID!, 'io.element.recent_emoji', {'recent_emoji': content}); + } +} diff --git a/lib/msc_extensions/msc_1236_widgets/msc_1236_widgets.dart b/lib/msc_extensions/msc_1236_widgets/msc_1236_widgets.dart new file mode 100644 index 00000000..85eb3773 --- /dev/null +++ b/lib/msc_extensions/msc_1236_widgets/msc_1236_widgets.dart @@ -0,0 +1,43 @@ +library msc_1236_widgets; + +import 'package:matrix/matrix.dart'; + +export 'src/widget.dart'; + +extension MatrixWidgets on Room { + /// Returns all present Widgets in the room. + List get widgets => { + ...states['m.widget'] ?? states['im.vector.modular.widgets'] ?? {}, + }.values.expand((e) { + try { + return [MatrixWidget.fromJson(e.content, this)]; + } catch (_) { + return []; + } + }).toList(); + + Future addWidget(MatrixWidget widget) { + final user = client.userID; + final widgetId = + widget.name!.toLowerCase().replaceAll(RegExp(r'\W'), '_') + '_' + user!; + + final json = widget.toJson(); + json['creatorUserId'] = user; + json['id'] = widgetId; + return client.setRoomStateWithKey( + id, + 'im.vector.modular.widgets', + widgetId, + json, + ); + } + + Future deleteWidget(String widgetId) { + return client.setRoomStateWithKey( + id, + 'im.vector.modular.widgets', + widgetId, + {}, + ); + } +} diff --git a/lib/msc_extensions/msc_1236_widgets/src/widget.dart b/lib/msc_extensions/msc_1236_widgets/src/widget.dart new file mode 100644 index 00000000..bc97ec30 --- /dev/null +++ b/lib/msc_extensions/msc_1236_widgets/src/widget.dart @@ -0,0 +1,128 @@ +import 'package:matrix/src/room.dart'; + +class MatrixWidget { + final Room room; + final String? creatorUserId; + final Map? data; + final String? id; + final String? name; + final String type; + + /// use [buildWidgetUrl] instead + final String url; + final bool waitForIframeLoad; + + MatrixWidget({ + required this.room, + this.creatorUserId, + this.data = const {}, + this.id, + required this.name, + required this.type, + required this.url, + this.waitForIframeLoad = false, + }); + + factory MatrixWidget.fromJson(Map json, Room room) => + MatrixWidget( + room: room, + creatorUserId: + json.containsKey('creatorUserId') ? json['creatorUserId'] : null, + data: json.containsKey('data') ? json['data'] : {}, + id: json.containsKey('id') ? json['id'] : null, + name: json['name'], + type: json['type'], + url: json['url'], + waitForIframeLoad: json.containsKey('waitForIframeLoad') + ? json['waitForIframeLoad'] + : false, + ); + + /// creates an `m.etherpad` [MatrixWidget] + factory MatrixWidget.etherpad(Room room, String name, Uri url) => + MatrixWidget( + room: room, + name: name, + type: 'm.etherpad', + url: url.toString(), + data: { + 'url': url.toString(), + }, + ); + + /// creates an `m.jitsi` [MatrixWidget] + factory MatrixWidget.jitsi(Room room, String name, Uri url, + {bool isAudioOnly = false}) => + MatrixWidget( + room: room, + name: name, + type: 'm.jitsi', + url: url.toString(), + data: { + 'domain': url.host, + 'conferenceId': url.pathSegments.last, + 'isAudioOnly': isAudioOnly, + }, + ); + + /// creates an `m.video` [MatrixWidget] + factory MatrixWidget.video(Room room, String name, Uri url) => MatrixWidget( + room: room, + name: name, + type: 'm.video', + url: url.toString(), + data: { + 'url': url.toString(), + }, + ); + + /// creates an `m.custom` [MatrixWidget] + factory MatrixWidget.custom(Room room, String name, Uri url) => MatrixWidget( + room: room, + name: name, + type: 'm.custom', + url: url.toString(), + data: { + 'url': url.toString(), + }, + ); + + Future buildWidgetUrl() async { + // See https://github.com/matrix-org/matrix-doc/issues/1236 for a + // description, specifically the section + // `What does the other stuff in content mean?` + final userProfile = await room.client.ownProfile; + var parsedUri = url; + + // a key-value map with the strings to be replaced + final replaceMap = { + r'$matrix_user_id': userProfile.userId, + r'$matrix_room_id': room.id, + r'$matrix_display_name': userProfile.displayName ?? '', + r'$matrix_avatar_url': userProfile.avatarUrl?.toString() ?? '', + // removing potentially dangerous keys containing anything but + // `[a-zA-Z0-9_-]` as well as non string values + if (data != null) + ...Map.from(data!) + ..removeWhere((key, value) => + !RegExp(r'^[\w-]+$').hasMatch(key) || !value is String) + ..map((key, value) => MapEntry('\$key', value)), + }; + + replaceMap.forEach((key, value) { + parsedUri = parsedUri.replaceAll(key, Uri.encodeComponent(value)); + }); + + return Uri.parse(parsedUri); + } + + Map toJson() => { + 'creatorUserId': creatorUserId, + 'data': data, + 'id': id, + 'name': name, + 'type': type, + 'url': url, + 'waitForIframeLoad': waitForIframeLoad, + }; +} diff --git a/test/client_test.dart b/test/client_test.dart index 4aebb752..286442e7 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -322,6 +322,15 @@ void main() { await matrix.setAvatar(testFile); }); + test('recentEmoji', () async { + final client = await getClient(); + final emojis = client.recentEmojis; + expect(emojis.isEmpty, isTrue); + + await client.addRecentEmoji('🦙'); + expect(client.recentEmojis['🦙'], 1); + }); + test('setMuteAllPushNotifications', () async { await matrix.setMuteAllPushNotifications(false); }); diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index 1ec43b2a..e8053228 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -2121,6 +2121,8 @@ class FakeMatrixApi extends MockClient { '/client/unstable/room_keys/version': (var reqI) => {'version': '5'}, }, 'PUT': { + '/client/r0/user/${Uri.encodeComponent('@alice:example.com')}/account_data/io.element.recent_emoji}': + (var req) => {}, '/client/r0/user/%40test%3AfakeServer.notExisting/account_data/m.ignored_user_list': (var req) => {}, '/client/r0/presence/${Uri.encodeComponent('@alice:example.com')}/status':