diff --git a/src/base-apis.js b/src/base-apis.js
index 135449835..a28f6b9cd 100644
--- a/src/base-apis.js
+++ b/src/base-apis.js
@@ -466,6 +466,32 @@ MatrixBaseApis.prototype.removeUserFromGroup = function(groupId, userId) {
return this._http.authedRequest(undefined, "PUT", path, undefined, {});
};
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.acceptGroupInvite = function(groupId) {
+ const path = utils.encodeUri(
+ "/groups/$groupId/self/accept_invite",
+ {$groupId: groupId},
+ );
+ return this._http.authedRequest(undefined, "PUT", path, undefined, {});
+};
+
+/**
+ * @param {string} groupId
+ * @return {module:client.Promise} Resolves: Empty object
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+MatrixBaseApis.prototype.leaveGroup = function(groupId) {
+ const path = utils.encodeUri(
+ "/groups/$groupId/self/leave",
+ {$groupId: groupId},
+ );
+ return this._http.authedRequest(undefined, "PUT", path, undefined, {});
+};
+
/**
* @return {module:client.Promise} Resolves: The groups to which the user is joined
* @return {module:http-api.MatrixError} Rejects: with an error response.
diff --git a/src/client.js b/src/client.js
index 5f7a542de..59718f6f1 100644
--- a/src/client.js
+++ b/src/client.js
@@ -659,6 +659,30 @@ MatrixClient.prototype.importRoomKeys = function(keys) {
return this._crypto.importRoomKeys(keys);
};
+// Group ops
+// =========
+// Operations on groups that come down the sync stream (ie. ones the
+// user is a member of or invited to)
+
+/**
+ * Get the group for the given group ID.
+ * This function will return a valid group for any group for which a Group event
+ * has been emitted.
+ * @param {string} groupId The group ID
+ * @return {Group} The Group or null if the group is not known or there is no data store.
+ */
+MatrixClient.prototype.getGroup = function(groupId) {
+ return this.store.getGroup(groupId);
+};
+
+/**
+ * Retrieve all known groups.
+ * @return {Groups[]} A list of groups, or an empty list if there is no data store.
+ */
+MatrixClient.prototype.getGroups = function() {
+ return this.store.getGroups();
+};
+
// Room ops
// ========
@@ -3423,6 +3447,17 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED;
* });
*/
+ /**
+ * Fires whenever the sdk learns about a new group. This event
+ * is experimental and may change.
+ * @event module:client~MatrixClient#"Group"
+ * @param {Group} group The newly created, fully populated group.
+ * @example
+ * matrixClient.on("Group", function(group){
+ * var groupId = group.groupId;
+ * });
+ */
+
/**
* Fires whenever a new Room is added. This will fire when you are invited to a
* room, as well as when you join a room. This event is experimental and
diff --git a/src/models/group.js b/src/models/group.js
new file mode 100644
index 000000000..9a4b9e989
--- /dev/null
+++ b/src/models/group.js
@@ -0,0 +1,95 @@
+/*
+Copyright 2017 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * @module models/group
+ */
+const EventEmitter = require("events").EventEmitter;
+
+const utils = require("../utils");
+
+/**
+ * Construct a new Group.
+ *
+ * @param {string} groupId The ID of this group.
+ *
+ * @prop {string} groupId The ID of this group.
+ * @prop {string} name The human-readable display name for this group.
+ * @prop {string} avatarUrl The mxc URL for this group's avatar.
+ * @prop {string} myMembership The logged in user's membership of this group
+ * @prop {Object} inviter Infomation about the user who invited the logged in user
+ * to the group, if myMembership is 'invite'.
+ * @prop {string} inviter.userId The user ID of the inviter
+ */
+function Group(groupId) {
+ this.groupId = groupId;
+ this.name = null;
+ this.avatarUrl = null;
+ this.myMembership = null;
+ this.inviter = null;
+}
+utils.inherits(Group, EventEmitter);
+
+Group.prototype.setProfile = function(name, avatarUrl) {
+ if (this.name === name && this.avatarUrl === avatarUrl) return;
+
+ this.name = name || this.groupId;
+ this.avatarUrl = avatarUrl;
+
+ this.emit("Group.profile", this);
+};
+
+Group.prototype.setMyMembership = function(membership) {
+ if (this.myMembership === membership) return;
+
+ this.myMembership = membership;
+
+ this.emit("Group.myMembership", this);
+};
+
+/**
+ * Sets the 'inviter' property. This does not emit an event (the inviter
+ * will only change when the user is revited / reinvited to a room),
+ * so set this before setting myMembership.
+ * @param {Object} inviter Infomation about who invited us to the room
+ */
+Group.prototype.setInviter = function(inviter) {
+ this.inviter = inviter;
+};
+
+module.exports = Group;
+
+/**
+ * Fires whenever a group's profile information is updated.
+ * This means the 'name' and 'avatarUrl' properties.
+ * @event module:client~MatrixClient#"Group.profile"
+ * @param {Group} group The group whose profile was updated.
+ * @example
+ * matrixClient.on("Group.profile", function(group){
+ * var name = group.name;
+ * });
+ */
+
+/**
+ * Fires whenever the logged in user's membership status of
+ * the group is updated.
+ * @event module:client~MatrixClient#"Group.myMembership"
+ * @param {Group} group The group in which the user's membership changed
+ * @example
+ * matrixClient.on("Group.myMembership", function(group){
+ * var myMembership = group.myMembership;
+ * });
+ */
diff --git a/src/store/indexeddb-local-backend.js b/src/store/indexeddb-local-backend.js
index b1da045db..1bcc31b11 100644
--- a/src/store/indexeddb-local-backend.js
+++ b/src/store/indexeddb-local-backend.js
@@ -175,6 +175,7 @@ LocalIndexedDBStoreBackend.prototype = {
this._syncAccumulator.accumulate({
next_batch: syncData.nextBatch,
rooms: syncData.roomsData,
+ groups: syncData.groupsData,
account_data: {
events: accountData,
},
@@ -251,7 +252,9 @@ LocalIndexedDBStoreBackend.prototype = {
return Promise.all([
this._persistUserPresenceEvents(userTuples),
this._persistAccountData(syncData.accountData),
- this._persistSyncData(syncData.nextBatch, syncData.roomsData),
+ this._persistSyncData(
+ syncData.nextBatch, syncData.roomsData, syncData.groupsData,
+ ),
]);
},
@@ -259,9 +262,10 @@ LocalIndexedDBStoreBackend.prototype = {
* Persist rooms /sync data along with the next batch token.
* @param {string} nextBatch The next_batch /sync value.
* @param {Object} roomsData The 'rooms' /sync data from a SyncAccumulator
+ * @param {Object} groupsData The 'groups' /sync data from a SyncAccumulator
* @return {Promise} Resolves if the data was persisted.
*/
- _persistSyncData: function(nextBatch, roomsData) {
+ _persistSyncData: function(nextBatch, roomsData, groupsData) {
console.log("Persisting sync data up to ", nextBatch);
return Promise.try(() => {
const txn = this.db.transaction(["sync"], "readwrite");
@@ -270,6 +274,7 @@ LocalIndexedDBStoreBackend.prototype = {
clobber: "-", // constant key so will always clobber
nextBatch: nextBatch,
roomsData: roomsData,
+ groupsData: groupsData,
}); // put == UPSERT
return promiseifyTxn(txn);
});
diff --git a/src/store/memory.js b/src/store/memory.js
index 566c04923..d25d2ea89 100644
--- a/src/store/memory.js
+++ b/src/store/memory.js
@@ -19,8 +19,8 @@ limitations under the License.
* This is an internal module. See {@link MatrixInMemoryStore} for the public class.
* @module store/memory
*/
- const utils = require("../utils");
- const User = require("../models/user");
+const utils = require("../utils");
+const User = require("../models/user");
import Promise from 'bluebird';
/**
@@ -35,6 +35,9 @@ module.exports.MatrixInMemoryStore = function MatrixInMemoryStore(opts) {
this.rooms = {
// roomId: Room
};
+ this.groups = {
+ // groupId: Group
+ };
this.users = {
// userId: User
};
@@ -69,6 +72,31 @@ module.exports.MatrixInMemoryStore.prototype = {
this.syncToken = token;
},
+ /**
+ * Store the given room.
+ * @param {Group} group The group to be stored
+ */
+ storeGroup: function(group) {
+ this.groups[group.groupId] = group;
+ },
+
+ /**
+ * Retrieve a group by its group ID.
+ * @param {string} groupId The group ID.
+ * @return {Group} The group or null.
+ */
+ getGroup: function(groupId) {
+ return this.groups[groupId] || null;
+ },
+
+ /**
+ * Retrieve all known groups.
+ * @return {Group[]} A list of groups, which may be empty.
+ */
+ getGroups: function() {
+ return utils.values(this.groups);
+ },
+
/**
* Store the given room.
* @param {Room} room The room to be stored. All properties must be stored.
diff --git a/src/store/stub.js b/src/store/stub.js
index c8a6c69eb..964ca4a81 100644
--- a/src/store/stub.js
+++ b/src/store/stub.js
@@ -47,6 +47,30 @@ StubStore.prototype = {
this.fromToken = token;
},
+ /**
+ * No-op.
+ * @param {Group} group
+ */
+ storeGroup: function(group) {
+ },
+
+ /**
+ * No-op.
+ * @param {string} groupId
+ * @return {null}
+ */
+ getGroup: function(groupId) {
+ return null;
+ },
+
+ /**
+ * No-op.
+ * @return {Array} An empty array.
+ */
+ getGroups: function() {
+ return [];
+ },
+
/**
* No-op.
* @param {Room} room
diff --git a/src/sync-accumulator.js b/src/sync-accumulator.js
index e32e277c2..c1266a9b0 100644
--- a/src/sync-accumulator.js
+++ b/src/sync-accumulator.js
@@ -72,10 +72,18 @@ class SyncAccumulator {
// coherent /sync response and know at what point they should be
// streaming from without losing events.
this.nextBatch = null;
+
+ // { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } }
+ this.groups = {
+ invite: {},
+ join: {},
+ leave: {},
+ };
}
accumulate(syncResponse) {
this._accumulateRooms(syncResponse);
+ this._accumulateGroups(syncResponse);
this._accumulateAccountData(syncResponse);
this.nextBatch = syncResponse.next_batch;
}
@@ -336,6 +344,44 @@ class SyncAccumulator {
}
}
+ /**
+ * Accumulate incremental /sync group data.
+ * @param {Object} syncResponse the complete /sync JSON
+ */
+ _accumulateGroups(syncResponse) {
+ if (!syncResponse.groups) {
+ return;
+ }
+ if (syncResponse.groups.invite) {
+ Object.keys(syncResponse.groups.invite).forEach((groupId) => {
+ this._accumulateGroup(
+ groupId, "invite", syncResponse.groups.invite[groupId],
+ );
+ });
+ }
+ if (syncResponse.groups.join) {
+ Object.keys(syncResponse.groups.join).forEach((groupId) => {
+ this._accumulateGroup(
+ groupId, "join", syncResponse.groups.join[groupId],
+ );
+ });
+ }
+ if (syncResponse.groups.leave) {
+ Object.keys(syncResponse.groups.leave).forEach((groupId) => {
+ this._accumulateGroup(
+ groupId, "leave", syncResponse.groups.leave[groupId],
+ );
+ });
+ }
+ }
+
+ _accumulateGroup(groupId, category, data) {
+ for (const cat of ['invite', 'join', 'leave']) {
+ delete this.groups[cat][groupId];
+ }
+ this.groups[category][groupId] = data;
+ }
+
/**
* Return everything under the 'rooms' key from a /sync response which
* represents all room data that should be stored. This should be paired
@@ -470,6 +516,7 @@ class SyncAccumulator {
return {
nextBatch: this.nextBatch,
roomsData: data,
+ groupsData: this.groups,
accountData: accData,
};
}
diff --git a/src/sync.js b/src/sync.js
index 0ebb65cc6..a11e0b412 100644
--- a/src/sync.js
+++ b/src/sync.js
@@ -27,6 +27,7 @@ limitations under the License.
import Promise from 'bluebird';
const User = require("./models/user");
const Room = require("./models/room");
+const Group = require('./models/group');
const utils = require("./utils");
const Filter = require("./filter");
const EventTimeline = require("./models/event-timeline");
@@ -124,6 +125,17 @@ SyncApi.prototype.createRoom = function(roomId) {
return room;
};
+/**
+ * @param {string} groupId
+ * @return {Group}
+ */
+SyncApi.prototype.createGroup = function(groupId) {
+ const client = this.client;
+ const group = new Group(groupId);
+ reEmit(client, group, ["Group.profile", "Group.myMembership"]);
+ return group;
+};
+
/**
* @param {Room} room
* @private
@@ -568,6 +580,7 @@ SyncApi.prototype._sync = function(syncOptions) {
return {
next_batch: savedSync.nextBatch,
rooms: savedSync.roomsData,
+ groups: savedSync.groupsData,
account_data: {
events: savedSync.accountData,
},
@@ -714,6 +727,19 @@ SyncApi.prototype._processSyncResponse = async function(syncToken, data) {
// }
// }
// },
+ // groups: {
+ // invite: {
+ // $groupId: {
+ // inviter: $inviter,
+ // profile: {
+ // avatar_url: $avatarUrl,
+ // name: $groupName,
+ // },
+ // },
+ // },
+ // join: {},
+ // leave: {},
+ // },
// }
// TODO-arch:
@@ -781,6 +807,20 @@ SyncApi.prototype._processSyncResponse = async function(syncToken, data) {
this._catchingUp = false;
}
+ if (data.groups) {
+ if (data.groups.invite) {
+ this._processGroupSyncEntry(data.groups.invite, 'invite');
+ }
+
+ if (data.groups.join) {
+ this._processGroupSyncEntry(data.groups.join, 'join');
+ }
+
+ if (data.groups.leave) {
+ this._processGroupSyncEntry(data.groups.leave, 'leave');
+ }
+ }
+
// the returned json structure is a bit crap, so make it into a
// nicer form (array) after applying sanity to make sure we don't fail
// on missing keys (on the off chance)
@@ -1070,6 +1110,35 @@ SyncApi.prototype._pokeKeepAlive = function() {
});
};
+/**
+ * @param {Object} groupsSection Groups section object, eg. response.groups.invite
+ * @param {string} sectionName Which section this is ('invite', 'join' or 'leave')
+ */
+SyncApi.prototype._processGroupSyncEntry = function(groupsSection, sectionName) {
+ // Processes entries from 'groups' section of the sync stream
+ for (const groupId of Object.keys(groupsSection)) {
+ const groupInfo = groupsSection[groupId];
+ let group = this.client.store.getGroup(groupId);
+ const isBrandNew = group === null;
+ if (group === null) {
+ group = this.createGroup(groupId);
+ }
+ if (groupInfo.profile) {
+ group.setProfile(
+ groupInfo.profile.name, groupInfo.profile.avatar_url,
+ );
+ }
+ if (groupInfo.inviter) {
+ group.setInviter({userId: groupInfo.inviter});
+ }
+ group.setMyMembership(sectionName);
+ if (isBrandNew) {
+ this.client.store.storeGroup(group);
+ this.client.emit("Group", group);
+ }
+ }
+};
+
/**
* @param {Object} obj
* @return {Object[]}