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[]}