diff --git a/lib/src/Client.dart b/lib/src/Client.dart index c83fad2e..266e00f3 100644 --- a/lib/src/Client.dart +++ b/lib/src/Client.dart @@ -25,6 +25,8 @@ import 'dart:async'; import 'dart:core'; import 'responses/ErrorResponse.dart'; import 'Connection.dart'; +import 'RoomList.dart'; +import 'Room.dart'; import 'Store.dart'; import 'User.dart'; import 'responses/PushrulesResponse.dart'; @@ -189,6 +191,28 @@ class Client { await connection.clear(); } + /// Loads the Rooms from the [store] and creates a new [RoomList] object. + Future getRoomList( + {bool onlyLeft = false, + bool onlyDirect = false, + bool onlyGroups = false, + onUpdateCallback onUpdate, + onInsertCallback, + onInsert, + onRemoveCallback onRemove}) async { + List rooms = await store.getRoomList( + onlyLeft: onlyLeft, onlyGroups: onlyGroups, onlyDirect: onlyDirect); + return RoomList( + client: this, + onlyLeft: onlyLeft, + onlyDirect: onlyDirect, + onlyGroups: onlyGroups, + onUpdate: onUpdate, + onInsert: onInsert, + onRemove: onRemove, + rooms: rooms); + } + /// Creates a new group chat and invites the given Users and returns the new /// created room ID. Future createGroup(List users) async { diff --git a/lib/src/Event.dart b/lib/src/Event.dart index 37851aed..ad85437b 100644 --- a/lib/src/Event.dart +++ b/lib/src/Event.dart @@ -126,13 +126,16 @@ class Event { /// Generate a new Event object from a json string, mostly a table row. static Event fromJson(Map jsonObj, Room room) { - Map content; - try { - content = json.decode(jsonObj["content_json"]); - } catch (e) { - print("jsonObj decode of event content failed: ${e.toString()}"); - content = {}; - } + Map content = jsonObj["content"]; + + if (content == null) + try { + content = json.decode(jsonObj["content_json"]); + } catch (e) { + print("jsonObj decode of event content failed: ${e.toString()}"); + content = {}; + } + return Event( jsonObj["id"], User.fromJson(jsonObj, room), diff --git a/lib/src/Room.dart b/lib/src/Room.dart index 0ff0420c..c34b542b 100644 --- a/lib/src/Room.dart +++ b/lib/src/Room.dart @@ -28,6 +28,7 @@ import 'package:famedlysdk/src/responses/ErrorResponse.dart'; import 'package:famedlysdk/src/sync/EventUpdate.dart'; import 'package:famedlysdk/src/Event.dart'; import './User.dart'; +import 'Timeline.dart'; /// Represents a Matrix room. class Room { @@ -83,14 +84,7 @@ class Room { /// The needed power levels for all actions. Map powerLevels = {}; - /// The list of events in this room. If the room is created by the - /// [getRoomList()] of the [Store], this will contain only the last event. - List events = []; - - /// The list of participants in this room. If the room is created by the - /// [getRoomList()] of the [Store], this will contain only the sender of the - /// last event. - List participants = []; + Event lastEvent; /// Your current client instance. final Client client; @@ -123,23 +117,22 @@ class Room { this.historyVisibility, this.joinRules, this.powerLevels, - this.events, - this.participants, + this.lastEvent, this.client, }); /// The last message sent to this room. String get lastMessage { - if (events != null && events.length > 0) - return events[0].getBody(); + if (lastEvent != null) + return lastEvent.getBody(); else return ""; } /// When the last message received. ChatTime get timeCreated { - if (events?.length > 0) - return events[0].time; + if (lastEvent != null) + return lastEvent.time; else return ChatTime.now(); } @@ -165,12 +158,6 @@ class Room { return res; } - @Deprecated("Use the client.connection streams instead!") - Stream> get eventsStream { - return Stream>.fromIterable(Iterable>.generate( - this.events.length, (int index) => this.events)).asBroadcastStream(); - } - /// Call the Matrix API to send a simple text message. Future sendText(String message, {String txid = null}) async { if (txid == null) txid = "txid${DateTime.now().millisecondsSinceEpoch}"; @@ -394,9 +381,8 @@ class Room { "power_event_name": row["power_event_name"], "power_event_power_levels": row["power_event_power_levels"], }, + lastEvent: Event.fromJson(row, null), client: matrix, - events: [Event.fromJson(row, null)], - participants: [], ); } @@ -413,26 +399,25 @@ class Room { return room; } + Future getTimeline({onUpdate, onInsert}) async { + List events = await loadEvents(); + return Timeline( + room: this, + events: events, + onUpdate: onUpdate, + onInsert: onInsert, + ); + } + /// Load all events for a given room from the store. This includes all /// senders of those events, who will be added to the participants list. Future> loadEvents() async { - this.events = await client.store.getEventList(this); - - Map participantMap = {}; - for (num i = 0; i < events.length; i++) { - if (!participantMap.containsKey(events[i].sender.mxid)) { - participants.add(events[i].sender); - participantMap[events[i].sender.mxid] = true; - } - } - - return this.events; + return await client.store.getEventList(this); } /// Load all participants for a given room from the store. Future> loadParticipants() async { - this.participants = await client.store.loadParticipants(this); - return this.participants; + return await client.store.loadParticipants(this); } /// Request the full list of participants from the server. The local list @@ -454,8 +439,6 @@ class Room { if (newUser.membership != "leave") participants.add(newUser); } - this.participants = participants; - - return this.participants; + return participants; } } diff --git a/lib/src/RoomList.dart b/lib/src/RoomList.dart new file mode 100644 index 00000000..0faeb9ff --- /dev/null +++ b/lib/src/RoomList.dart @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2019 Zender & Kurtz GbR. + * + * Authors: + * Christian Pauly + * Marcel Radzio + * + * This file is part of famedlysdk. + * + * famedlysdk is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * famedlysdk 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with famedlysdk. If not, see . + */ + +import 'dart:async'; +import 'dart:core'; +import 'Client.dart'; +import 'Event.dart'; +import 'Room.dart'; +import 'User.dart'; +import 'utils/ChatTime.dart'; +import 'utils/MxContent.dart'; +import 'sync/EventUpdate.dart'; +import 'sync/RoomUpdate.dart'; + +/// Represents a list of rooms for this client, which will automatically update +/// itself and call the [onUpdate], [onInsert] and [onDelete] callbacks. To get +/// the initial room list, use the store or create a RoomList instance by using +/// [client.getRoomList]. +class RoomList { + final Client client; + List rooms = []; + + final bool onlyLeft; + final bool onlyDirect; + final bool onlyGroups; + + /// Will be called, when the room list has changed. Can be used e.g. to update + /// the state of a StatefulWidget. + final onUpdateCallback onUpdate; + + /// Will be called, when a new room is added to the list. + final onInsertCallback onInsert; + + /// Will be called, when a room has been removed from the list. + final onRemoveCallback onRemove; + + StreamSubscription eventSub; + StreamSubscription roomSub; + + RoomList( + {this.client, + this.rooms, + this.onUpdate, + this.onInsert, + this.onRemove, + this.onlyLeft = false, + this.onlyDirect = false, + this.onlyGroups = false}) { + eventSub ??= client.connection.onEvent.stream.listen(_handleEventUpdate); + roomSub ??= client.connection.onRoomUpdate.stream.listen(_handleRoomUpdate); + } + + void _handleRoomUpdate(RoomUpdate chatUpdate) async { + // Update the chat list item. + // Search the room in the rooms + num j = 0; + for (j = 0; j < rooms.length; j++) { + if (rooms[j].id == chatUpdate.id) break; + } + final bool found = (j < rooms.length - 1 && rooms[j].id == chatUpdate.id); + + // Does the chat already exist in the list rooms? + if (!found && chatUpdate.membership != "leave") { + num position = chatUpdate.membership == "invite" ? 0 : j; + ChatTime timestamp = + chatUpdate.membership == "invite" ? ChatTime.now() : ChatTime(0); + // Add the new chat to the list + Room newRoom = Room( + id: chatUpdate.id, + name: "", + membership: chatUpdate.membership, + prev_batch: chatUpdate.prev_batch, + highlightCount: chatUpdate.highlight_count, + notificationCount: chatUpdate.notification_count); + rooms.insert(position, newRoom); + onInsert(position); + } + // If the membership is "leave" then remove the item and stop here + else if (found && chatUpdate.membership == "leave") { + final Room removed = rooms.removeAt(j); + onRemove(j); + } + // Update notification and highlight count + else if (found && + chatUpdate.membership != "leave" && + (rooms[j].notificationCount != chatUpdate.notification_count || + rooms[j].highlightCount != chatUpdate.highlight_count)) { + rooms[j].notificationCount = chatUpdate.notification_count; + rooms[j].highlightCount = chatUpdate.highlight_count; + } + sortAndUpdate(); + } + + void _handleEventUpdate(EventUpdate eventUpdate) { + // Is the event necessary for the chat list? If not, then return + if (!(eventUpdate.type == "timeline" || + eventUpdate.eventType == "m.room.avatar" || + eventUpdate.eventType == "m.room.name")) return; + + // Search the room in the rooms + num j = 0; + for (j = 0; j < rooms.length; j++) { + if (rooms[j].id == eventUpdate.roomID) break; + } + final bool found = (j < rooms.length && rooms[j].id == eventUpdate.roomID); + if (!found) return; + + // Is this an old timeline event? Then stop here... + /*if (eventUpdate.type == "timeline" && + ChatTime(eventUpdate.content["origin_server_ts"]) <= + rooms[j].timeCreated) return;*/ + + if (eventUpdate.type == "timeline") { + // Update the last message preview + String body = eventUpdate.content["content"]["body"] ?? ""; + rooms[j].lastEvent = Event( + eventUpdate.content["id"], + User(eventUpdate.content["sender"]), + ChatTime(eventUpdate.content["origin_server_ts"]), + room: rooms[j], + content: eventUpdate.content["content"], + environment: "timeline", + status: 2, + ); + } + if (eventUpdate.eventType == "m.room.name") { + // Update the room name + rooms[j].name = eventUpdate.content["content"]["name"]; + } else if (eventUpdate.eventType == "m.room.avatar") { + // Update the room avatar + rooms[j].avatar = MxContent(eventUpdate.content["content"]["url"]); + } + sortAndUpdate(); + } + + sortAndUpdate() { + rooms?.sort((a, b) => + b.timeCreated.toTimeStamp().compareTo(a.timeCreated.toTimeStamp())); + onUpdate(); + } +} + +typedef onUpdateCallback = void Function(); +typedef onInsertCallback = void Function(int insertID); +typedef onRemoveCallback = void Function(int insertID); diff --git a/lib/src/Timeline.dart b/lib/src/Timeline.dart new file mode 100644 index 00000000..04a23b59 --- /dev/null +++ b/lib/src/Timeline.dart @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2019 Zender & Kurtz GbR. + * + * Authors: + * Christian Pauly + * Marcel Radzio + * + * This file is part of famedlysdk. + * + * famedlysdk is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * famedlysdk 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with famedlysdk. If not, see . + */ + +import 'dart:async'; +import 'Event.dart'; +import 'Room.dart'; +import 'User.dart'; +import 'sync/EventUpdate.dart'; + +/// Represents the timeline of a room. The callbacks [onUpdate], [onDelete], +/// [onInsert] and [onResort] will be triggered automatically. The initial +/// event list will be retreived when created by the [room.getTimeline] method. +class Timeline { + final Room room; + List events = []; + + final onUpdateCallback onUpdate; + final onInsertCallback onInsert; + + StreamSubscription sub; + + Timeline({this.room, this.events, this.onUpdate, this.onInsert}) { + sub ??= room.client.connection.onEvent.stream.listen(_handleEventUpdate); + } + + void _handleEventUpdate(EventUpdate eventUpdate) async { + try { + if (eventUpdate.roomID != room.id) return; + if (eventUpdate.type == "timeline" || eventUpdate.type == "history") { + if (!eventUpdate.content.containsKey("id")) + eventUpdate.content["id"] = eventUpdate.content["event_id"]; + + User user = await room.client.store + ?.getUser(matrixID: eventUpdate.content["sender"], room: room); + if (user != null) { + eventUpdate.content["displayname"] = user.displayName; + eventUpdate.content["avatar_url"] = user.avatarUrl.mxc; + } + + Event newEvent = Event.fromJson(eventUpdate.content, room); + + events.insert(0, newEvent); + onInsert(0); + } + sortAndUpdate(); + } catch (e) { + print("[WARNING] ${e.toString()}"); + sub.cancel(); + } + } + + sortAndUpdate() { + events + ?.sort((a, b) => b.time.toTimeStamp().compareTo(a.time.toTimeStamp())); + onUpdate(); + } +} + +typedef onUpdateCallback = void Function(); +typedef onInsertCallback = void Function(int insertID); diff --git a/lib/src/User.dart b/lib/src/User.dart index aff91227..9836727f 100644 --- a/lib/src/User.dart +++ b/lib/src/User.dart @@ -79,7 +79,7 @@ class User { /// Creates a new User object from a json string like a row from the database. static User fromJson(Map json, Room room) { - return User(json['matrix_id'], + return User(json['matrix_id'] ?? json['sender'], displayName: json['displayname'], avatarUrl: MxContent(json['avatar_url']), membership: json['membership'], diff --git a/lib/src/sync/EventUpdate.dart b/lib/src/sync/EventUpdate.dart index 41c40b00..725a711d 100644 --- a/lib/src/sync/EventUpdate.dart +++ b/lib/src/sync/EventUpdate.dart @@ -25,7 +25,7 @@ /// already known event. class EventUpdate { /// Usually 'timeline', 'state' or whatever. - final String eventType; + final String type; /// Most events belong to a room. If not, this equals to eventType. final String roomID; @@ -33,7 +33,7 @@ class EventUpdate { /// See (Matrix Room Events)[https://matrix.org/docs/spec/client_server/r0.4.0.html#room-events] /// and (Matrix Events)[https://matrix.org/docs/spec/client_server/r0.4.0.html#id89] for more /// informations. - final String type; + final String eventType; // The json payload of the content of this event. final dynamic content; diff --git a/test/RoomList_test.dart b/test/RoomList_test.dart new file mode 100644 index 00000000..abd1c4f8 --- /dev/null +++ b/test/RoomList_test.dart @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2019 Zender & Kurtz GbR. + * + * Authors: + * Christian Pauly + * Marcel Radzio + * + * This file is part of famedlysdk. + * + * famedlysdk is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * famedlysdk 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with famedlysdk. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:famedlysdk/src/Client.dart'; +import 'package:famedlysdk/src/Event.dart'; +import 'package:famedlysdk/src/Room.dart'; +import 'package:famedlysdk/src/RoomList.dart'; +import 'package:famedlysdk/src/User.dart'; +import 'package:famedlysdk/src/sync/EventUpdate.dart'; +import 'package:famedlysdk/src/sync/RoomUpdate.dart'; +import 'package:famedlysdk/src/utils/ChatTime.dart'; + +void main() { + /// All Tests related to the MxContent + group("RoomList", () { + final roomID = "!1:example.com"; + + test("Create and insert one room", () async { + final Client client = Client("testclient"); + client.homeserver = "https://testserver.abc"; + + int updateCount = 0; + List insertList = []; + List removeList = []; + + RoomList roomList = RoomList( + client: client, + rooms: [], + onUpdate: () { + updateCount++; + }, + onInsert: (int insertID) { + insertList.add(insertID); + }, + onRemove: (int removeID) { + insertList.add(removeID); + }); + + expect(roomList.eventSub != null, true); + expect(roomList.roomSub != null, true); + + client.connection.onRoomUpdate.add(RoomUpdate( + id: roomID, + membership: "join", + notification_count: 2, + highlight_count: 1, + limitedTimeline: false, + prev_batch: "1234", + )); + + await new Future.delayed(new Duration(milliseconds: 50)); + + expect(updateCount, 1); + expect(insertList, [0]); + expect(removeList, []); + + expect(roomList.rooms.length, 1); + expect(roomList.rooms[0].id, roomID); + expect(roomList.rooms[0].membership, "join"); + expect(roomList.rooms[0].notificationCount, 2); + expect(roomList.rooms[0].highlightCount, 1); + expect(roomList.rooms[0].prev_batch, "1234"); + expect(roomList.rooms[0].timeCreated, ChatTime.now()); + }); + + test("Restort", () async { + final Client client = Client("testclient"); + client.homeserver = "https://testserver.abc"; + + int updateCount = 0; + List insertList = []; + List removeList = []; + + RoomList roomList = RoomList( + client: client, + rooms: [], + onUpdate: () { + updateCount++; + }, + onInsert: (int insertID) { + insertList.add(insertID); + }, + onRemove: (int removeID) { + insertList.add(removeID); + }); + + client.connection.onRoomUpdate.add(RoomUpdate( + id: "1", + membership: "join", + notification_count: 2, + highlight_count: 1, + limitedTimeline: false, + prev_batch: "1234", + )); + client.connection.onRoomUpdate.add(RoomUpdate( + id: "2", + membership: "join", + notification_count: 2, + highlight_count: 1, + limitedTimeline: false, + prev_batch: "1234", + )); + + await new Future.delayed(new Duration(milliseconds: 50)); + + expect(roomList.eventSub != null, true); + expect(roomList.roomSub != null, true); + expect(roomList.rooms[0].id, "1"); + expect(roomList.rooms[1].id, "2"); + + ChatTime now = ChatTime.now(); + + client.connection.onEvent.add(EventUpdate( + type: "timeline", + roomID: "1", + eventType: "m.room.message", + content: { + "type": "m.room.message", + "content": {"msgtype": "m.text", "body": "Testcase"}, + "sender": "@alice:example.com", + "status": 2, + "id": "1", + "origin_server_ts": now.toTimeStamp() - 1000 + })); + + client.connection.onEvent.add(EventUpdate( + type: "timeline", + roomID: "2", + eventType: "m.room.message", + content: { + "type": "m.room.message", + "content": {"msgtype": "m.text", "body": "Testcase 2"}, + "sender": "@alice:example.com", + "status": 2, + "id": "2", + "origin_server_ts": now.toTimeStamp() + })); + + await new Future.delayed(new Duration(milliseconds: 50)); + + expect(updateCount, 4); + expect(insertList, [0, 1]); + expect(removeList, []); + + expect(roomList.rooms.length, 2); + expect( + roomList.rooms[0].timeCreated > roomList.rooms[1].timeCreated, true); + expect(roomList.rooms[0].id, "2"); + expect(roomList.rooms[1].id, "1"); + expect(roomList.rooms[0].lastMessage, "Testcase 2"); + expect(roomList.rooms[0].timeCreated, now); + }); + }); +} diff --git a/test/Timeline_test.dart b/test/Timeline_test.dart new file mode 100644 index 00000000..2576aca1 --- /dev/null +++ b/test/Timeline_test.dart @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019 Zender & Kurtz GbR. + * + * Authors: + * Christian Pauly + * Marcel Radzio + * + * This file is part of famedlysdk. + * + * famedlysdk is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * famedlysdk 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with famedlysdk. If not, see . + */ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:famedlysdk/src/Client.dart'; +import 'package:famedlysdk/src/Room.dart'; +import 'package:famedlysdk/src/Timeline.dart'; +import 'package:famedlysdk/src/sync/EventUpdate.dart'; +import 'package:famedlysdk/src/utils/ChatTime.dart'; + +void main() { + /// All Tests related to the MxContent + group("Timeline", () { + final String roomID = "!1234:example.com"; + final testTimeStamp = ChatTime.now().toTimeStamp(); + int updateCount = 0; + List insertList = []; + + test("Create", () async { + Client client = Client("testclient"); + client.homeserver = "https://testserver.abc"; + + Room room = Room(id: roomID, client: client); + Timeline timeline = Timeline( + room: room, + events: [], + onUpdate: () { + updateCount++; + }, + onInsert: (int insertID) { + insertList.add(insertID); + }); + + client.connection.onEvent.add(EventUpdate( + type: "timeline", + roomID: roomID, + eventType: "m.room.message", + content: { + "type": "m.room.message", + "content": {"msgtype": "m.text", "body": "Testcase"}, + "sender": "@alice:example.com", + "status": 2, + "id": "1", + "origin_server_ts": testTimeStamp + })); + + client.connection.onEvent.add(EventUpdate( + type: "timeline", + roomID: roomID, + eventType: "m.room.message", + content: { + "type": "m.room.message", + "content": {"msgtype": "m.text", "body": "Testcase"}, + "sender": "@alice:example.com", + "status": 2, + "id": "2", + "origin_server_ts": testTimeStamp - 1000 + })); + + expect(timeline.sub != null, true); + + await new Future.delayed(new Duration(milliseconds: 50)); + + expect(updateCount, 2); + expect(insertList, [0, 0]); + expect(timeline.events.length, 2); + expect(timeline.events[0].id, "1"); + expect(timeline.events[0].sender.id, "@alice:example.com"); + expect(timeline.events[0].time.toTimeStamp(), testTimeStamp); + expect(timeline.events[0].environment, "m.room.message"); + expect(timeline.events[0].getBody(), "Testcase"); + expect(timeline.events[0].time > timeline.events[1].time, true); + }); + }); +}