From fb0177ac5f81b51f3497cb07f7819ddf195f94da Mon Sep 17 00:00:00 2001 From: Christian Pauly Date: Wed, 31 Mar 2021 14:22:57 +0200 Subject: [PATCH] feat: Implement spaces --- lib/src/client.dart | 28 +++++++++ lib/src/room.dart | 68 +++++++++++++++++++++ lib/src/utils/space_child.dart | 47 +++++++++++++++ pubspec.yaml | 2 +- test/client_test.dart | 10 ++++ test/room_test.dart | 104 +++++++++++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 lib/src/utils/space_child.dart diff --git a/lib/src/client.dart b/lib/src/client.dart index 9a3f6506..961bd6c0 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -526,6 +526,34 @@ class Client extends MatrixApi { return roomId; } + /// Creates a new space and returns the Room ID. The parameters are mostly + /// the same like in [createRoom()]. + /// Be aware that spaces appear in the [rooms] list. You should check if a + /// room is a space by using the `room.isSpace` getter and then just use the + /// room as a space with `room.toSpace()`. + /// + /// https://github.com/matrix-org/matrix-doc/blob/matthew/msc1772/proposals/1772-groups-as-rooms.md + Future createSpace({ + String name, + String topic, + Visibility visibility = Visibility.public, + String spaceAliasName, + List invite, + List> invite3pid, + String roomVersion, + }) => + createRoom( + name: name, + topic: topic, + visibility: visibility, + roomAliasName: spaceAliasName, + creationContent: {'type': 'm.space'}, + powerLevelContentOverride: {'events_default': 100}, + invite: invite, + invite3pid: invite3pid, + roomVersion: roomVersion, + ); + /// Returns the user's own displayname and avatar url. In Matrix it is possible that /// one user can have different displaynames and avatar urls in different rooms. So /// this endpoint first checks if the profile is the same in all rooms. If not, the diff --git a/lib/src/room.dart b/lib/src/room.dart index 521fbdf1..2ad1585e 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -19,6 +19,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:famedlysdk/src/utils/space_child.dart'; import 'package:html_unescape/html_unescape.dart'; import 'package:matrix_file_e2ee/matrix_file_e2ee.dart'; @@ -1806,6 +1807,73 @@ class Room { ? getState(EventTypes.RoomTombstone).parsedTombstoneContent : null; + /// Checks if the `m.room.create` state has a `type` key with the value + /// `m.space`. + bool get isSpace => + getState(EventTypes.RoomCreate)?.content?.tryGet('type') == + RoomCreationTypes.mSpace; // TODO: Magic string! + + /// The parents of this room. Currently this SDK doesn't yet set the canonical + /// flag and is not checking if this room is in fact a child of this space. + /// You should therefore not rely on this and always check the children of + /// the space. + List get spaceParents => + states[EventTypes.spaceParent] + ?.values + ?.map((state) => SpaceParent.fromState(state)) + ?.where((child) => child.via?.isNotEmpty ?? false) + ?.toList() ?? + []; + + /// List all children of this space. Children without a `via` domain will be + /// ignored. + /// Children are sorted by the `order` while those without this field will be + /// sorted at the end of the list. + List get spaceChildren => !isSpace + ? throw Exception('Room is not a space!') + : (states[EventTypes.spaceChild] + ?.values + ?.map((state) => SpaceChild.fromState(state)) + ?.where((child) => child.via?.isNotEmpty ?? false) + ?.toList() ?? + []) + ..sort((a, b) => a.order.isEmpty || b.order.isEmpty + ? b.order.compareTo(a.order) + : a.order.compareTo(b.order)); + + /// Adds or edits a child of this space. + Future setSpaceChild( + String roomId, { + List via, + String order, + bool suggested, + }) async { + if (!isSpace) throw Exception('Room is not a space!'); + via ??= [roomId.domain]; + await client.sendState( + id, + EventTypes.spaceChild, + { + 'via': via, + if (order != null) 'order': order, + if (suggested != null) 'suggested': suggested, + }, + roomId); + await client.sendState( + roomId, + EventTypes.spaceParent, + { + 'via': via, + }, + id); + return; + } + + /// Remove a child from this space by setting the `via` to an empty list. + Future removeSpaceChild(String roomId) => !isSpace + ? throw Exception('Room is not a space!') + : setSpaceChild(roomId, via: const []); + @override bool operator ==(dynamic other) => (other is Room && other.id == id); } diff --git a/lib/src/utils/space_child.dart b/lib/src/utils/space_child.dart new file mode 100644 index 00000000..be045b5a --- /dev/null +++ b/lib/src/utils/space_child.dart @@ -0,0 +1,47 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2019, 2020, 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:matrix_api_lite/matrix_api_lite.dart'; + +import '../event.dart'; + +class SpaceChild { + final String roomId; + final List via; + final String order; + final bool suggested; + + SpaceChild.fromState(Event state) + : assert(state.type == EventTypes.spaceChild), + roomId = state.stateKey, + via = state.content.tryGetList('via'), + order = state.content.tryGet('order', ''), + suggested = state.content.tryGet('suggested'); +} + +class SpaceParent { + final String roomId; + final List via; + final bool canonical; + + SpaceParent.fromState(Event state) + : assert(state.type == EventTypes.spaceParent), + roomId = state.stateKey, + via = state.content.tryGetList('via'), + canonical = state.content.tryGet('canonical'); +} diff --git a/pubspec.yaml b/pubspec.yaml index e03826b6..e0f32195 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: matrix_file_e2ee: ^1.1.0 isolate: ^2.0.3 logger: ^1.0.0 - matrix_api_lite: ^0.2.2 + matrix_api_lite: ^0.2.4 dev_dependencies: test: ^1.15.7 diff --git a/test/client_test.dart b/test/client_test.dart index b469dfdf..0469ce24 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -340,6 +340,16 @@ void main() { await matrix.setMuteAllPushNotifications(false); }); + test('createSpace', () async { + await matrix.createSpace( + name: 'space', + topic: 'My test space', + spaceAliasName: '#myspace:example.invalid', + invite: ['@alice:example.invalid'], + roomVersion: '3', + ); + }); + test('get archive', () async { var archive = await matrix.archive; diff --git a/test/room_test.dart b/test/room_test.dart index a6e3fe58..c4c6c4f7 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -768,6 +768,110 @@ void main() { expect(room.getState('m.room.message') != null, true); }); + test('Spaces', () async { + expect(room.isSpace, false); + room.states['m.room.create'] = { + '': Event.fromJson({ + 'content': {'type': 'm.space'}, + 'event_id': '\$143273582443PhrSn:example.org', + 'origin_server_ts': 1432735824653, + 'room_id': '!jEsUZKDJdhlrceRyVU:example.org', + 'sender': '@example:example.org', + 'type': 'm.room.create', + 'unsigned': {'age': 1234}, + 'state_key': '', + }, room, 1432735824653.0), + }; + expect(room.isSpace, true); + + expect(room.spaceParents.isEmpty, true); + room.states[EventTypes.spaceParent] = { + '!1234:example.invalid': Event.fromJson({ + 'content': { + 'via': ['example.invalid'] + }, + 'event_id': '\$143273582443PhrSn:example.org', + 'origin_server_ts': 1432735824653, + 'room_id': '!jEsUZKDJdhlrceRyVU:example.org', + 'sender': '@example:example.org', + 'type': EventTypes.spaceParent, + 'unsigned': {'age': 1234}, + 'state_key': '!1234:example.invalid', + }, room, 1432735824653.0), + }; + expect(room.spaceParents.length, 1); + + expect(room.spaceChildren.isEmpty, true); + room.states[EventTypes.spaceChild] = { + '!b:example.invalid': Event.fromJson({ + 'content': { + 'via': ['example.invalid'], + 'order': 'b', + }, + 'event_id': '\$143273582443PhrSn:example.org', + 'origin_server_ts': 1432735824653, + 'room_id': '!jEsUZKDJdhlrceRyVU:example.org', + 'sender': '@example:example.org', + 'type': EventTypes.spaceChild, + 'unsigned': {'age': 1234}, + 'state_key': '!b:example.invalid', + }, room, 1432735824653.0), + '!c:example.invalid': Event.fromJson({ + 'content': { + 'via': ['example.invalid'], + 'order': 'c', + }, + 'event_id': '\$143273582443PhrSn:example.org', + 'origin_server_ts': 1432735824653, + 'room_id': '!jEsUZKDJdhlrceRyVU:example.org', + 'sender': '@example:example.org', + 'type': EventTypes.spaceChild, + 'unsigned': {'age': 1234}, + 'state_key': '!c:example.invalid', + }, room, 1432735824653.0), + '!noorder:example.invalid': Event.fromJson({ + 'content': { + 'via': ['example.invalid'], + }, + 'event_id': '\$143273582443PhrSn:example.org', + 'origin_server_ts': 1432735824653, + 'room_id': '!jEsUZKDJdhlrceRyVU:example.org', + 'sender': '@example:example.org', + 'type': EventTypes.spaceChild, + 'unsigned': {'age': 1234}, + 'state_key': '!noorder:example.invalid', + }, room, 1432735824653.0), + '!a:example.invalid': Event.fromJson({ + 'content': { + 'via': ['example.invalid'], + 'order': 'a', + }, + 'event_id': '\$143273582443PhrSn:example.org', + 'origin_server_ts': 1432735824653, + 'room_id': '!jEsUZKDJdhlrceRyVU:example.org', + 'sender': '@example:example.org', + 'type': EventTypes.spaceChild, + 'unsigned': {'age': 1234}, + 'state_key': '!a:example.invalid', + }, room, 1432735824653.0), + }; + expect(room.spaceChildren.length, 4); + + expect(room.spaceChildren[0].roomId, '!a:example.invalid'); + expect(room.spaceChildren[1].roomId, '!b:example.invalid'); + expect(room.spaceChildren[2].roomId, '!c:example.invalid'); + expect(room.spaceChildren[3].roomId, '!noorder:example.invalid'); + + // TODO: Implement a more generic fake api + /*await room.setSpaceChild( + '!jEsUZKDJdhlrceRyVU:example.org', + via: ['example.invalid'], + order: '5', + suggested: true, + ); + await room.removeSpaceChild('!1234:example.invalid');*/ + }); + test('logout', () async { await matrix.logout(); });