Merge branch 'store-enhance-usestatestore' into 'master'

[Store] Switch to a state focused store

See merge request famedly/famedlysdk!67
This commit is contained in:
Marcel 2019-09-02 11:02:55 +00:00
commit 902df33d50
20 changed files with 1265 additions and 984 deletions

View file

@ -35,6 +35,7 @@ export 'package:famedlysdk/src/Connection.dart';
export 'package:famedlysdk/src/Event.dart'; export 'package:famedlysdk/src/Event.dart';
export 'package:famedlysdk/src/Room.dart'; export 'package:famedlysdk/src/Room.dart';
export 'package:famedlysdk/src/RoomList.dart'; export 'package:famedlysdk/src/RoomList.dart';
export 'package:famedlysdk/src/RoomState.dart';
export 'package:famedlysdk/src/Store.dart'; export 'package:famedlysdk/src/Store.dart';
export 'package:famedlysdk/src/Timeline.dart'; export 'package:famedlysdk/src/Timeline.dart';
export 'package:famedlysdk/src/User.dart'; export 'package:famedlysdk/src/User.dart';

41
lib/src/AccountData.dart Normal file
View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2019 Zender & Kurtz GbR.
*
* Authors:
* Christian Pauly <krille@famedly.com>
* Marcel Radzio <mtrnord@famedly.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:famedlysdk/src/RoomState.dart';
class AccountData {
/// The json payload of the content. The content highly depends on the type.
final Map<String, dynamic> content;
/// The type String of this event. For example 'm.room.message'.
final String typeKey;
AccountData({this.content, this.typeKey});
/// Get a State event from a table row or from the event stream.
factory AccountData.fromJson(Map<String, dynamic> jsonPayload) {
final Map<String, dynamic> content =
RoomState.getMapFromPayload(jsonPayload['content']);
return AccountData(content: content, typeKey: jsonPayload['type']);
}
}

View file

@ -24,6 +24,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:core'; import 'dart:core';
import 'package:famedlysdk/src/AccountData.dart';
import 'package:famedlysdk/src/Presence.dart';
import 'package:famedlysdk/src/sync/UserUpdate.dart';
import 'Connection.dart'; import 'Connection.dart';
import 'Room.dart'; import 'Room.dart';
import 'RoomList.dart'; import 'RoomList.dart';
@ -33,6 +37,9 @@ import 'requests/SetPushersRequest.dart';
import 'responses/ErrorResponse.dart'; import 'responses/ErrorResponse.dart';
import 'responses/PushrulesResponse.dart'; import 'responses/PushrulesResponse.dart';
typedef AccountDataEventCB = void Function(AccountData accountData);
typedef PresenceCB = void Function(Presence presence);
/// Represents a Matrix client to communicate with a /// Represents a Matrix client to communicate with a
/// [Matrix](https://matrix.org) homeserver and is the entry point for this /// [Matrix](https://matrix.org) homeserver and is the entry point for this
/// SDK. /// SDK.
@ -86,6 +93,56 @@ class Client {
/// Returns the current login state. /// Returns the current login state.
bool isLogged() => accessToken != null; bool isLogged() => accessToken != null;
/// A list of all rooms the user is participating or invited.
RoomList roomList;
/// Key/Value store of account data.
Map<String, AccountData> accountData = {};
/// Presences of users by a given matrix ID
Map<String, Presence> presences = {};
/// Callback will be called on account data updates.
AccountDataEventCB onAccountData;
/// Callback will be called on presences.
PresenceCB onPresence;
void handleUserUpdate(UserUpdate userUpdate) {
if (userUpdate.type == "account_data") {
AccountData newAccountData = AccountData.fromJson(userUpdate.content);
accountData[newAccountData.typeKey] = newAccountData;
if (onAccountData != null) onAccountData(newAccountData);
}
if (userUpdate.type == "presence") {
Presence newPresence = Presence.fromJson(userUpdate.content);
presences[newPresence.sender] = newPresence;
if (onPresence != null) onPresence(newPresence);
}
}
Map<String, dynamic> get directChats =>
accountData["m.direct"] != null ? 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) {
if (accountData["m.direct"] != null &&
accountData["m.direct"].content[userId] is List<dynamic> &&
accountData["m.direct"].content[userId].length > 0) {
if (roomList.getRoomById(accountData["m.direct"].content[userId][0]) !=
null) return accountData["m.direct"].content[userId][0];
(accountData["m.direct"].content[userId] as List<dynamic>)
.remove(accountData["m.direct"].content[userId][0]);
connection.jsonRequest(
type: HTTPType.PUT,
action: "/client/r0/user/${userID}/account_data/m.direct",
data: directChats);
return getDirectChatFromUserId(userId);
}
return null;
}
/// Checks the supported versions of the Matrix protocol and the supported /// Checks the supported versions of the Matrix protocol and the supported
/// login types. Returns false if the server is not compatible with the /// login types. Returns false if the server is not compatible with the
/// client. Automatically sets [matrixVersions] and [lazyLoadMembers]. /// client. Automatically sets [matrixVersions] and [lazyLoadMembers].
@ -225,12 +282,12 @@ class Client {
/// defined by the autojoin room feature in Synapse. /// defined by the autojoin room feature in Synapse.
Future<List<User>> loadFamedlyContacts() async { Future<List<User>> loadFamedlyContacts() async {
List<User> contacts = []; List<User> contacts = [];
Room contactDiscoveryRoom = await store Room contactDiscoveryRoom = roomList
.getRoomByAlias("#famedlyContactDiscovery:${userID.split(":")[1]}"); .getRoomByAlias("#famedlyContactDiscovery:${userID.split(":")[1]}");
if (contactDiscoveryRoom != null) if (contactDiscoveryRoom != null)
contacts = await contactDiscoveryRoom.requestParticipants(); contacts = await contactDiscoveryRoom.requestParticipants();
else else
contacts = await store.loadContacts(); contacts = await store?.loadContacts();
return contacts; return contacts;
} }

View file

@ -25,6 +25,8 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:core'; import 'dart:core';
import 'package:famedlysdk/src/Room.dart';
import 'package:famedlysdk/src/RoomList.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -50,9 +52,9 @@ class Connection {
})); }));
} }
String get _syncFilters => '{"room":{"state":{"lazy_load_members":true}}}'; static String syncFilters = '{"room":{"state":{"lazy_load_members":true}}}';
String get _firstSyncFilters => static String firstSyncFilters =
'{"room":{"include_leave":true,"state":{"lazy_load_members":true}}}'; '{"room":{"include_leave":true,"state":{"lazy_load_members":true}}}';
/// Handles the connection to the Matrix Homeserver. You can change this to a /// Handles the connection to the Matrix Homeserver. You can change this to a
@ -147,13 +149,34 @@ class Connection {
client.lazyLoadMembers = newLazyLoadMembers; client.lazyLoadMembers = newLazyLoadMembers;
client.prevBatch = newPrevBatch; client.prevBatch = newPrevBatch;
client.store?.storeClient(); List<Room> rooms = [];
if (client.store != null) {
client.store.storeClient();
rooms = await client.store
.getRoomList(onlyLeft: false, onlyGroups: false, onlyDirect: false);
client.accountData = await client.store.getAccountData();
client.presences = await client.store.getPresences();
}
client.roomList = RoomList(
client: client,
onlyLeft: false,
onlyDirect: false,
onlyGroups: false,
onUpdate: null,
onInsert: null,
onRemove: null,
rooms: rooms);
_userEventSub ??= onUserEvent.stream.listen(client.handleUserUpdate);
onLoginStateChanged.add(LoginState.logged); onLoginStateChanged.add(LoginState.logged);
_sync(); _sync();
} }
StreamSubscription _userEventSub;
/// Resets all settings and stops the synchronisation. /// Resets all settings and stops the synchronisation.
void clear() { void clear() {
client.store?.clear(); client.store?.clear();
@ -261,10 +284,10 @@ class Connection {
Future<void> _sync() async { Future<void> _sync() async {
if (client.isLogged() == false) return; if (client.isLogged() == false) return;
String action = "/client/r0/sync?filter=$_firstSyncFilters"; String action = "/client/r0/sync?filter=$firstSyncFilters";
if (client.prevBatch != null) { if (client.prevBatch != null) {
action = "/client/r0/sync?filter=$_syncFilters"; action = "/client/r0/sync?filter=$syncFilters";
action += "&timeout=30000"; action += "&timeout=30000";
action += "&since=${client.prevBatch}"; action += "&since=${client.prevBatch}";
} }
@ -450,6 +473,8 @@ class Connection {
} }
} }
typedef _FutureVoidCallback = Future<void> Function();
class _LifecycleEventHandler extends WidgetsBindingObserver { class _LifecycleEventHandler extends WidgetsBindingObserver {
_LifecycleEventHandler({this.resumeCallBack, this.suspendingCallBack}); _LifecycleEventHandler({this.resumeCallBack, this.suspendingCallBack});
@ -471,6 +496,4 @@ class _LifecycleEventHandler extends WidgetsBindingObserver {
} }
} }
typedef _FutureVoidCallback = Future<void> Function();
enum LoginState { logged, loggedOut } enum LoginState { logged, loggedOut }

View file

@ -21,37 +21,14 @@
* along with famedlysdk. If not, see <http://www.gnu.org/licenses/>. * along with famedlysdk. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'dart:convert'; import 'package:famedlysdk/src/RoomState.dart';
import 'package:famedlysdk/src/Client.dart';
import 'package:famedlysdk/src/sync/EventUpdate.dart'; import 'package:famedlysdk/src/sync/EventUpdate.dart';
import 'package:famedlysdk/src/utils/ChatTime.dart'; import 'package:famedlysdk/src/utils/ChatTime.dart';
import './Room.dart'; import './Room.dart';
import './User.dart';
/// A single Matrix event, e.g. a message in a chat.
class Event {
/// The Matrix ID for this event in the format '$localpart:server.abc'.
final String id;
/// The room this event belongs to.
final Room room;
/// The time this event has received at the server.
final ChatTime time;
/// The user who has sent this event.
final User sender;
/// The user who is the target of this event e.g. for a m.room.member event.
final User stateKey;
/// The type of this event. Mostly this is 'timeline'.
final String environment;
Event replyEvent;
/// Defines a timeline event for a room.
class Event extends RoomState {
/// The status of this event. /// The status of this event.
/// -1=ERROR /// -1=ERROR
/// 0=SENDING /// 0=SENDING
@ -59,20 +36,53 @@ class Event {
/// 2=RECEIVED /// 2=RECEIVED
int status; int status;
/// The json payload of the content. The content highly depends on the type. static const int defaultStatus = 2;
final Map<String, dynamic> content;
Event( Event(
this.id, {this.status = defaultStatus,
this.sender, dynamic content,
this.time, { String typeKey,
this.room, String eventId,
this.stateKey, String roomId,
this.status = 2, String senderId,
this.environment, ChatTime time,
this.content, dynamic unsigned,
this.replyEvent, dynamic prevContent,
}); String stateKey,
Room room})
: super(
content: content,
typeKey: typeKey,
eventId: eventId,
roomId: roomId,
senderId: senderId,
time: time,
unsigned: unsigned,
prevContent: prevContent,
stateKey: stateKey,
room: room);
/// Get a State event from a table row or from the event stream.
factory Event.fromJson(Map<String, dynamic> jsonPayload, Room room) {
final Map<String, dynamic> content =
RoomState.getMapFromPayload(jsonPayload['content']);
final Map<String, dynamic> unsigned =
RoomState.getMapFromPayload(jsonPayload['unsigned']);
final Map<String, dynamic> prevContent =
RoomState.getMapFromPayload(jsonPayload['prev_content']);
return Event(
status: jsonPayload['status'] ?? defaultStatus,
content: content,
typeKey: jsonPayload['type'],
eventId: jsonPayload['event_id'],
roomId: jsonPayload['room_id'],
senderId: jsonPayload['sender'],
time: ChatTime(jsonPayload['origin_server_ts']),
unsigned: unsigned,
prevContent: prevContent,
stateKey: jsonPayload['state_key'],
room: room);
}
/// Returns the body of this event if it has a body. /// Returns the body of this event if it has a body.
String get text => content["body"] ?? ""; String get text => content["body"] ?? "";
@ -84,89 +94,7 @@ class Event {
String getBody() { String getBody() {
if (text != "") return text; if (text != "") return text;
if (formattedText != "") return formattedText; if (formattedText != "") return formattedText;
return "*** Unable to parse Content ***"; return "$type";
}
/// Get the real type.
EventTypes get type {
switch (environment) {
case "m.room.avatar":
return EventTypes.RoomAvatar;
case "m.room.name":
return EventTypes.RoomName;
case "m.room.topic":
return EventTypes.RoomTopic;
case "m.room.Aliases":
return EventTypes.RoomAliases;
case "m.room.canonical_alias":
return EventTypes.RoomCanonicalAlias;
case "m.room.create":
return EventTypes.RoomCreate;
case "m.room.join_rules":
return EventTypes.RoomJoinRules;
case "m.room.member":
return EventTypes.RoomMember;
case "m.room.power_levels":
return EventTypes.RoomPowerLevels;
case "m.room.guest_access":
return EventTypes.GuestAccess;
case "m.room.history_visibility":
return EventTypes.HistoryVisibility;
case "m.room.message":
switch (content["msgtype"] ?? "m.text") {
case "m.text":
if (content.containsKey("m.relates_to")) {
return EventTypes.Reply;
}
return EventTypes.Text;
case "m.notice":
return EventTypes.Notice;
case "m.emote":
return EventTypes.Emote;
case "m.image":
return EventTypes.Image;
case "m.video":
return EventTypes.Video;
case "m.audio":
return EventTypes.Audio;
case "m.file":
return EventTypes.File;
case "m.location":
return EventTypes.Location;
}
}
return EventTypes.Unknown;
}
/// Generate a new Event object from a json string, mostly a table row.
static Event fromJson(Map<String, dynamic> jsonObj, Room room,
{User senderUser, User stateKeyUser}) {
Map<String, dynamic> content = jsonObj["content"];
if (content == null && jsonObj["content_json"] != null)
try {
content = json.decode(jsonObj["content_json"]);
} catch (e) {
if (room.client.debug) {
print("jsonObj decode of event content failed: ${e.toString()}");
}
content = {};
}
else if (content == null) content = {};
if (senderUser == null) senderUser = User.fromJson(jsonObj, room);
if (stateKeyUser == null) stateKeyUser = User(jsonObj["state_key"]);
return Event(
jsonObj["event_id"] ?? jsonObj["id"],
senderUser,
ChatTime(jsonObj["origin_server_ts"]),
stateKey: stateKeyUser,
environment: jsonObj["type"],
status: jsonObj["status"] ?? 2,
content: content,
room: room,
);
} }
/// Removes this event if the status is < 1. This event will just be removed /// Removes this event if the status is < 1. This event will just be removed
@ -175,14 +103,14 @@ class Event {
if (status < 1) { if (status < 1) {
if (room.client.store != null) if (room.client.store != null)
await room.client.store.db await room.client.store.db
.rawDelete("DELETE FROM Events WHERE id=?", [id]); .rawDelete("DELETE FROM Events WHERE id=?", [eventId]);
room.client.connection.onEvent.add(EventUpdate( room.client.connection.onEvent.add(EventUpdate(
roomID: room.id, roomID: room.id,
type: "timeline", type: "timeline",
eventType: environment, eventType: typeKey,
content: { content: {
"event_id": id, "event_id": eventId,
"status": -2, "status": -2,
"content": {"body": "Removed..."} "content": {"body": "Removed..."}
})); }));
@ -198,42 +126,4 @@ class Event {
final String eventID = await room.sendTextEvent(text, txid: txid); final String eventID = await room.sendTextEvent(text, txid: txid);
return eventID; return eventID;
} }
@Deprecated("Use [client.store.getEventList(Room room)] instead!")
static Future<List<Event>> getEventList(Client matrix, Room room) async {
List<Event> eventList = await matrix.store.getEventList(room);
return eventList;
}
} }
enum EventTypes {
Text,
Emote,
Notice,
Image,
Video,
Audio,
File,
Location,
Reply,
RoomAliases,
RoomCanonicalAlias,
RoomCreate,
RoomJoinRules,
RoomMember,
RoomPowerLevels,
RoomName,
RoomTopic,
RoomAvatar,
GuestAccess,
HistoryVisibility,
Unknown,
}
final Map<String, int> StatusTypes = {
"REMOVE": -2,
"ERROR": -1,
"SENDING": 0,
"SENT": 1,
"RECEIVED": 2,
};

43
lib/src/Presence.dart Normal file
View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2019 Zender & Kurtz GbR.
*
* Authors:
* Christian Pauly <krille@famedly.com>
* Marcel Radzio <mtrnord@famedly.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:famedlysdk/src/AccountData.dart';
import 'package:famedlysdk/src/RoomState.dart';
class Presence extends AccountData {
/// The user who has sent this event if it is not a global account data event.
final String sender;
Presence({this.sender, Map<String, dynamic> content, String typeKey})
: super(content: content, typeKey: typeKey);
/// Get a State event from a table row or from the event stream.
factory Presence.fromJson(Map<String, dynamic> jsonPayload) {
final Map<String, dynamic> content =
RoomState.getMapFromPayload(jsonPayload['content']);
return Presence(
content: content,
typeKey: jsonPayload['type'],
sender: jsonPayload['sender']);
}
}

View file

@ -23,6 +23,8 @@
import 'package:famedlysdk/src/Client.dart'; import 'package:famedlysdk/src/Client.dart';
import 'package:famedlysdk/src/Event.dart'; import 'package:famedlysdk/src/Event.dart';
import 'package:famedlysdk/src/RoomAccountData.dart';
import 'package:famedlysdk/src/RoomState.dart';
import 'package:famedlysdk/src/responses/ErrorResponse.dart'; import 'package:famedlysdk/src/responses/ErrorResponse.dart';
import 'package:famedlysdk/src/sync/EventUpdate.dart'; import 'package:famedlysdk/src/sync/EventUpdate.dart';
import 'package:famedlysdk/src/utils/ChatTime.dart'; import 'package:famedlysdk/src/utils/ChatTime.dart';
@ -40,15 +42,6 @@ class Room {
/// Membership status of the user for this room. /// Membership status of the user for this room.
Membership membership; Membership membership;
/// The name of the room if set by a participant.
String name;
/// The topic of the room if set by a participant.
String topic;
/// The avatar of the room if set by a participant.
MxContent avatar = MxContent("");
/// The count of unread notifications. /// The count of unread notifications.
int notificationCount; int notificationCount;
@ -57,7 +50,13 @@ class Room {
String prev_batch; String prev_batch;
String draft; List<String> mHeroes = [];
int mJoinedMemberCount;
int mInvitedMemberCount;
Map<String, RoomState> states = {};
Map<String, RoomAccountData> roomAccountData = {};
/// Time when the user has last read the chat. /// Time when the user has last read the chat.
ChatTime unread; ChatTime unread;
@ -65,69 +64,97 @@ class Room {
/// ID of the fully read marker event. /// ID of the fully read marker event.
String fullyRead; String fullyRead;
/// The address in the format: #roomname:homeserver.org. /// The name of the room if set by a participant.
String canonicalAlias; String get name {
if (states["m.room.name"] != null &&
!states["m.room.name"].content["name"].isEmpty)
return states["m.room.name"].content["name"];
if (canonicalAlias != null && !canonicalAlias.isEmpty)
return canonicalAlias.substring(1, canonicalAlias.length).split(":")[0];
if (mHeroes != null && mHeroes.length > 0) {
String displayname = "";
for (int i = 0; i < mHeroes.length; i++) {
User hero = states[mHeroes[i]] != null
? states[mHeroes[i]].asUser
: User(mHeroes[i]);
displayname += hero.calcDisplayname() + ", ";
}
return displayname.substring(0, displayname.length - 2);
}
return "Empty chat";
}
/// If this room is a direct chat, this is the matrix ID of the user /// The topic of the room if set by a participant.
String directChatMatrixID; String get topic => states["m.room.topic"] != null
? states["m.room.topic"].content["topic"]
: "";
/// The avatar of the room if set by a participant.
MxContent get avatar {
if (states["m.room.avatar"] != null)
return MxContent(states["m.room.avatar"].content["url"]);
if (mHeroes != null && mHeroes.length == 1 && states[mHeroes[0]] != null)
return states[mHeroes[0]].asUser.avatarUrl;
return MxContent("");
}
/// The address in the format: #roomname:homeserver.org.
String get canonicalAlias => states["m.room.canonical_alias"] != null
? states["m.room.canonical_alias"].content["alias"]
: "";
/// If this room is a direct chat, this is the matrix ID of the user.
/// Returns null otherwise.
String get directChatMatrixID {
String returnUserId = null;
if (client.directChats is Map<String, dynamic>) {
client.directChats.forEach((String userId, dynamic roomIds) {
if (roomIds is List<dynamic>) {
for (int i = 0; i < roomIds.length; i++)
if (roomIds[i] == this.id) {
returnUserId = userId;
break;
}
}
});
}
return returnUserId;
}
/// Wheither this is a direct chat or not
bool get isDirectChat => directChatMatrixID != null;
/// Must be one of [all, mention] /// Must be one of [all, mention]
String notificationSettings; String notificationSettings;
/// Are guest users allowed? Event get lastEvent {
String guestAccess; ChatTime lastTime = ChatTime(0);
Event lastEvent = null;
/// Who can see the history of this room? states.forEach((String key, RoomState state) {
String historyVisibility; if (state.time != null && state.time > lastTime) {
lastTime = state.time;
/// Who is allowed to join this room? lastEvent = state.timelineEvent;
String joinRules; }
});
/// The needed power levels for all actions. return lastEvent;
Map<String, int> powerLevels = {}; }
List<String> mHeroes;
int mJoinedMemberCount;
int mInvitedMemberCount;
Event lastEvent;
/// Your current client instance. /// Your current client instance.
final Client client; final Client client;
@Deprecated("Rooms.roomID is deprecated! Use Rooms.id instead!")
String get roomID => this.id;
@Deprecated("Rooms.matrix is deprecated! Use Rooms.client instead!")
Client get matrix => this.client;
@Deprecated("Rooms.status is deprecated! Use Rooms.membership instead!")
String get status => this.membership.toString().split('.').last;
Room({ Room({
this.id, this.id,
this.membership, this.membership = Membership.join,
this.name, this.notificationCount = 0,
this.topic, this.highlightCount = 0,
this.avatar,
this.notificationCount,
this.highlightCount,
this.prev_batch = "", this.prev_batch = "",
this.draft,
this.unread,
this.fullyRead,
this.canonicalAlias,
this.directChatMatrixID,
this.notificationSettings,
this.guestAccess,
this.historyVisibility,
this.joinRules,
this.powerLevels,
this.lastEvent,
this.client, this.client,
this.mHeroes, this.notificationSettings,
this.mInvitedMemberCount, this.mHeroes = const [],
this.mJoinedMemberCount, this.mInvitedMemberCount = 0,
this.mJoinedMemberCount = 0,
this.states = const {},
this.roomAccountData = const {},
}); });
/// Calculates the displayname. First checks if there is a name, then checks for a canonical alias and /// Calculates the displayname. First checks if there is a name, then checks for a canonical alias and
@ -297,9 +324,10 @@ class Room {
return res; return res;
} }
/// Call the Matrix API to unban a banned user from this room. /// Set the power level of the user with the [userID] to the value [power].
Future<dynamic> setPower(String userID, int power) async { Future<dynamic> setPower(String userID, int power) async {
Map<String, int> powerMap = await client.store.getPowerLevels(id); if (states["m.room.power_levels"] == null) return null;
Map<String, int> powerMap = states["m.room.power_levels"].content["users"];
powerMap[userID] = power; powerMap[userID] = power;
dynamic res = await client.connection.jsonRequest( dynamic res = await client.connection.jsonRequest(
@ -325,7 +353,7 @@ class Room {
final dynamic resp = await client.connection.jsonRequest( final dynamic resp = await client.connection.jsonRequest(
type: HTTPType.GET, type: HTTPType.GET,
action: action:
"/client/r0/rooms/$id/messages?from=${prev_batch}&dir=b&limit=$historyCount"); "/client/r0/rooms/$id/messages?from=${prev_batch}&dir=b&limit=$historyCount&filter=${Connection.syncFilters}");
if (resp is ErrorResponse) return; if (resp is ErrorResponse) return;
@ -336,6 +364,33 @@ class Room {
resp["chunk"].length > 0 && resp["chunk"].length > 0 &&
resp["end"] is String)) return; resp["end"] is String)) return;
if (resp["state"] is List<dynamic>) {
client.store?.transaction(() {
for (int i = 0; i < resp["state"].length; i++) {
EventUpdate eventUpdate = EventUpdate(
type: "state",
roomID: id,
eventType: resp["state"][i]["type"],
content: resp["state"][i],
);
client.connection.onEvent.add(eventUpdate);
client.store.storeEventUpdate(eventUpdate);
}
return;
});
if (client.store == null) {
for (int i = 0; i < resp["state"].length; i++) {
EventUpdate eventUpdate = EventUpdate(
type: "state",
roomID: id,
eventType: resp["state"][i]["type"],
content: resp["state"][i],
);
client.connection.onEvent.add(eventUpdate);
}
}
}
List<dynamic> history = resp["chunk"]; List<dynamic> history = resp["chunk"];
client.store?.transaction(() { client.store?.transaction(() {
for (int i = 0; i < history.length; i++) { for (int i = 0; i < history.length; i++) {
@ -348,7 +403,7 @@ class Room {
client.connection.onEvent.add(eventUpdate); client.connection.onEvent.add(eventUpdate);
client.store.storeEventUpdate(eventUpdate); client.store.storeEventUpdate(eventUpdate);
client.store.txn.rawUpdate( client.store.txn.rawUpdate(
"UPDATE Rooms SET prev_batch=? WHERE id=?", [resp["end"], id]); "UPDATE Rooms SET prev_batch=? WHERE room_id=?", [resp["end"], id]);
} }
return; return;
}); });
@ -367,8 +422,7 @@ class Room {
/// Sets this room as a direct chat for this user. /// Sets this room as a direct chat for this user.
Future<dynamic> addToDirectChat(String userID) async { Future<dynamic> addToDirectChat(String userID) async {
Map<String, List<String>> directChats = Map<String, dynamic> directChats = client.directChats;
await client.store.getAccountDataDirectChats();
if (directChats.containsKey(userID)) if (!directChats[userID].contains(id)) if (directChats.containsKey(userID)) if (!directChats[userID].contains(id))
directChats[userID].add(id); directChats[userID].add(id);
else else
@ -395,73 +449,58 @@ class Room {
return resp; return resp;
} }
/// Returns a Room from a json String which comes normally from the store. /// Returns a Room from a json String which comes normally from the store. If the
/// state are also given, the method will await them.
static Future<Room> getRoomFromTableRow( static Future<Room> getRoomFromTableRow(
Map<String, dynamic> row, Client matrix) async { Map<String, dynamic> row, Client matrix,
String avatarUrl = row["avatar_url"]; {Future<List<Map<String, dynamic>>> states,
if (avatarUrl == "") Future<List<Map<String, dynamic>>> roomAccountData}) async {
avatarUrl = await matrix.store?.getAvatarFromSingleChat(row["id"]) ?? ""; Room newRoom = Room(
id: row["room_id"],
return Room(
id: row["id"],
name: row["topic"],
membership: Membership.values membership: Membership.values
.firstWhere((e) => e.toString() == 'Membership.' + row["membership"]), .firstWhere((e) => e.toString() == 'Membership.' + row["membership"]),
topic: row["description"],
avatar: MxContent(avatarUrl),
notificationCount: row["notification_count"], notificationCount: row["notification_count"],
highlightCount: row["highlight_count"], highlightCount: row["highlight_count"],
unread: ChatTime(row["unread"]),
fullyRead: row["fully_read"],
notificationSettings: row["notification_settings"], notificationSettings: row["notification_settings"],
directChatMatrixID: row["direct_chat_matrix_id"],
draft: row["draft"],
prev_batch: row["prev_batch"], prev_batch: row["prev_batch"],
guestAccess: row["guest_access"],
historyVisibility: row["history_visibility"],
joinRules: row["join_rules"],
canonicalAlias: row["canonical_alias"],
mInvitedMemberCount: row["invited_member_count"], mInvitedMemberCount: row["invited_member_count"],
mJoinedMemberCount: row["joined_member_count"], mJoinedMemberCount: row["joined_member_count"],
mHeroes: row["heroes"]?.split(",") ?? [], mHeroes: row["heroes"]?.split(",") ?? [],
powerLevels: {
"power_events_default": row["power_events_default"],
"power_state_default": row["power_state_default"],
"power_redact": row["power_redact"],
"power_invite": row["power_invite"],
"power_ban": row["power_ban"],
"power_kick": row["power_kick"],
"power_user_default": row["power_user_default"],
"power_event_avatar": row["power_event_avatar"],
"power_event_history_visibility": row["power_event_history_visibility"],
"power_event_canonical_alias": row["power_event_canonical_alias"],
"power_event_aliases": row["power_event_aliases"],
"power_event_name": row["power_event_name"],
"power_event_power_levels": row["power_event_power_levels"],
},
lastEvent: Event.fromJson(row, null),
client: matrix, client: matrix,
states: {},
roomAccountData: {},
); );
}
@Deprecated("Use client.store.getRoomById(String id) instead!") Map<String, RoomState> newStates = {};
static Future<Room> getRoomById(String id, Client matrix) async { if (states != null) {
Room room = await matrix.store.getRoomById(id); List<Map<String, dynamic>> rawStates = await states;
return room; for (int i = 0; i < rawStates.length; i++) {
} RoomState newState = RoomState.fromJson(rawStates[i], newRoom);
newStates[newState.key] = newState;
}
newRoom.states = newStates;
}
/// Load a room from the store including all room events. Map<String, RoomAccountData> newRoomAccountData = {};
static Future<Room> loadRoomEvents(String id, Client matrix) async { if (roomAccountData != null) {
Room room = await matrix.store.getRoomById(id); List<Map<String, dynamic>> rawRoomAccountData = await roomAccountData;
await room.loadEvents(); for (int i = 0; i < rawRoomAccountData.length; i++) {
return room; RoomAccountData newData =
RoomAccountData.fromJson(rawRoomAccountData[i], newRoom);
newRoomAccountData[newData.typeKey] = newData;
}
newRoom.roomAccountData = newRoomAccountData;
}
return newRoom;
} }
/// Creates a timeline from the store. Returns a [Timeline] object. /// Creates a timeline from the store. Returns a [Timeline] object.
Future<Timeline> getTimeline( Future<Timeline> getTimeline(
{onTimelineUpdateCallback onUpdate, {onTimelineUpdateCallback onUpdate,
onTimelineInsertCallback onInsert}) async { onTimelineInsertCallback onInsert}) async {
List<Event> events = await loadEvents(); List<Event> events = [];
if (client.store != null) events = await client.store.getEventList(this);
return Timeline( return Timeline(
room: this, room: this,
events: events, events: events,
@ -470,17 +509,23 @@ class Room {
); );
} }
/// 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<List<Event>> loadEvents() async {
return await client.store.getEventList(this);
}
/// Load all participants for a given room from the store. /// Load all participants for a given room from the store.
@deprecated
Future<List<User>> loadParticipants() async { Future<List<User>> loadParticipants() async {
return await client.store.loadParticipants(this); return await client.store.loadParticipants(this);
} }
/// Returns all participants for this room. With lazy loading this
/// list may not be complete. User [requestParticipants] in this
/// case.
List<User> getParticipants() {
List<User> userList = [];
for (var entry in states.entries)
if (entry.value.type == EventTypes.RoomMember)
userList.add(entry.value.asUser);
return userList;
}
/// Request the full list of participants from the server. The local list /// Request the full list of participants from the server. The local list
/// from the store is not complete if the client uses lazy loading. /// from the store is not complete if the client uses lazy loading.
Future<List<User>> requestParticipants() async { Future<List<User>> requestParticipants() async {
@ -492,14 +537,7 @@ class Room {
return participants; return participants;
for (num i = 0; i < res["chunk"].length; i++) { for (num i = 0; i < res["chunk"].length; i++) {
User newUser = User(res["chunk"][i]["state_key"], User newUser = RoomState.fromJson(res["chunk"][i], this).asUser;
displayName: res["chunk"][i]["content"]["displayname"] ?? "",
membership: Membership.values.firstWhere((e) =>
e.toString() ==
'Membership.' + res["chunk"][i]["content"]["membership"] ??
""),
avatarUrl: MxContent(res["chunk"][i]["content"]["avatar_url"] ?? ""),
room: this);
if (newUser.membership != Membership.leave) participants.add(newUser); if (newUser.membership != Membership.leave) participants.add(newUser);
} }
@ -507,18 +545,14 @@ class Room {
} }
Future<User> getUserByMXID(String mxID) async { Future<User> getUserByMXID(String mxID) async {
if (client.store != null) { if (states[mxID] != null) return states[mxID].asUser;
final User storeEvent =
await client.store.getUser(matrixID: mxID, room: this);
if (storeEvent != null) return storeEvent;
}
final dynamic resp = await client.connection.jsonRequest( final dynamic resp = await client.connection.jsonRequest(
type: HTTPType.GET, type: HTTPType.GET,
action: "/client/r0/rooms/$id/state/m.room.member/$mxID"); action: "/client/r0/rooms/$id/state/m.room.member/$mxID");
if (resp is ErrorResponse) return null; if (resp is ErrorResponse) return null;
// Somehow we miss the mxid in the response and only get the content of the event. // Somehow we miss the mxid in the response and only get the content of the event.
resp["matrix_id"] = mxID; resp["matrix_id"] = mxID;
return User.fromJson(resp, this); return RoomState.fromJson(resp, this).asUser;
} }
/// Searches for the event in the store. If it isn't found, try to request it /// Searches for the event in the store. If it isn't found, try to request it
@ -531,7 +565,30 @@ class Room {
final dynamic resp = await client.connection.jsonRequest( final dynamic resp = await client.connection.jsonRequest(
type: HTTPType.GET, action: "/client/r0/rooms/$id/event/$eventID"); type: HTTPType.GET, action: "/client/r0/rooms/$id/event/$eventID");
if (resp is ErrorResponse) return null; if (resp is ErrorResponse) return null;
return Event.fromJson(resp, this, return Event.fromJson(resp, this);
senderUser: (await getUserByMXID(resp["sender"]))); }
/// Returns the user's own power level.
int getPowerLevelByUserId(String userId) {
int powerLevel = 0;
RoomState powerLevelState = states["m.room.power_levels"];
if (powerLevelState == null) return powerLevel;
if (powerLevelState.content["users_default"] is int)
powerLevel = powerLevelState.content["users_default"];
if (powerLevelState.content["users"] is Map<String, dynamic> &&
powerLevelState.content["users"][userId] != null)
powerLevel = powerLevelState.content["users"][userId];
return powerLevel;
}
/// Returns the user's own power level.
int get ownPowerLevel => getPowerLevelByUserId(client.userID);
/// Returns the power levels from all users for this room or null if not given.
Map<String, int> get powerLevels {
RoomState powerLevelState = states["m.room.power_levels"];
if (powerLevelState.content["users"] is Map<String, int>)
return powerLevelState.content["users"];
return null;
} }
} }

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2019 Zender & Kurtz GbR.
*
* Authors:
* Christian Pauly <krille@famedly.com>
* Marcel Radzio <mtrnord@famedly.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/AccountData.dart';
import 'package:famedlysdk/src/RoomState.dart';
class RoomAccountData extends AccountData {
/// The user who has sent this event if it is not a global account data event.
final String roomId;
final Room room;
RoomAccountData(
{this.roomId, this.room, Map<String, dynamic> content, String typeKey})
: super(content: content, typeKey: typeKey);
/// Get a State event from a table row or from the event stream.
factory RoomAccountData.fromJson(
Map<String, dynamic> jsonPayload, Room room) {
final Map<String, dynamic> content =
RoomState.getMapFromPayload(jsonPayload['content']);
return RoomAccountData(
content: content,
typeKey: jsonPayload['type'],
roomId: jsonPayload['room_id'],
room: room);
}
}

View file

@ -24,14 +24,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:core'; import 'dart:core';
import 'package:famedlysdk/src/RoomState.dart';
import 'Client.dart'; import 'Client.dart';
import 'Event.dart';
import 'Room.dart'; import 'Room.dart';
import 'User.dart'; import 'User.dart';
import 'sync/EventUpdate.dart'; import 'sync/EventUpdate.dart';
import 'sync/RoomUpdate.dart'; import 'sync/RoomUpdate.dart';
import 'utils/ChatTime.dart';
import 'utils/MxContent.dart'; typedef onRoomListUpdateCallback = void Function();
typedef onRoomListInsertCallback = void Function(int insertID);
typedef onRoomListRemoveCallback = void Function(int insertID);
/// Represents a list of rooms for this client, which will automatically update /// Represents a list of rooms for this client, which will automatically update
/// itself and call the [onUpdate], [onInsert] and [onDelete] callbacks. To get /// itself and call the [onUpdate], [onInsert] and [onDelete] callbacks. To get
@ -69,6 +72,21 @@ class RoomList {
this.onlyGroups = false}) { this.onlyGroups = false}) {
eventSub ??= client.connection.onEvent.stream.listen(_handleEventUpdate); eventSub ??= client.connection.onEvent.stream.listen(_handleEventUpdate);
roomSub ??= client.connection.onRoomUpdate.stream.listen(_handleRoomUpdate); roomSub ??= client.connection.onRoomUpdate.stream.listen(_handleRoomUpdate);
sort();
}
Room getRoomByAlias(String alias) {
for (int i = 0; i < rooms.length; i++) {
if (rooms[i].canonicalAlias == alias) return rooms[i];
}
return null;
}
Room getRoomById(String id) {
for (int j = 0; j < rooms.length; j++) {
if (rooms[j].id == id) return rooms[j];
}
return null;
} }
void _handleRoomUpdate(RoomUpdate chatUpdate) { void _handleRoomUpdate(RoomUpdate chatUpdate) {
@ -87,7 +105,6 @@ class RoomList {
// Add the new chat to the list // Add the new chat to the list
Room newRoom = Room( Room newRoom = Room(
id: chatUpdate.id, id: chatUpdate.id,
name: "",
membership: chatUpdate.membership, membership: chatUpdate.membership,
prev_batch: chatUpdate.prev_batch, prev_batch: chatUpdate.prev_batch,
highlightCount: chatUpdate.highlight_count, highlightCount: chatUpdate.highlight_count,
@ -95,6 +112,9 @@ class RoomList {
mHeroes: chatUpdate.summary?.mHeroes, mHeroes: chatUpdate.summary?.mHeroes,
mJoinedMemberCount: chatUpdate.summary?.mJoinedMemberCount, mJoinedMemberCount: chatUpdate.summary?.mJoinedMemberCount,
mInvitedMemberCount: chatUpdate.summary?.mInvitedMemberCount, mInvitedMemberCount: chatUpdate.summary?.mInvitedMemberCount,
states: {},
roomAccountData: {},
client: client,
); );
rooms.insert(position, newRoom); rooms.insert(position, newRoom);
if (onInsert != null) onInsert(position); if (onInsert != null) onInsert(position);
@ -125,11 +145,7 @@ class RoomList {
} }
void _handleEventUpdate(EventUpdate eventUpdate) { void _handleEventUpdate(EventUpdate eventUpdate) {
// Is the event necessary for the chat list? If not, then return if (eventUpdate.type != "timeline" && eventUpdate.type != "state") return;
if (!(eventUpdate.type == "timeline" ||
eventUpdate.eventType == "m.room.avatar" ||
eventUpdate.eventType == "m.room.name")) return;
// Search the room in the rooms // Search the room in the rooms
num j = 0; num j = 0;
for (j = 0; j < rooms.length; j++) { for (j = 0; j < rooms.length; j++) {
@ -138,44 +154,20 @@ class RoomList {
final bool found = (j < rooms.length && rooms[j].id == eventUpdate.roomID); final bool found = (j < rooms.length && rooms[j].id == eventUpdate.roomID);
if (!found) return; if (!found) return;
// Is this an old timeline event? Then stop here... RoomState stateEvent = RoomState.fromJson(eventUpdate.content, rooms[j]);
/*if (eventUpdate.type == "timeline" && if (rooms[j].states[stateEvent.key] != null &&
ChatTime(eventUpdate.content["origin_server_ts"]) <= rooms[j].states[stateEvent.key].time > stateEvent.time) return;
rooms[j].timeCreated) return;*/ rooms[j].states[stateEvent.key] = stateEvent;
if (eventUpdate.type == "timeline") {
User stateKey = null;
if (eventUpdate.content["state_key"] is String)
stateKey = User(eventUpdate.content["state_key"]);
// Update the last message preview
rooms[j].lastEvent = Event(
eventUpdate.content["id"],
User(eventUpdate.content["sender"]),
ChatTime(eventUpdate.content["origin_server_ts"]),
room: rooms[j],
stateKey: stateKey,
content: eventUpdate.content["content"],
environment: eventUpdate.eventType,
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();
} }
sortAndUpdate() { sort() {
rooms?.sort((a, b) => rooms?.sort((a, b) =>
b.timeCreated.toTimeStamp().compareTo(a.timeCreated.toTimeStamp())); b.timeCreated.toTimeStamp().compareTo(a.timeCreated.toTimeStamp()));
}
sortAndUpdate() {
sort();
if (onUpdate != null) onUpdate(); if (onUpdate != null) onUpdate();
} }
} }
typedef onRoomListUpdateCallback = void Function();
typedef onRoomListInsertCallback = void Function(int insertID);
typedef onRoomListRemoveCallback = void Function(int insertID);

215
lib/src/RoomState.dart Normal file
View file

@ -0,0 +1,215 @@
/*
* Copyright (c) 2019 Zender & Kurtz GbR.
*
* Authors:
* Christian Pauly <krille@famedly.com>
* Marcel Radzio <mtrnord@famedly.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/utils/ChatTime.dart';
import './Room.dart';
class RoomState {
/// The Matrix ID for this event in the format '$localpart:server.abc'. Please not
/// that account data, presence and other events may not have an eventId.
final String eventId;
/// The json payload of the content. The content highly depends on the type.
final Map<String, dynamic> content;
/// The type String of this event. For example 'm.room.message'.
final String typeKey;
/// The ID of the room this event belongs to.
final String roomId;
/// The user who has sent this event if it is not a global account data event.
final String senderId;
User get sender => room.states[senderId]?.asUser ?? User(senderId);
/// The time this event has received at the server. May be null for events like
/// account data.
final ChatTime time;
/// Optional additional content for this event.
final Map<String, dynamic> unsigned;
/// The room this event belongs to. May be null.
final Room room;
/// Optional. The previous content for this state.
/// This will be present only for state events appearing in the timeline.
/// If this is not a state event, or there is no previous content, this key will be null.
final Map<String, dynamic> prevContent;
/// Optional. This key will only be present for state events. A unique key which defines
/// the overwriting semantics for this piece of room state.
final String stateKey;
User get stateKeyUser => room.states[stateKey]?.asUser ?? User(stateKey);
RoomState(
{this.content,
this.typeKey,
this.eventId,
this.roomId,
this.senderId,
this.time,
this.unsigned,
this.prevContent,
this.stateKey,
this.room});
static Map<String, dynamic> getMapFromPayload(dynamic payload) {
if (payload is String)
try {
return json.decode(payload);
} catch (e) {
return {};
}
if (payload is Map<String, dynamic>) return payload;
return {};
}
/// Get a State event from a table row or from the event stream.
factory RoomState.fromJson(Map<String, dynamic> jsonPayload, Room room) {
final Map<String, dynamic> content =
RoomState.getMapFromPayload(jsonPayload['content']);
final Map<String, dynamic> unsigned =
RoomState.getMapFromPayload(jsonPayload['unsigned']);
final Map<String, dynamic> prevContent =
RoomState.getMapFromPayload(jsonPayload['prev_content']);
return RoomState(
stateKey: jsonPayload['state_key'],
prevContent: prevContent,
content: content,
typeKey: jsonPayload['type'],
eventId: jsonPayload['event_id'],
roomId: jsonPayload['room_id'],
senderId: jsonPayload['sender'],
time: ChatTime(jsonPayload['origin_server_ts']),
unsigned: unsigned,
room: room);
}
Event get timelineEvent => Event(
content: content,
typeKey: typeKey,
eventId: eventId,
room: room,
roomId: roomId,
senderId: senderId,
time: time,
unsigned: unsigned,
status: 1,
);
/// The unique key of this event. For events with a [stateKey], it will be the
/// stateKey. Otherwise it will be the [type] as a string.
String get key => stateKey == null || stateKey.isEmpty ? typeKey : stateKey;
User get asUser => User.fromState(
stateKey: stateKey,
prevContent: prevContent,
content: content,
typeKey: typeKey,
eventId: eventId,
roomId: roomId,
senderId: senderId,
time: time,
unsigned: unsigned,
room: room);
/// Get the real type.
EventTypes get type {
switch (typeKey) {
case "m.room.avatar":
return EventTypes.RoomAvatar;
case "m.room.name":
return EventTypes.RoomName;
case "m.room.topic":
return EventTypes.RoomTopic;
case "m.room.Aliases":
return EventTypes.RoomAliases;
case "m.room.canonical_alias":
return EventTypes.RoomCanonicalAlias;
case "m.room.create":
return EventTypes.RoomCreate;
case "m.room.join_rules":
return EventTypes.RoomJoinRules;
case "m.room.member":
return EventTypes.RoomMember;
case "m.room.power_levels":
return EventTypes.RoomPowerLevels;
case "m.room.guest_access":
return EventTypes.GuestAccess;
case "m.room.history_visibility":
return EventTypes.HistoryVisibility;
case "m.room.message":
switch (content["msgtype"] ?? "m.text") {
case "m.text":
if (content.containsKey("m.relates_to")) {
return EventTypes.Reply;
}
return EventTypes.Text;
case "m.notice":
return EventTypes.Notice;
case "m.emote":
return EventTypes.Emote;
case "m.image":
return EventTypes.Image;
case "m.video":
return EventTypes.Video;
case "m.audio":
return EventTypes.Audio;
case "m.file":
return EventTypes.File;
case "m.location":
return EventTypes.Location;
}
}
return EventTypes.Unknown;
}
}
enum EventTypes {
Text,
Emote,
Notice,
Image,
Video,
Audio,
File,
Location,
Reply,
RoomAliases,
RoomCanonicalAlias,
RoomCreate,
RoomJoinRules,
RoomMember,
RoomPowerLevels,
RoomName,
RoomTopic,
RoomAvatar,
GuestAccess,
HistoryVisibility,
Unknown,
}

View file

@ -25,6 +25,9 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:core'; import 'dart:core';
import 'package:famedlysdk/src/AccountData.dart';
import 'package:famedlysdk/src/Presence.dart';
import 'package:famedlysdk/src/RoomState.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
@ -55,7 +58,7 @@ class Store {
_init() async { _init() async {
var databasePath = await getDatabasesPath(); var databasePath = await getDatabasesPath();
String path = p.join(databasePath, "FluffyMatrix.db"); String path = p.join(databasePath, "FluffyMatrix.db");
_db = await openDatabase(path, version: 12, _db = await openDatabase(path, version: 14,
onCreate: (Database db, int version) async { onCreate: (Database db, int version) async {
await createTables(db); await createTables(db);
}, onUpgrade: (Database db, int oldVersion, int newVersion) async { }, onUpgrade: (Database db, int oldVersion, int newVersion) async {
@ -153,8 +156,8 @@ class Store {
} }
Future<void> storeRoomPrevBatch(Room room) async { Future<void> storeRoomPrevBatch(Room room) async {
await _db.rawUpdate( await _db.rawUpdate("UPDATE Rooms SET prev_batch=? WHERE room_id=?",
"UPDATE Rooms SET prev_batch=? WHERE id=?", [room.prev_batch, room.id]); [room.prev_batch, room.id]);
return null; return null;
} }
@ -163,8 +166,7 @@ class Store {
Future<void> storeRoomUpdate(RoomUpdate roomUpdate) { Future<void> storeRoomUpdate(RoomUpdate roomUpdate) {
// Insert the chat into the database if not exists // Insert the chat into the database if not exists
txn.rawInsert( txn.rawInsert(
"INSERT OR IGNORE INTO Rooms " + "INSERT OR IGNORE INTO Rooms " + "VALUES(?, ?, 0, 0, '', 0, 0, '') ",
"VALUES(?, ?, '', 0, 0, 0, 0, '', '', '', '', 0, '', '', '', '', '', '', '', '', 0, 50, 50, 0, 50, 50, 0, 50, 100, 50, 50, 50, 100) ",
[roomUpdate.id, roomUpdate.membership.toString().split('.').last]); [roomUpdate.id, roomUpdate.membership.toString().split('.').last]);
// Update the notification counts and the limited timeline boolean and the summary // Update the notification counts and the limited timeline boolean and the summary
@ -187,15 +189,15 @@ class Store {
updateQuery += ", heroes=?"; updateQuery += ", heroes=?";
updateArgs.add(roomUpdate.summary.mHeroes.join(",")); updateArgs.add(roomUpdate.summary.mHeroes.join(","));
} }
updateQuery += " WHERE id=?"; updateQuery += " WHERE room_id=?";
updateArgs.add(roomUpdate.id); updateArgs.add(roomUpdate.id);
txn.rawUpdate(updateQuery, updateArgs); txn.rawUpdate(updateQuery, updateArgs);
// Is the timeline limited? Then all previous messages should be // Is the timeline limited? Then all previous messages should be
// removed from the database! // removed from the database!
if (roomUpdate.limitedTimeline) { if (roomUpdate.limitedTimeline) {
txn.rawDelete("DELETE FROM Events WHERE chat_id=?", [roomUpdate.id]); txn.rawDelete("DELETE FROM Events WHERE room_id=?", [roomUpdate.id]);
txn.rawUpdate("UPDATE Rooms SET prev_batch=? WHERE id=?", txn.rawUpdate("UPDATE Rooms SET prev_batch=? WHERE room_id=?",
[roomUpdate.prev_batch, roomUpdate.id]); [roomUpdate.prev_batch, roomUpdate.id]);
} }
return null; return null;
@ -204,21 +206,17 @@ class Store {
/// Stores an UserUpdate object in the database. Must be called inside of /// Stores an UserUpdate object in the database. Must be called inside of
/// [transaction]. /// [transaction].
Future<void> storeUserEventUpdate(UserUpdate userUpdate) { Future<void> storeUserEventUpdate(UserUpdate userUpdate) {
switch (userUpdate.eventType) { if (userUpdate.type == "account_data")
case "m.direct": txn.rawInsert("INSERT OR REPLACE INTO AccountData VALUES(?, ?)", [
if (userUpdate.content["content"] is Map<String, dynamic>) { userUpdate.eventType,
final Map<String, dynamic> directMap = userUpdate.content["content"]; json.encode(userUpdate.content["content"]),
directMap.forEach((String key, dynamic value) { ]);
if (value is List<dynamic> && value.length > 0) else if (userUpdate.type == "presence")
for (int i = 0; i < value.length; i++) { txn.rawInsert("INSERT OR REPLACE INTO Presences VALUES(?, ?, ?)", [
txn.rawUpdate( userUpdate.eventType,
"UPDATE Rooms SET direct_chat_matrix_id=? WHERE id=?", userUpdate.content["sender"],
[key, value[i]]); json.encode(userUpdate.content["content"]),
} ]);
});
}
break;
}
return null; return null;
} }
@ -229,252 +227,96 @@ class Store {
String type = eventUpdate.type; String type = eventUpdate.type;
String chat_id = eventUpdate.roomID; String chat_id = eventUpdate.roomID;
// Get the state_key for m.room.member events
String state_key = "";
if (eventContent["state_key"] is String) {
state_key = eventContent["state_key"];
}
if (type == "timeline" || type == "history") { if (type == "timeline" || type == "history") {
// calculate the status // calculate the status
num status = 2; num status = 2;
if (eventContent["status"] is num) status = eventContent["status"]; if (eventContent["status"] is num) status = eventContent["status"];
// Make unsigned part of the content
if (eventContent.containsKey("unsigned")) {
Map<String, dynamic> newContent = {
"unsigned": eventContent["unsigned"]
};
eventContent["content"].forEach((key, val) => newContent[key] = val);
eventContent["content"] = newContent;
}
// Get the state_key for m.room.member events
String state_key = "";
if (eventContent["state_key"] is String) {
state_key = eventContent["state_key"];
}
// Save the event in the database // Save the event in the database
if ((status == 1 || status == -1) && if ((status == 1 || status == -1) &&
eventContent["unsigned"] is Map<String, dynamic> && eventContent["unsigned"] is Map<String, dynamic> &&
eventContent["unsigned"]["transaction_id"] is String) eventContent["unsigned"]["transaction_id"] is String)
txn.rawUpdate("UPDATE Events SET status=?, id=? WHERE id=?", [ txn.rawUpdate(
"UPDATE Events SET status=?, event_id=? WHERE event_id=?", [
status, status,
eventContent["event_id"], eventContent["event_id"],
eventContent["unsigned"]["transaction_id"] eventContent["unsigned"]["transaction_id"]
]); ]);
else else
txn.rawInsert( txn.rawInsert(
"INSERT OR REPLACE INTO Events VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", [ "INSERT OR REPLACE INTO Events VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
eventContent["event_id"], [
chat_id, eventContent["event_id"],
eventContent["origin_server_ts"], chat_id,
eventContent["sender"], eventContent["origin_server_ts"],
state_key, eventContent["sender"],
eventContent["content"]["body"], eventContent["type"],
eventContent["type"], json.encode(eventContent["unsigned"] ?? ""),
json.encode(eventContent["content"]), json.encode(eventContent["content"]),
status json.encode(eventContent["prevContent"]),
]); eventContent["state_key"],
status
]);
// Is there a transaction id? Then delete the event with this id. // Is there a transaction id? Then delete the event with this id.
if (status != -1 && if (status != -1 &&
eventUpdate.content.containsKey("unsigned") && eventUpdate.content.containsKey("unsigned") &&
eventUpdate.content["unsigned"]["transaction_id"] is String) eventUpdate.content["unsigned"]["transaction_id"] is String)
txn.rawDelete("DELETE FROM Events WHERE id=?", txn.rawDelete("DELETE FROM Events WHERE event_id=?",
[eventUpdate.content["unsigned"]["transaction_id"]]); [eventUpdate.content["unsigned"]["transaction_id"]]);
} }
if (type == "history") return null; if (type == "history") return null;
switch (eventUpdate.eventType) { if (eventUpdate.content["event_id"] != null) {
case "m.receipt": txn.rawInsert(
if (eventContent["user"] == client.userID) { "INSERT OR REPLACE INTO RoomStates VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
txn.rawUpdate("UPDATE Rooms SET unread=? WHERE id=?", [
[eventContent["ts"], chat_id]); eventContent["event_id"],
} else { chat_id,
// Mark all previous received messages as seen eventContent["origin_server_ts"],
txn.rawUpdate( eventContent["sender"],
"UPDATE Events SET status=3 WHERE origin_server_ts<=? AND chat_id=? AND status=2", state_key,
[eventContent["ts"], chat_id]); json.encode(eventContent["unsigned"] ?? ""),
} json.encode(eventContent["prev_content"] ?? ""),
break; eventContent["type"],
// This event means, that the name of a room has been changed, so json.encode(eventContent["content"]),
// it has to be changed in the database. ]);
case "m.room.name": } else
txn.rawUpdate("UPDATE Rooms SET topic=? WHERE id=?", txn.rawInsert("INSERT OR REPLACE INTO RoomAccountData VALUES(?, ?, ?)", [
[eventContent["content"]["name"], chat_id]); eventContent["type"],
break; chat_id,
// This event means, that the topic of a room has been changed, so json.encode(eventContent["content"]),
// it has to be changed in the database ]);
case "m.room.topic":
txn.rawUpdate("UPDATE Rooms SET description=? WHERE id=?",
[eventContent["content"]["topic"], chat_id]);
break;
// This event means, that the topic of a room has been changed, so
// it has to be changed in the database
case "m.room.history_visibility":
txn.rawUpdate("UPDATE Rooms SET history_visibility=? WHERE id=?",
[eventContent["content"]["history_visibility"], chat_id]);
break;
// This event means, that the topic of a room has been changed, so
// it has to be changed in the database
case "m.room.redaction":
txn.rawDelete(
"DELETE FROM Events WHERE id=?", [eventContent["redacts"]]);
break;
// This event means, that the topic of a room has been changed, so
// it has to be changed in the database
case "m.room.guest_access":
txn.rawUpdate("UPDATE Rooms SET guest_access=? WHERE id=?",
[eventContent["content"]["guest_access"], chat_id]);
break;
// This event means, that the canonical alias of a room has been changed, so
// it has to be changed in the database
case "m.room.canonical_alias":
txn.rawUpdate("UPDATE Rooms SET canonical_alias=? WHERE id=?",
[eventContent["content"]["alias"], chat_id]);
break;
// This event means, that the topic of a room has been changed, so
// it has to be changed in the database
case "m.room.join_rules":
txn.rawUpdate("UPDATE Rooms SET join_rules=? WHERE id=?",
[eventContent["content"]["join_rule"], chat_id]);
break;
// This event means, that the avatar of a room has been changed, so
// it has to be changed in the database
case "m.room.avatar":
txn.rawUpdate("UPDATE Rooms SET avatar_url=? WHERE id=?",
[eventContent["content"]["url"], chat_id]);
break;
// This event means, that the aliases of a room has been changed, so
// it has to be changed in the database
case "m.fully_read":
txn.rawUpdate("UPDATE Rooms SET fully_read=? WHERE id=?",
[eventContent["content"]["event_id"], chat_id]);
break;
// This event means, that someone joined the room, has left the room
// or has changed his nickname
case "m.room.member":
String membership = eventContent["content"]["membership"];
String state_key = eventContent["state_key"];
String insertDisplayname = "";
String insertAvatarUrl = "";
if (eventContent["content"]["displayname"] is String) {
insertDisplayname = eventContent["content"]["displayname"];
}
if (eventContent["content"]["avatar_url"] is String) {
insertAvatarUrl = eventContent["content"]["avatar_url"];
}
// Update membership table
txn.rawInsert("INSERT OR IGNORE INTO Users VALUES(?,?,?,?,?,0)", [
chat_id,
state_key,
insertDisplayname,
insertAvatarUrl,
membership
]);
String queryStr = "UPDATE Users SET membership=?";
List<String> queryArgs = [membership];
if (eventContent["content"]["displayname"] is String) {
queryStr += " , displayname=?";
queryArgs.add(eventContent["content"]["displayname"]);
}
if (eventContent["content"]["avatar_url"] is String) {
queryStr += " , avatar_url=?";
queryArgs.add(eventContent["content"]["avatar_url"]);
}
queryStr += " WHERE matrix_id=? AND chat_id=?";
queryArgs.add(state_key);
queryArgs.add(chat_id);
txn.rawUpdate(queryStr, queryArgs);
break;
// This event changes the permissions of the users and the power levels
case "m.room.power_levels":
String query = "UPDATE Rooms SET ";
if (eventContent["content"]["ban"] is num)
query += ", power_ban=" + eventContent["content"]["ban"].toString();
if (eventContent["content"]["events_default"] is num)
query += ", power_events_default=" +
eventContent["content"]["events_default"].toString();
if (eventContent["content"]["state_default"] is num)
query += ", power_state_default=" +
eventContent["content"]["state_default"].toString();
if (eventContent["content"]["redact"] is num)
query +=
", power_redact=" + eventContent["content"]["redact"].toString();
if (eventContent["content"]["invite"] is num)
query +=
", power_invite=" + eventContent["content"]["invite"].toString();
if (eventContent["content"]["kick"] is num)
query += ", power_kick=" + eventContent["content"]["kick"].toString();
if (eventContent["content"]["user_default"] is num)
query += ", power_user_default=" +
eventContent["content"]["user_default"].toString();
if (eventContent["content"]["events"] is Map<String, dynamic>) {
if (eventContent["content"]["events"]["m.room.avatar"] is num)
query += ", power_event_avatar=" +
eventContent["content"]["events"]["m.room.avatar"].toString();
if (eventContent["content"]["events"]["m.room.history_visibility"]
is num)
query += ", power_event_history_visibility=" +
eventContent["content"]["events"]["m.room.history_visibility"]
.toString();
if (eventContent["content"]["events"]["m.room.canonical_alias"]
is num)
query += ", power_event_canonical_alias=" +
eventContent["content"]["events"]["m.room.canonical_alias"]
.toString();
if (eventContent["content"]["events"]["m.room.aliases"] is num)
query += ", power_event_aliases=" +
eventContent["content"]["events"]["m.room.aliases"].toString();
if (eventContent["content"]["events"]["m.room.name"] is num)
query += ", power_event_name=" +
eventContent["content"]["events"]["m.room.name"].toString();
if (eventContent["content"]["events"]["m.room.power_levels"] is num)
query += ", power_event_power_levels=" +
eventContent["content"]["events"]["m.room.power_levels"]
.toString();
}
if (query != "UPDATE Rooms SET ") {
query = query.replaceFirst(",", "");
txn.rawUpdate(query + " WHERE id=?", [chat_id]);
}
// Set the users power levels:
if (eventContent["content"]["users"] is Map<String, dynamic>) {
eventContent["content"]["users"]
.forEach((String user, dynamic value) async {
num power_level = eventContent["content"]["users"][user];
txn.rawUpdate(
"UPDATE Users SET power_level=? WHERE matrix_id=? AND chat_id=?",
[power_level, user, chat_id]);
txn.rawInsert(
"INSERT OR IGNORE INTO Users VALUES(?, ?, '', '', ?, ?)",
[chat_id, user, "unknown", power_level]);
});
}
break;
}
return null; return null;
} }
/// Returns a User object by a given Matrix ID and a Room. /// Returns a User object by a given Matrix ID and a Room.
Future<User> getUser({String matrixID, Room room}) async { Future<User> getUser({String matrixID, Room room}) async {
List<Map<String, dynamic>> res = await db.rawQuery( List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT * FROM Users WHERE matrix_id=? AND chat_id=?", "SELECT * FROM RoomStates WHERE state_key=? AND room_id=?",
[matrixID, room.id]); [matrixID, room.id]);
if (res.length != 1) return null; if (res.length != 1) return null;
return User.fromJson(res[0], room); return RoomState.fromJson(res[0], room).asUser;
} }
/// Loads all Users in the database to provide a contact list /// Loads all Users in the database to provide a contact list
/// except users who are in the Room with the ID [exceptRoomID]. /// except users who are in the Room with the ID [exceptRoomID].
Future<List<User>> loadContacts({String exceptRoomID = ""}) async { Future<List<User>> loadContacts({String exceptRoomID = ""}) async {
List<Map<String, dynamic>> res = await db.rawQuery( List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT * FROM Users WHERE matrix_id!=? AND chat_id!=? GROUP BY matrix_id ORDER BY displayname", "SELECT * FROM RoomStates WHERE state_key LIKE '@%:%' AND state_key!=? AND room_id!=? GROUP BY state_key ORDER BY state_key",
[client.userID, exceptRoomID]); [client.userID, exceptRoomID]);
List<User> userList = []; List<User> userList = [];
for (int i = 0; i < res.length; i++) for (int i = 0; i < res.length; i++)
userList.add(User.fromJson(res[i], Room(id: "", client: client))); userList
.add(RoomState.fromJson(res[i], Room(id: "", client: client)).asUser);
return userList; return userList;
} }
@ -482,15 +324,15 @@ class Store {
Future<List<User>> loadParticipants(Room room) async { Future<List<User>> loadParticipants(Room room) async {
List<Map<String, dynamic>> res = await db.rawQuery( List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT * " + "SELECT * " +
" FROM Users " + " FROM RoomStates " +
" WHERE chat_id=? " + " WHERE room_id=? " +
" AND membership='join'", " AND type='m.room.member'",
[room.id]); [room.id]);
List<User> participants = []; List<User> participants = [];
for (num i = 0; i < res.length; i++) { for (num i = 0; i < res.length; i++) {
participants.add(User.fromJson(res[i], room)); participants.add(RoomState.fromJson(res[i], room).asUser);
} }
return participants; return participants;
@ -498,26 +340,18 @@ class Store {
/// Returns a list of events for the given room and sets all participants. /// Returns a list of events for the given room and sets all participants.
Future<List<Event>> getEventList(Room room) async { Future<List<Event>> getEventList(Room room) async {
List<Map<String, dynamic>> memberRes = await db.rawQuery(
"SELECT * " + " FROM Users " + " WHERE Users.chat_id=?", [room.id]);
Map<String, User> userMap = {};
for (num i = 0; i < memberRes.length; i++)
userMap[memberRes[i]["matrix_id"]] = User.fromJson(memberRes[i], room);
List<Map<String, dynamic>> eventRes = await db.rawQuery( List<Map<String, dynamic>> eventRes = await db.rawQuery(
"SELECT * " + "SELECT * " +
" FROM Events events " + " FROM Events " +
" WHERE events.chat_id=?" + " WHERE room_id=?" +
" GROUP BY events.id " + " GROUP BY event_id " +
" ORDER BY origin_server_ts DESC", " ORDER BY origin_server_ts DESC",
[room.id]); [room.id]);
List<Event> eventList = []; List<Event> eventList = [];
for (num i = 0; i < eventRes.length; i++) for (num i = 0; i < eventRes.length; i++)
eventList.add(Event.fromJson(eventRes[i], room, eventList.add(Event.fromJson(eventRes[i], room));
senderUser: userMap[eventRes[i]["sender"]],
stateKeyUser: userMap[eventRes[i]["state_key"]]));
return eventList; return eventList;
} }
@ -528,25 +362,17 @@ class Store {
bool onlyDirect = false, bool onlyDirect = false,
bool onlyGroups = false}) async { bool onlyGroups = false}) async {
if (onlyDirect && onlyGroups) return []; if (onlyDirect && onlyGroups) return [];
List<Map<String, dynamic>> res = await db.rawQuery( List<Map<String, dynamic>> res = await db.rawQuery("SELECT * " +
"SELECT rooms.*, events.origin_server_ts, events.content_json, events.type, events.sender, events.status, events.state_key " + " FROM Rooms" +
" FROM Rooms rooms LEFT JOIN Events events " + " WHERE membership" +
" ON rooms.id=events.chat_id " + (onlyLeft ? "=" : "!=") +
" WHERE rooms.membership" + "'leave' " +
(onlyLeft ? "=" : "!=") + " GROUP BY room_id ");
"'leave' " +
(onlyDirect ? " AND rooms.direct_chat_matrix_id!= '' " : "") +
(onlyGroups ? " AND rooms.direct_chat_matrix_id= '' " : "") +
" GROUP BY rooms.id " +
" ORDER BY origin_server_ts DESC ");
List<Room> roomList = []; List<Room> roomList = [];
for (num i = 0; i < res.length; i++) { for (num i = 0; i < res.length; i++) {
try { Room room = await Room.getRoomFromTableRow(res[i], client,
Room room = await Room.getRoomFromTableRow(res[i], client); states: getStatesFromRoomId(res[i]["room_id"]));
roomList.add(room); roomList.add(room);
} catch (e) {
print(e.toString());
}
} }
return roomList; return roomList;
} }
@ -554,114 +380,47 @@ class Store {
/// Returns a room without events and participants. /// Returns a room without events and participants.
Future<Room> getRoomById(String id) async { Future<Room> getRoomById(String id) async {
List<Map<String, dynamic>> res = List<Map<String, dynamic>> res =
await db.rawQuery("SELECT * FROM Rooms WHERE id=?", [id]); await db.rawQuery("SELECT * FROM Rooms WHERE room_id=?", [id]);
if (res.length != 1) return null; if (res.length != 1) return null;
return Room.getRoomFromTableRow(res[0], client); return Room.getRoomFromTableRow(res[0], client,
states: getStatesFromRoomId(id));
} }
/// Returns a room without events and participants. Future<List<Map<String, dynamic>>> getStatesFromRoomId(String id) async {
Future<Room> getRoomByAlias(String alias) async { return db.rawQuery("SELECT * FROM RoomStates WHERE room_id=?", [id]);
List<Map<String, dynamic>> res = await db
.rawQuery("SELECT * FROM Rooms WHERE canonical_alias=?", [alias]);
if (res.length != 1) return null;
return Room.getRoomFromTableRow(res[0], client);
}
/// Calculates and returns an avatar for a direct chat by a given [roomID].
Future<String> getAvatarFromSingleChat(String roomID) async {
String avatarStr = "";
List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT avatar_url FROM Users " +
" WHERE Users.chat_id=? " +
" AND (Users.membership='join' OR Users.membership='invite') " +
" AND Users.matrix_id!=? ",
[roomID, client.userID]);
if (res.length == 1) avatarStr = res[0]["avatar_url"];
return avatarStr;
}
/// Calculates a chat name for a groupchat without a name. The chat name will
/// be the name of all users (excluding the user of this client) divided by
/// ','.
Future<String> getChatNameFromMemberNames(String roomID) async {
String displayname = 'Empty chat';
List<Map<String, dynamic>> rs = await db.rawQuery(
"SELECT Users.displayname, Users.matrix_id, Users.membership FROM Users " +
" WHERE Users.chat_id=? " +
" AND (Users.membership='join' OR Users.membership='invite') " +
" AND Users.matrix_id!=? ",
[roomID, client.userID]);
if (rs.length > 0) {
displayname = "";
for (var i = 0; i < rs.length; i++) {
String username = rs[i]["displayname"];
if (username == "" || username == null) username = rs[i]["matrix_id"];
if (rs[i]["state_key"] != client.userID) displayname += username + ", ";
}
if (displayname == "" || displayname == null)
displayname = 'Empty chat';
else
displayname = displayname.substring(0, displayname.length - 2);
}
return displayname;
}
/// Returns the (first) room ID from the store which is a private chat with
/// the user [userID]. Returns null if there is none.
Future<String> getDirectChatRoomID(String userID) async {
List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT id FROM Rooms WHERE direct_chat_matrix_id=? AND membership!='leave' LIMIT 1",
[userID]);
if (res.length != 1) return null;
return res[0]["id"];
}
/// Returns the power level of the user for the given [roomID]. Returns null if
/// the room or the own user wasn't found.
Future<int> getPowerLevel(String roomID) async {
List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT power_level FROM Users WHERE matrix_id=? AND chat_id=?",
[roomID, client.userID]);
if (res.length != 1) return null;
return res[0]["power_level"];
}
/// Returns the power levels from all users for the given [roomID].
Future<Map<String, int>> getPowerLevels(String roomID) async {
List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT matrix_id, power_level FROM Users WHERE chat_id=?",
[roomID, client.userID]);
Map<String, int> powerMap = {};
for (int i = 0; i < res.length; i++)
powerMap[res[i]["matrix_id"]] = res[i]["power_level"];
return powerMap;
}
Future<Map<String, List<String>>> getAccountDataDirectChats() async {
Map<String, List<String>> directChats = {};
List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT id, direct_chat_matrix_id FROM Rooms WHERE direct_chat_matrix_id!=''");
for (int i = 0; i < res.length; i++) {
if (directChats.containsKey(res[i]["direct_chat_matrix_id"]))
directChats[res[i]["direct_chat_matrix_id"]].add(res[i]["id"]);
else
directChats[res[i]["direct_chat_matrix_id"]] = [res[i]["id"]];
}
return directChats;
} }
Future<void> forgetRoom(String roomID) async { Future<void> forgetRoom(String roomID) async {
await db.rawDelete("DELETE FROM Rooms WHERE id=?", [roomID]); await db.rawDelete("DELETE FROM Rooms WHERE room_id=?", [roomID]);
return; return;
} }
/// Searches for the event in the store. /// Searches for the event in the store.
Future<Event> getEventById(String eventID, Room room) async { Future<Event> getEventById(String eventID, Room room) async {
List<Map<String, dynamic>> res = await db.rawQuery( List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT * FROM Events WHERE id=? AND chat_id=?", [eventID, room.id]); "SELECT * FROM Events WHERE id=? AND room_id=?", [eventID, room.id]);
if (res.length == 0) return null; if (res.length == 0) return null;
return Event.fromJson(res[0], room, return Event.fromJson(res[0], room);
senderUser: (await room.getUserByMXID(res[0]["sender"]))); }
Future<Map<String, AccountData>> getAccountData() async {
Map<String, AccountData> newAccountData = {};
List<Map<String, dynamic>> rawAccountData =
await db.rawQuery("SELECT * FROM AccountData");
for (int i = 0; i < rawAccountData.length; i++)
newAccountData[rawAccountData[i]["type"]] =
AccountData.fromJson(rawAccountData[i]);
return newAccountData;
}
Future<Map<String, Presence>> getPresences() async {
Map<String, Presence> newPresences = {};
List<Map<String, dynamic>> rawPresences =
await db.rawQuery("SELECT * FROM Presences");
for (int i = 0; i < rawPresences.length; i++)
newPresences[rawPresences[i]["type"]] =
Presence.fromJson(rawPresences[i]);
return newPresences;
} }
Future forgetNotification(String roomID) async { Future forgetNotification(String roomID) async {
@ -679,7 +438,8 @@ class Store {
"INSERT INTO NotificationsCache(id, chat_id, event_id) VALUES (?, ?, ?)", "INSERT INTO NotificationsCache(id, chat_id, event_id) VALUES (?, ?, ?)",
[uniqueID, roomID, event_id]); [uniqueID, roomID, event_id]);
// Make sure we got the same unique ID everywhere // Make sure we got the same unique ID everywhere
await db.rawUpdate("UPDATE NotificationsCache SET id=? WHERE chat_id=?", [uniqueID, roomID]); await db.rawUpdate("UPDATE NotificationsCache SET id=? WHERE chat_id=?",
[uniqueID, roomID]);
return; return;
} }
@ -707,70 +467,63 @@ class Store {
'UNIQUE(client))', 'UNIQUE(client))',
/// The database scheme for the Room class. /// The database scheme for the Room class.
"Rooms": 'CREATE TABLE IF NOT EXISTS Rooms(' + 'Rooms': 'CREATE TABLE IF NOT EXISTS Rooms(' +
'id TEXT PRIMARY KEY, ' + 'room_id TEXT PRIMARY KEY, ' +
'membership TEXT, ' + 'membership TEXT, ' +
'topic TEXT, ' +
'highlight_count INTEGER, ' + 'highlight_count INTEGER, ' +
'notification_count INTEGER, ' + 'notification_count INTEGER, ' +
'prev_batch TEXT, ' +
'joined_member_count INTEGER, ' + 'joined_member_count INTEGER, ' +
'invited_member_count INTEGER, ' + 'invited_member_count INTEGER, ' +
'heroes TEXT, ' + 'heroes TEXT, ' +
'prev_batch TEXT, ' + 'UNIQUE(room_id))',
'avatar_url TEXT, ' +
'draft TEXT, ' +
'unread INTEGER, ' + // Timestamp of when the user has last read the chat
'fully_read TEXT, ' + // ID of the fully read marker event
'description TEXT, ' +
'canonical_alias TEXT, ' + // The address in the form: #roomname:homeserver.org
'direct_chat_matrix_id TEXT, ' + //If this room is a direct chat, this is the matrix ID of the user
'notification_settings TEXT, ' + // Must be one of [all, mention]
// Security rules /// The database scheme for the TimelineEvent class.
'guest_access TEXT, ' + 'Events': 'CREATE TABLE IF NOT EXISTS Events(' +
'history_visibility TEXT, ' + 'event_id TEXT PRIMARY KEY, ' +
'join_rules TEXT, ' + 'room_id TEXT, ' +
'origin_server_ts INTEGER, ' +
'sender TEXT, ' +
'type TEXT, ' +
'unsigned TEXT, ' +
'content TEXT, ' +
'prev_content TEXT, ' +
'state_key TEXT, ' +
"status INTEGER, " +
'UNIQUE(event_id))',
// Power levels /// The database scheme for room states.
'power_events_default INTEGER, ' + 'RoomStates': 'CREATE TABLE IF NOT EXISTS RoomStates(' +
'power_state_default INTEGER, ' + 'event_id TEXT PRIMARY KEY, ' +
'power_redact INTEGER, ' + 'room_id TEXT, ' +
'power_invite INTEGER, ' +
'power_ban INTEGER, ' +
'power_kick INTEGER, ' +
'power_user_default INTEGER, ' +
// Power levels for events
'power_event_avatar INTEGER, ' +
'power_event_history_visibility INTEGER, ' +
'power_event_canonical_alias INTEGER, ' +
'power_event_aliases INTEGER, ' +
'power_event_name INTEGER, ' +
'power_event_power_levels INTEGER, ' +
'UNIQUE(id))',
/// The database scheme for the Event class.
"Events": 'CREATE TABLE IF NOT EXISTS Events(' +
'id TEXT PRIMARY KEY, ' +
'chat_id TEXT, ' +
'origin_server_ts INTEGER, ' + 'origin_server_ts INTEGER, ' +
'sender TEXT, ' + 'sender TEXT, ' +
'state_key TEXT, ' + 'state_key TEXT, ' +
'content_body TEXT, ' + 'unsigned TEXT, ' +
'prev_content TEXT, ' +
'type TEXT, ' + 'type TEXT, ' +
'content_json TEXT, ' + 'content TEXT, ' +
"status INTEGER, " + 'UNIQUE(room_id,state_key,type))',
'UNIQUE(id))',
/// The database scheme for the User class. /// The database scheme for room states.
"Users": 'CREATE TABLE IF NOT EXISTS Users(' + 'AccountData': 'CREATE TABLE IF NOT EXISTS AccountData(' +
'chat_id TEXT, ' + // The chat id of this membership 'type TEXT PRIMARY KEY, ' +
'matrix_id TEXT, ' + // The matrix id of this user 'content TEXT, ' +
'displayname TEXT, ' + 'UNIQUE(type))',
'avatar_url TEXT, ' +
'membership TEXT, ' + // The status of the membership. Must be one of [join, invite, ban, leave] /// The database scheme for room states.
'power_level INTEGER, ' + // The power level of this user. Must be in [0,..,100] 'RoomAccountData': 'CREATE TABLE IF NOT EXISTS RoomAccountData(' +
'UNIQUE(chat_id, matrix_id))', 'type TEXT PRIMARY KEY, ' +
'room_id TEXT, ' +
'content TEXT, ' +
'UNIQUE(type,room_id))',
/// The database scheme for room states.
'Presences': 'CREATE TABLE IF NOT EXISTS Presences(' +
'type TEXT PRIMARY KEY, ' +
'sender TEXT, ' +
'content TEXT, ' +
'UNIQUE(sender))',
/// The database scheme for the NotificationsCache class. /// The database scheme for the NotificationsCache class.
"NotificationsCache": 'CREATE TABLE IF NOT EXISTS NotificationsCache(' + "NotificationsCache": 'CREATE TABLE IF NOT EXISTS NotificationsCache(' +

View file

@ -28,6 +28,9 @@ import 'Room.dart';
import 'User.dart'; import 'User.dart';
import 'sync/EventUpdate.dart'; import 'sync/EventUpdate.dart';
typedef onTimelineUpdateCallback = void Function();
typedef onTimelineInsertCallback = void Function(int insertID);
/// Represents the timeline of a room. The callbacks [onUpdate], [onDelete], /// Represents the timeline of a room. The callbacks [onUpdate], [onDelete],
/// [onInsert] and [onResort] will be triggered automatically. The initial /// [onInsert] and [onResort] will be triggered automatically. The initial
/// event list will be retreived when created by the [room.getTimeline] method. /// event list will be retreived when created by the [room.getTimeline] method.
@ -47,8 +50,8 @@ class Timeline {
int _findEvent({String event_id, String unsigned_txid}) { int _findEvent({String event_id, String unsigned_txid}) {
int i; int i;
for (i = 0; i < events.length; i++) { for (i = 0; i < events.length; i++) {
if (events[i].id == event_id || if (events[i].eventId == event_id ||
(unsigned_txid != null && events[i].id == unsigned_txid)) break; (unsigned_txid != null && events[i].eventId == unsigned_txid)) break;
} }
return i; return i;
} }
@ -82,33 +85,7 @@ class Timeline {
eventUpdate.content["avatar_url"] = senderUser.avatarUrl.mxc; eventUpdate.content["avatar_url"] = senderUser.avatarUrl.mxc;
} }
User stateKeyUser; newEvent = Event.fromJson(eventUpdate.content, room);
if (eventUpdate.content.containsKey("state_key")) {
stateKeyUser = await room.client.store?.getUser(
matrixID: eventUpdate.content["state_key"], room: room);
}
if (senderUser != null && stateKeyUser != null) {
newEvent = Event.fromJson(eventUpdate.content, room,
senderUser: senderUser, stateKeyUser: stateKeyUser);
} else if (senderUser != null) {
newEvent = Event.fromJson(eventUpdate.content, room,
senderUser: senderUser);
} else if (stateKeyUser != null) {
newEvent = Event.fromJson(eventUpdate.content, room,
stateKeyUser: stateKeyUser);
} else {
newEvent = Event.fromJson(eventUpdate.content, room);
}
// TODO update to type check when https://gitlab.com/famedly/famedlysdk/merge_requests/28/ is merged
if (newEvent.content.containsKey("m.relates_to")) {
Map<String, dynamic> relates_to = newEvent.content["m.relates_to"];
if (relates_to.containsKey("m.in_reply_to")) {
newEvent.replyEvent = await room.getEventById(newEvent
.content["m.relates_to"]["m.in_reply_to"]["event_id"]);
}
}
events.insert(0, newEvent); events.insert(0, newEvent);
if (onInsert != null) onInsert(0); if (onInsert != null) onInsert(0);
@ -128,6 +105,3 @@ class Timeline {
if (onUpdate != null) onUpdate(); if (onUpdate != null) onUpdate();
} }
} }
typedef onTimelineUpdateCallback = void Function();
typedef onTimelineInsertCallback = void Function(int insertID);

View file

@ -22,7 +22,9 @@
*/ */
import 'package:famedlysdk/src/Room.dart'; import 'package:famedlysdk/src/Room.dart';
import 'package:famedlysdk/src/RoomState.dart';
import 'package:famedlysdk/src/responses/ErrorResponse.dart'; import 'package:famedlysdk/src/responses/ErrorResponse.dart';
import 'package:famedlysdk/src/utils/ChatTime.dart';
import 'package:famedlysdk/src/utils/MxContent.dart'; import 'package:famedlysdk/src/utils/MxContent.dart';
import 'Connection.dart'; import 'Connection.dart';
@ -30,85 +32,80 @@ import 'Connection.dart';
enum Membership { join, invite, leave, ban } enum Membership { join, invite, leave, ban }
/// Represents a Matrix User which may be a participant in a Matrix Room. /// Represents a Matrix User which may be a participant in a Matrix Room.
class User { class User extends RoomState {
factory User(
String id, {
String membership,
String displayName,
String avatarUrl,
Room room,
}) {
Map<String, String> content = {};
if (membership != null) content["membership"] = membership;
if (displayName != null) content["displayname"] = displayName;
if (avatarUrl != null) content["avatar_url"] = avatarUrl;
return User.fromState(
stateKey: id,
content: content,
typeKey: "m.room.member",
roomId: room?.id,
room: room,
time: ChatTime.now(),
);
}
User.fromState(
{dynamic prevContent,
String stateKey,
dynamic content,
String typeKey,
String eventId,
String roomId,
String senderId,
ChatTime time,
dynamic unsigned,
Room room})
: super(
stateKey: stateKey,
prevContent: prevContent,
content: content,
typeKey: typeKey,
eventId: eventId,
roomId: roomId,
senderId: senderId,
time: time,
unsigned: unsigned,
room: room);
/// The full qualified Matrix ID in the format @username:server.abc. /// The full qualified Matrix ID in the format @username:server.abc.
final String id; String get id => stateKey;
/// The displayname of the user if the user has set one. /// The displayname of the user if the user has set one.
final String displayName; String get displayName => content != null ? content["displayname"] : null;
/// The membership status of the user. One of: /// The membership status of the user. One of:
/// join /// join
/// invite /// invite
/// leave /// leave
/// ban /// ban
Membership membership; Membership get membership => Membership.values.firstWhere((e) {
if (content["membership"] != null) {
return e.toString() == 'Membership.' + content['membership'];
}
return false;
});
/// The avatar if the user has one. /// The avatar if the user has one.
MxContent avatarUrl; MxContent get avatarUrl => content != null && content["avatar_url"] is String
? MxContent(content["avatar_url"])
/// The powerLevel of the user. Normally: : MxContent("");
/// 0=Normal user
/// 50=Moderator
/// 100=Admin
int powerLevel = 0;
/// All users normally belong to a room.
final Room room;
@Deprecated("Use membership instead!")
String get status => membership.toString().split('.').last;
@Deprecated("Use ID instead!")
String get mxid => id;
@Deprecated("Use avatarUrl instead!")
MxContent get avatar_url => avatarUrl;
User(
String id, {
this.membership,
this.displayName,
this.avatarUrl,
this.powerLevel,
this.room,
}) : this.id = id ?? "";
/// Returns the displayname or the local part of the Matrix ID if the user /// Returns the displayname or the local part of the Matrix ID if the user
/// has no displayname. /// has no displayname.
String calcDisplayname() => (displayName == null || displayName.isEmpty) String calcDisplayname() => (displayName == null || displayName.isEmpty)
? id.replaceFirst("@", "").split(":")[0] ? stateKey.replaceFirst("@", "").split(":")[0]
: displayName; : displayName;
/// Creates a new User object from a json string like a row from the database.
static User fromJson(Map<String, dynamic> json, Room room) {
return User(json['matrix_id'] ?? json['sender'],
displayName: json['displayname'],
avatarUrl: MxContent(json['avatar_url']),
membership: Membership.values.firstWhere((e) {
if (json["membership"] != null) {
return e.toString() == 'Membership.' + json['membership'];
}
return false;
}, orElse: () => null),
powerLevel: json['power_level'],
room: room);
}
/// Checks if the client's user has the permission to kick this user.
Future<bool> get canKick async {
final int ownPowerLevel = await room.client.store.getPowerLevel(room.id);
return ownPowerLevel > powerLevel &&
ownPowerLevel >= room.powerLevels["power_kick"];
}
/// Checks if the client's user has the permission to ban or unban this user.
Future<bool> get canBan async {
final int ownPowerLevel = await room.client.store.getPowerLevel(room.id);
return ownPowerLevel > powerLevel &&
ownPowerLevel >= room.powerLevels["power_ban"];
}
/// Call the Matrix API to kick this user from this room. /// Call the Matrix API to kick this user from this room.
Future<dynamic> kick() async { Future<dynamic> kick() async {
dynamic res = await room.kick(id); dynamic res = await room.kick(id);
@ -137,7 +134,7 @@ class User {
/// Returns null on error. /// Returns null on error.
Future<String> startDirectChat() async { Future<String> startDirectChat() async {
// Try to find an existing direct chat // Try to find an existing direct chat
String roomID = await room.client?.store?.getDirectChatRoomID(id); String roomID = await room.client?.getDirectChatFromUserId(id);
if (roomID != null) return roomID; if (roomID != null) return roomID;
// Start a new direct chat // Start a new direct chat

View file

@ -59,7 +59,6 @@ class ChatTime {
return toTimeString(); return toTimeString();
} else if (sameWeek) { } else if (sameWeek) {
switch (dateTime.weekday) { switch (dateTime.weekday) {
// TODO: Needs localization
case 1: case 1:
return "Montag"; return "Montag";
case 2: case 2:

View file

@ -23,8 +23,10 @@
import 'dart:async'; import 'dart:async';
import 'package:famedlysdk/src/AccountData.dart';
import 'package:famedlysdk/src/Client.dart'; import 'package:famedlysdk/src/Client.dart';
import 'package:famedlysdk/src/Connection.dart'; import 'package:famedlysdk/src/Connection.dart';
import 'package:famedlysdk/src/Presence.dart';
import 'package:famedlysdk/src/User.dart'; import 'package:famedlysdk/src/User.dart';
import 'package:famedlysdk/src/requests/SetPushersRequest.dart'; import 'package:famedlysdk/src/requests/SetPushersRequest.dart';
import 'package:famedlysdk/src/responses/ErrorResponse.dart'; import 'package:famedlysdk/src/responses/ErrorResponse.dart';
@ -61,10 +63,19 @@ void main() {
Future<ErrorResponse> errorFuture = Future<ErrorResponse> errorFuture =
matrix.connection.onError.stream.first; matrix.connection.onError.stream.first;
int presenceCounter = 0;
int accountDataCounter = 0;
matrix.onPresence = (Presence data) {
presenceCounter++;
};
matrix.onAccountData = (AccountData data) {
accountDataCounter++;
};
final bool checkResp1 = final bool checkResp1 =
await matrix.checkServer("https://fakeServer.wrongaddress"); await matrix.checkServer("https://fakeserver.wrongaddress");
final bool checkResp2 = final bool checkResp2 =
await matrix.checkServer("https://fakeServer.notExisting"); await matrix.checkServer("https://fakeserver.notexisting");
ErrorResponse checkError = await errorFuture; ErrorResponse checkError = await errorFuture;
@ -107,6 +118,23 @@ void main() {
expect(loginState, LoginState.logged); expect(loginState, LoginState.logged);
expect(firstSync, true); expect(firstSync, true);
expect(sync["next_batch"] == matrix.prevBatch, true); expect(sync["next_batch"] == matrix.prevBatch, true);
expect(matrix.accountData.length, 2);
expect(matrix.getDirectChatFromUserId("@bob:example.com"),
"!726s6s6q:example.com");
expect(matrix.roomList.rooms[1].directChatMatrixID, "@bob:example.com");
expect(matrix.directChats, matrix.accountData["m.direct"].content);
expect(matrix.presences.length, 1);
expect(matrix.roomList.rooms.length, 2);
expect(matrix.roomList.rooms[1].canonicalAlias,
"#famedlyContactDiscovery:${matrix.userID.split(":")[1]}");
final List<User> contacts = await matrix.loadFamedlyContacts();
expect(contacts.length, 1);
expect(contacts[0].senderId, "@alice:example.org");
expect(
matrix.presences["@alice:example.com"].content["presence"], "online");
expect(presenceCounter, 1);
expect(accountDataCounter, 2);
}); });
test('Try to get ErrorResponse', () async { test('Try to get ErrorResponse', () async {
@ -172,36 +200,39 @@ void main() {
List<EventUpdate> eventUpdateList = await eventUpdateListFuture; List<EventUpdate> eventUpdateList = await eventUpdateListFuture;
expect(eventUpdateList.length, 7); expect(eventUpdateList.length, 8);
expect(eventUpdateList[0].eventType == "m.room.member", true); expect(eventUpdateList[0].eventType, "m.room.member");
expect(eventUpdateList[0].roomID == "!726s6s6q:example.com", true); expect(eventUpdateList[0].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[0].type == "state", true); expect(eventUpdateList[0].type, "state");
expect(eventUpdateList[1].eventType == "m.room.member", true); expect(eventUpdateList[1].eventType, "m.room.canonical_alias");
expect(eventUpdateList[1].roomID == "!726s6s6q:example.com", true); expect(eventUpdateList[1].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[1].type == "timeline", true); expect(eventUpdateList[1].type, "state");
expect(eventUpdateList[2].eventType == "m.room.message", true); expect(eventUpdateList[2].eventType, "m.room.member");
expect(eventUpdateList[2].roomID == "!726s6s6q:example.com", true); expect(eventUpdateList[2].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[2].type == "timeline", true); expect(eventUpdateList[2].type, "timeline");
expect(eventUpdateList[3].eventType == "m.tag", true); expect(eventUpdateList[3].eventType, "m.room.message");
expect(eventUpdateList[3].roomID == "!726s6s6q:example.com", true); expect(eventUpdateList[3].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[3].type == "account_data", true); expect(eventUpdateList[3].type, "timeline");
expect(eventUpdateList[4].eventType == "org.example.custom.room.config", expect(eventUpdateList[4].eventType, "m.tag");
true); expect(eventUpdateList[4].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[4].roomID == "!726s6s6q:example.com", true); expect(eventUpdateList[4].type, "account_data");
expect(eventUpdateList[4].type == "account_data", true);
expect(eventUpdateList[5].eventType == "m.room.name", true); expect(eventUpdateList[5].eventType, "org.example.custom.room.config");
expect(eventUpdateList[5].roomID == "!696r7674:example.com", true); expect(eventUpdateList[5].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[5].type == "invite_state", true); expect(eventUpdateList[5].type, "account_data");
expect(eventUpdateList[6].eventType == "m.room.member", true); expect(eventUpdateList[6].eventType, "m.room.name");
expect(eventUpdateList[6].roomID == "!696r7674:example.com", true); expect(eventUpdateList[6].roomID, "!696r7674:example.com");
expect(eventUpdateList[6].type == "invite_state", true); expect(eventUpdateList[6].type, "invite_state");
expect(eventUpdateList[7].eventType, "m.room.member");
expect(eventUpdateList[7].roomID, "!696r7674:example.com");
expect(eventUpdateList[7].type, "invite_state");
}); });
test('User Update Test', () async { test('User Update Test', () async {
@ -209,16 +240,13 @@ void main() {
List<UserUpdate> eventUpdateList = await userUpdateListFuture; List<UserUpdate> eventUpdateList = await userUpdateListFuture;
expect(eventUpdateList.length, 3); expect(eventUpdateList.length, 4);
expect(eventUpdateList[0].eventType == "m.presence", true); expect(eventUpdateList[0].eventType == "m.presence", true);
expect(eventUpdateList[0].type == "presence", true); expect(eventUpdateList[0].type == "presence", true);
expect(eventUpdateList[1].eventType == "org.example.custom.config", true); expect(eventUpdateList[1].eventType == "org.example.custom.config", true);
expect(eventUpdateList[1].type == "account_data", true); expect(eventUpdateList[1].type == "account_data", true);
expect(eventUpdateList[2].eventType == "m.new_device", true);
expect(eventUpdateList[2].type == "to_device", true);
}); });
testWidgets('should get created', create); testWidgets('should get created', create);

View file

@ -24,8 +24,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:famedlysdk/famedlysdk.dart'; import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/Event.dart'; import 'package:famedlysdk/src/RoomState.dart';
import 'package:famedlysdk/src/User.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'FakeMatrixApi.dart'; import 'FakeMatrixApi.dart';
@ -36,9 +35,6 @@ void main() {
final int timestamp = DateTime.now().millisecondsSinceEpoch; final int timestamp = DateTime.now().millisecondsSinceEpoch;
final String id = "!4fsdfjisjf:server.abc"; final String id = "!4fsdfjisjf:server.abc";
final String senderID = "@alice:server.abc"; final String senderID = "@alice:server.abc";
final String senderDisplayname = "Alice";
final String empty = "";
final Membership membership = Membership.join;
final String type = "m.room.message"; final String type = "m.room.message";
final String msgtype = "m.text"; final String msgtype = "m.text";
final String body = "Hello World"; final String body = "Hello World";
@ -49,29 +45,29 @@ void main() {
Map<String, dynamic> jsonObj = { Map<String, dynamic> jsonObj = {
"event_id": id, "event_id": id,
"matrix_id": senderID, "sender": senderID,
"displayname": senderDisplayname,
"avatar_url": empty,
"membership": membership.toString().split('.').last,
"origin_server_ts": timestamp, "origin_server_ts": timestamp,
"state_key": empty,
"type": type, "type": type,
"content_json": contentJson, "status": 2,
"content": contentJson,
}; };
test("Create from json", () async { test("Create from json", () async {
Event event = Event.fromJson(jsonObj, null); Event event = Event.fromJson(jsonObj, null);
expect(event.id, id); expect(event.eventId, id);
expect(event.sender.id, senderID); expect(event.senderId, senderID);
expect(event.sender.displayName, senderDisplayname);
expect(event.sender.avatarUrl.mxc, empty);
expect(event.sender.membership, membership);
expect(event.status, 2); expect(event.status, 2);
expect(event.text, body); expect(event.text, body);
expect(event.formattedText, formatted_body); expect(event.formattedText, formatted_body);
expect(event.getBody(), body); expect(event.getBody(), body);
expect(event.type, EventTypes.Text); expect(event.type, EventTypes.Text);
jsonObj["state_key"] = "";
RoomState state = RoomState.fromJson(jsonObj, null);
expect(state.eventId, id);
expect(state.stateKey, "");
expect(state.key, "m.room.message");
expect(state.timelineEvent.status, 1);
}); });
test("Test all EventTypes", () async { test("Test all EventTypes", () async {
Event event; Event event;
@ -121,7 +117,7 @@ void main() {
expect(event.type, EventTypes.HistoryVisibility); expect(event.type, EventTypes.HistoryVisibility);
jsonObj["type"] = "m.room.message"; jsonObj["type"] = "m.room.message";
jsonObj["content"] = json.decode(jsonObj["content_json"]); jsonObj["content"] = json.decode(jsonObj["content"]);
jsonObj["content"]["msgtype"] = "m.notice"; jsonObj["content"]["msgtype"] = "m.notice";
event = Event.fromJson(jsonObj, null); event = Event.fromJson(jsonObj, null);

View file

@ -39,7 +39,7 @@ class FakeMatrixApi extends MockClient {
method == "GET" ? request.url.queryParameters : request.body; method == "GET" ? request.url.queryParameters : request.body;
var res = {}; var res = {};
print("$method request to $action with Data: $data"); //print("$method request to $action with Data: $data");
// Sync requests with timeout // Sync requests with timeout
if (data is Map<String, dynamic> && data["timeout"] is String) { if (data is Map<String, dynamic> && data["timeout"] is String) {
@ -64,6 +64,20 @@ class FakeMatrixApi extends MockClient {
static final Map<String, Map<String, dynamic>> api = { static final Map<String, Map<String, dynamic>> api = {
"GET": { "GET": {
"/client/r0/rooms/!localpart:server.abc/state/m.room.member/@getme:example.com":
(var req) => {
"content": {
"membership": "join",
"displayname": "You got me",
},
"type": "m.room.member",
"event_id": "143273582443PhrSn:example.org",
"room_id": "!localpart:server.abc",
"sender": "@getme:example.com",
"state_key": "@getme:example.com",
"origin_server_ts": 1432735824653,
"unsigned": {"age": 1234}
},
"/client/r0/rooms/!localpart:server.abc/event/1234": (var req) => { "/client/r0/rooms/!localpart:server.abc/event/1234": (var req) => {
"content": { "content": {
"body": "This is an example text message", "body": "This is an example text message",
@ -78,7 +92,7 @@ class FakeMatrixApi extends MockClient {
"origin_server_ts": 1432735824653, "origin_server_ts": 1432735824653,
"unsigned": {"age": 1234} "unsigned": {"age": 1234}
}, },
"/client/r0/rooms/!1234:example.com/messages?from=1234&dir=b&limit=100": "/client/r0/rooms/!1234:example.com/messages?from=1234&dir=b&limit=100&filter=%7B%22room%22:%7B%22state%22:%7B%22lazy_load_members%22:true%7D%7D%7D":
(var req) => { (var req) => {
"start": "t47429-4392820_219380_26003_2265", "start": "t47429-4392820_219380_26003_2265",
"end": "t47409-4357353_219380_26003_2265", "end": "t47409-4357353_219380_26003_2265",
@ -153,6 +167,24 @@ class FakeMatrixApi extends MockClient {
{"type": "m.login.password"} {"type": "m.login.password"}
] ]
}, },
"/client/r0/rooms/!726s6s6q:example.com/members": (var req) => {
"chunk": [
{
"content": {
"membership": "join",
"avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF",
"displayname": "Alice Margatroid"
},
"type": "m.room.member",
"event_id": "§143273582443PhrSn:example.org",
"room_id": "!636q39766251:example.com",
"sender": "@alice:example.org",
"origin_server_ts": 1432735824653,
"unsigned": {"age": 1234},
"state_key": "@alice:example.org"
}
]
},
"/client/r0/rooms/!localpart:server.abc/members": (var req) => { "/client/r0/rooms/!localpart:server.abc/members": (var req) => {
"chunk": [ "chunk": [
{ {
@ -333,7 +365,16 @@ class FakeMatrixApi extends MockClient {
{ {
"type": "org.example.custom.config", "type": "org.example.custom.config",
"content": {"custom_config_key": "custom_config_value"} "content": {"custom_config_key": "custom_config_value"}
} },
{
"content": {
"@bob:example.com": [
"!726s6s6q:example.com",
"!hgfedcba:example.com"
]
},
"type": "m.direct"
},
] ]
}, },
"to_device": { "to_device": {
@ -364,6 +405,17 @@ class FakeMatrixApi extends MockClient {
"content": {"membership": "join"}, "content": {"membership": "join"},
"origin_server_ts": 1417731086795, "origin_server_ts": 1417731086795,
"event_id": "66697273743031:example.com" "event_id": "66697273743031:example.com"
},
{
"sender": "@alice:example.com",
"type": "m.room.canonical_alias",
"content": {
"alias":
"#famedlyContactDiscovery:fakeServer.notExisting"
},
"state_key": "",
"origin_server_ts": 1417731086796,
"event_id": "66697273743032:example.com"
} }
] ]
}, },
@ -465,12 +517,30 @@ class FakeMatrixApi extends MockClient {
"room_id": "!1234:fakeServer.notExisting", "room_id": "!1234:fakeServer.notExisting",
}, },
"/client/r0/rooms/!localpart:server.abc/read_markers": (var reqI) => {}, "/client/r0/rooms/!localpart:server.abc/read_markers": (var reqI) => {},
"/client/r0/rooms/!localpart:server.abc/kick": (var reqI) => {},
"/client/r0/rooms/!localpart:server.abc/ban": (var reqI) => {},
"/client/r0/rooms/!localpart:server.abc/unban": (var reqI) => {},
"/client/r0/rooms/!localpart:server.abc/invite": (var reqI) => {},
}, },
"PUT": { "PUT": {
"/client/r0/rooms/!1234:example.com/send/m.room.message/1234": "/client/r0/rooms/!1234:example.com/send/m.room.message/1234":
(var reqI) => { (var reqI) => {
"event_id": "42", "event_id": "42",
}, },
"/client/r0/rooms/!localpart:server.abc/state/m.room.name": (var reqI) =>
{
"event_id": "42",
},
"/client/r0/rooms/!localpart:server.abc/state/m.room.topic": (var reqI) =>
{
"event_id": "42",
},
"/client/r0/rooms/!localpart:server.abc/state/m.room.power_levels":
(var reqI) => {
"event_id": "42",
},
"/client/r0/user/@test:fakeServer.notExisting/account_data/m.direct":
(var reqI) => {},
}, },
"DELETE": { "DELETE": {
"/unknown/token": (var req) => {"errcode": "M_UNKNOWN_TOKEN"}, "/unknown/token": (var req) => {"errcode": "M_UNKNOWN_TOKEN"},

View file

@ -24,7 +24,10 @@
import 'package:famedlysdk/src/Client.dart'; import 'package:famedlysdk/src/Client.dart';
import 'package:famedlysdk/src/Event.dart'; import 'package:famedlysdk/src/Event.dart';
import 'package:famedlysdk/src/Room.dart'; import 'package:famedlysdk/src/Room.dart';
import 'package:famedlysdk/src/RoomState.dart';
import 'package:famedlysdk/src/Timeline.dart';
import 'package:famedlysdk/src/User.dart'; import 'package:famedlysdk/src/User.dart';
import 'package:famedlysdk/src/utils/ChatTime.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'FakeMatrixApi.dart'; import 'FakeMatrixApi.dart';
@ -50,24 +53,9 @@ void main() {
test("Create from json", () async { test("Create from json", () async {
final String id = "!localpart:server.abc"; final String id = "!localpart:server.abc";
final String name = "My Room";
final Membership membership = Membership.join; final Membership membership = Membership.join;
final String topic = "This is my own room";
final int unread = DateTime.now().millisecondsSinceEpoch;
final int notificationCount = 2; final int notificationCount = 2;
final int highlightCount = 1; final int highlightCount = 1;
final String fullyRead = "fjh82jdjifd:server.abc";
final String notificationSettings = "all";
final String guestAccess = "forbidden";
final String canonicalAlias = "#testroom:example.com";
final String historyVisibility = "invite";
final String joinRules = "invite";
final int now = DateTime.now().millisecondsSinceEpoch;
final String msgtype = "m.text";
final String body = "Hello World";
final String formatted_body = "<b>Hello</b> World";
final String contentJson =
'{"msgtype":"$msgtype","body":"$body","formatted_body":"$formatted_body"}';
final List<String> heroes = [ final List<String> heroes = [
"@alice:matrix.org", "@alice:matrix.org",
"@bob:example.com", "@bob:example.com",
@ -75,37 +63,12 @@ void main() {
]; ];
Map<String, dynamic> jsonObj = { Map<String, dynamic> jsonObj = {
"id": id, "room_id": id,
"membership": membership.toString().split('.').last, "membership": membership.toString().split('.').last,
"topic": name,
"description": topic,
"avatar_url": "", "avatar_url": "",
"notification_count": notificationCount, "notification_count": notificationCount,
"highlight_count": highlightCount, "highlight_count": highlightCount,
"unread": unread,
"fully_read": fullyRead,
"notification_settings": notificationSettings,
"direct_chat_matrix_id": "",
"draft": "",
"prev_batch": "", "prev_batch": "",
"guest_access": guestAccess,
"history_visibility": historyVisibility,
"join_rules": joinRules,
"canonical_alias": canonicalAlias,
"power_events_default": 0,
"power_state_default": 0,
"power_redact": 0,
"power_invite": 0,
"power_ban": 0,
"power_kick": 0,
"power_user_default": 0,
"power_event_avatar": 0,
"power_event_history_visibility": 0,
"power_event_canonical_alias": 0,
"power_event_aliases": 0,
"power_event_name": 0,
"power_event_power_levels": 0,
"content_json": contentJson,
"joined_member_count": notificationCount, "joined_member_count": notificationCount,
"invited_member_count": notificationCount, "invited_member_count": notificationCount,
"heroes": heroes.join(","), "heroes": heroes.join(","),
@ -115,37 +78,69 @@ void main() {
expect(room.id, id); expect(room.id, id);
expect(room.membership, membership); expect(room.membership, membership);
expect(room.name, name);
expect(room.displayname, name);
expect(room.topic, topic);
expect(room.avatar.mxc, "");
expect(room.notificationCount, notificationCount); expect(room.notificationCount, notificationCount);
expect(room.highlightCount, highlightCount); expect(room.highlightCount, highlightCount);
expect(room.unread.toTimeStamp(), unread);
expect(room.fullyRead, fullyRead);
expect(room.notificationSettings, notificationSettings);
expect(room.directChatMatrixID, "");
expect(room.draft, "");
expect(room.canonicalAlias, canonicalAlias);
expect(room.prev_batch, "");
expect(room.guestAccess, guestAccess);
expect(room.historyVisibility, historyVisibility);
expect(room.joinRules, joinRules);
expect(room.lastMessage, body);
expect(room.timeCreated.toTimeStamp() >= now, true);
room.powerLevels.forEach((String key, int value) {
expect(value, 0);
});
expect(room.mJoinedMemberCount, notificationCount); expect(room.mJoinedMemberCount, notificationCount);
expect(room.mInvitedMemberCount, notificationCount); expect(room.mInvitedMemberCount, notificationCount);
expect(room.mHeroes, heroes); expect(room.mHeroes, heroes);
jsonObj["topic"] = "";
room = await Room.getRoomFromTableRow(jsonObj, matrix);
expect(room.displayname, "testroom");
jsonObj["canonical_alias"] = "";
room = await Room.getRoomFromTableRow(jsonObj, matrix);
expect(room.displayname, "alice, bob, charley"); expect(room.displayname, "alice, bob, charley");
room.states["m.room.canonical_alias"] = RoomState(
senderId: "@test:example.com",
typeKey: "m.room.canonical_alias",
roomId: room.id,
room: room,
eventId: "123",
content: {"alias": "#testalias:example.com"},
stateKey: "");
expect(room.displayname, "testalias");
expect(room.canonicalAlias, "#testalias:example.com");
room.states["m.room.name"] = RoomState(
senderId: "@test:example.com",
typeKey: "m.room.name",
roomId: room.id,
room: room,
eventId: "123",
content: {"name": "testname"},
stateKey: "");
expect(room.displayname, "testname");
expect(room.topic, "");
room.states["m.room.topic"] = RoomState(
senderId: "@test:example.com",
typeKey: "m.room.topic",
roomId: room.id,
room: room,
eventId: "123",
content: {"topic": "testtopic"},
stateKey: "");
expect(room.topic, "testtopic");
expect(room.avatar.mxc, "");
room.states["m.room.avatar"] = RoomState(
senderId: "@test:example.com",
typeKey: "m.room.avatar",
roomId: room.id,
room: room,
eventId: "123",
content: {"url": "mxc://testurl"},
stateKey: "");
expect(room.avatar.mxc, "mxc://testurl");
expect(room.lastEvent, null);
room.states["m.room.message"] = RoomState(
senderId: "@test:example.com",
typeKey: "m.room.message",
roomId: room.id,
room: room,
eventId: "12345",
time: ChatTime.now(),
content: {"msgtype": "m.text", "body": "test"},
stateKey: "");
expect(room.lastEvent.eventId, "12345");
expect(room.lastMessage, "test");
expect(room.timeCreated, room.lastEvent.time);
}); });
test("sendReadReceipt", () async { test("sendReadReceipt", () async {
@ -167,7 +162,98 @@ void main() {
test("getEventByID", () async { test("getEventByID", () async {
final Event event = await room.getEventById("1234"); final Event event = await room.getEventById("1234");
expect(event.id, "143273582443PhrSn:example.org"); expect(event.eventId, "143273582443PhrSn:example.org");
});
test("setName", () async {
final dynamic resp = await room.setName("Testname");
expect(resp["event_id"], "42");
});
test("setDescription", () async {
final dynamic resp = await room.setDescription("Testname");
expect(resp["event_id"], "42");
});
test("kick", () async {
final dynamic resp = await room.kick("Testname");
expect(resp, {});
});
test("ban", () async {
final dynamic resp = await room.ban("Testname");
expect(resp, {});
});
test("unban", () async {
final dynamic resp = await room.unban("Testname");
expect(resp, {});
});
test("PowerLevels", () async {
room.states["m.room.power_levels"] = RoomState(
senderId: "@test:example.com",
typeKey: "m.room.power_levels",
roomId: room.id,
room: room,
eventId: "123",
content: {
"ban": 50,
"events": {"m.room.name": 100, "m.room.power_levels": 100},
"events_default": 0,
"invite": 50,
"kick": 50,
"notifications": {"room": 20},
"redact": 50,
"state_default": 50,
"users": {"@test:fakeServer.notExisting": 100},
"users_default": 10
},
stateKey: "");
expect(room.ownPowerLevel, 100);
expect(room.getPowerLevelByUserId(matrix.userID), room.ownPowerLevel);
expect(room.getPowerLevelByUserId("@nouser:example.com"), 10);
expect(room.powerLevels,
room.states["m.room.power_levels"].content["users"]);
final dynamic resp =
await room.setPower("@test:fakeServer.notExisting", 90);
expect(resp["event_id"], "42");
});
test("invite", () async {
final dynamic resp = await room.invite("Testname");
expect(resp, {});
});
test("getParticipants", () async {
room.states["@alice:test.abc"] = RoomState(
senderId: "@alice:test.abc",
typeKey: "m.room.member",
roomId: room.id,
room: room,
eventId: "12345",
time: ChatTime.now(),
content: {"displayname": "alice"},
stateKey: "@alice:test.abc");
final List<User> userList = room.getParticipants();
expect(userList.length, 1);
expect(userList[0].displayName, "alice");
});
test("addToDirectChat", () async {
final dynamic resp = await room.addToDirectChat("Testname");
expect(resp, {});
});
test("getTimeline", () async {
final Timeline timeline = await room.getTimeline();
expect(timeline.events, []);
});
test("getUserByMXID", () async {
final User user = await room.getUserByMXID("@getme:example.com");
expect(user.stateKey, "@getme:example.com");
expect(user.calcDisplayname(), "You got me");
}); });
}); });
} }

View file

@ -41,7 +41,12 @@ void main() {
client.connection.httpClient = FakeMatrixApi(); client.connection.httpClient = FakeMatrixApi();
client.homeserver = "https://fakeServer.notExisting"; client.homeserver = "https://fakeServer.notExisting";
Room room = Room(id: roomID, client: client, prev_batch: "1234"); Room room = Room(
id: roomID,
client: client,
prev_batch: "1234",
states: {},
roomAccountData: {});
Timeline timeline = Timeline( Timeline timeline = Timeline(
room: room, room: room,
events: [], events: [],
@ -87,10 +92,9 @@ void main() {
expect(insertList, [0, 0]); expect(insertList, [0, 0]);
expect(insertList.length, timeline.events.length); expect(insertList.length, timeline.events.length);
expect(timeline.events.length, 2); expect(timeline.events.length, 2);
expect(timeline.events[0].id, "1"); expect(timeline.events[0].eventId, "1");
expect(timeline.events[0].sender.id, "@alice:example.com"); expect(timeline.events[0].sender.id, "@alice:example.com");
expect(timeline.events[0].time.toTimeStamp(), testTimeStamp); 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].getBody(), "Testcase");
expect(timeline.events[0].time > timeline.events[1].time, true); expect(timeline.events[0].time > timeline.events[1].time, true);
}); });
@ -103,7 +107,7 @@ void main() {
expect(updateCount, 4); expect(updateCount, 4);
expect(insertList, [0, 0, 0]); expect(insertList, [0, 0, 0]);
expect(insertList.length, timeline.events.length); expect(insertList.length, timeline.events.length);
expect(timeline.events[0].id, "42"); expect(timeline.events[0].eventId, "42");
expect(timeline.events[0].status, 1); expect(timeline.events[0].status, 1);
client.connection.onEvent.add(EventUpdate( client.connection.onEvent.add(EventUpdate(
@ -125,7 +129,7 @@ void main() {
expect(updateCount, 5); expect(updateCount, 5);
expect(insertList, [0, 0, 0]); expect(insertList, [0, 0, 0]);
expect(insertList.length, timeline.events.length); expect(insertList.length, timeline.events.length);
expect(timeline.events[0].id, "42"); expect(timeline.events[0].eventId, "42");
expect(timeline.events[0].status, 2); expect(timeline.events[0].status, 2);
}); });
@ -189,9 +193,9 @@ void main() {
expect(updateCount, 19); expect(updateCount, 19);
expect(timeline.events.length, 9); expect(timeline.events.length, 9);
expect(timeline.events[6].id, "1143273582443PhrSn:example.org"); expect(timeline.events[6].eventId, "1143273582443PhrSn:example.org");
expect(timeline.events[7].id, "2143273582443PhrSn:example.org"); expect(timeline.events[7].eventId, "2143273582443PhrSn:example.org");
expect(timeline.events[8].id, "3143273582443PhrSn:example.org"); expect(timeline.events[8].eventId, "3143273582443PhrSn:example.org");
expect(room.prev_batch, "t47409-4357353_219380_26003_2265"); expect(room.prev_batch, "t47409-4357353_219380_26003_2265");
}); });
}); });

View file

@ -21,6 +21,7 @@
* along with famedlysdk. If not, see <http://www.gnu.org/licenses/>. * along with famedlysdk. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:famedlysdk/src/RoomState.dart';
import 'package:famedlysdk/src/User.dart'; import 'package:famedlysdk/src/User.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -32,30 +33,35 @@ void main() {
final Membership membership = Membership.join; final Membership membership = Membership.join;
final String displayName = "Alice"; final String displayName = "Alice";
final String avatarUrl = ""; final String avatarUrl = "";
final int powerLevel = 50;
final Map<String, dynamic> jsonObj = { final Map<String, dynamic> jsonObj = {
"matrix_id": id, "content": {
"displayname": displayName, "membership": "join",
"avatar_url": avatarUrl, "avatar_url": avatarUrl,
"membership": membership.toString().split('.').last, "displayname": displayName
"power_level": powerLevel, },
"type": "m.room.member",
"event_id": "143273582443PhrSn:example.org",
"room_id": "!636q39766251:example.com",
"sender": id,
"origin_server_ts": 1432735824653,
"unsigned": {"age": 1234},
"state_key": id
}; };
User user = User.fromJson(jsonObj, null); User user = RoomState.fromJson(jsonObj, null).asUser;
expect(user.id, id); expect(user.id, id);
expect(user.membership, membership); expect(user.membership, membership);
expect(user.displayName, displayName); expect(user.displayName, displayName);
expect(user.avatarUrl.mxc, avatarUrl); expect(user.avatarUrl.mxc, avatarUrl);
expect(user.powerLevel, powerLevel);
expect(user.calcDisplayname(), displayName); expect(user.calcDisplayname(), displayName);
}); });
test("calcDisplayname", () async { test("calcDisplayname", () async {
final User user1 = User("@alice:example.com"); final User user1 = User("@alice:example.com");
final User user2 = User("@alice:example.com", displayName: "SuperAlice"); final User user2 = User("@SuperAlice:example.com");
final User user3 = User("@alice:example.com", displayName: ""); final User user3 = User("@alice:example.com");
expect(user1.calcDisplayname(), "alice"); expect(user1.calcDisplayname(), "alice");
expect(user2.calcDisplayname(), "SuperAlice"); expect(user2.calcDisplayname(), "SuperAlice");
expect(user3.calcDisplayname(), "alice"); expect(user3.calcDisplayname(), "alice");