diff --git a/lib/client.js b/lib/client.js index 76c6c6938..0d786e1c5 100644 --- a/lib/client.js +++ b/lib/client.js @@ -993,7 +993,23 @@ function createNewUser(client, userId) { function createNewRoom(client, roomId) { var room = new Room(roomId); reEmit(client, room, ["Room.name", "Room.timeline"]); - client.emit("Room", room); + client.emit("Room", room); // emit created room event + + // we need to also re-emit room state and room member events, so hook it up + // to the client now. We need to add a listener for RoomState.members in + // order to hook them correctly. (TODO: find a better way?) + reEmit(client, room.currentState, [ + "RoomState.events", "RoomState.members", "RoomState.newMember" + ]); + room.currentState.on("RoomState.newMember", function(event, state, member) { + reEmit( + client, member, + [ + "RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel", + "RoomMember.membership" + ] + ); + }); return room; } diff --git a/lib/models/room-member.js b/lib/models/room-member.js index b8d461bc5..795bed5b5 100644 --- a/lib/models/room-member.js +++ b/lib/models/room-member.js @@ -2,6 +2,8 @@ /** * @module models/room-member */ +var EventEmitter = require("events").EventEmitter; + var utils = require("../utils"); /** @@ -17,6 +19,7 @@ var utils = require("../utils"); * @prop {Number} powerLevelNorm The normalised power level (0-100) for this * room member. * @prop {User} user The User object for this room member, if one exists. + * @prop {string} membership The membership state for this room member e.g. 'join'. * @throws If the event provided is not m.room.member */ function RoomMember(roomId, userId) { @@ -27,103 +30,130 @@ function RoomMember(roomId, userId) { this.powerLevel = 0; this.powerLevelNorm = 0; this.user = null; + this.membership = null; } -RoomMember.prototype = { +utils.inherits(RoomMember, EventEmitter); - /** - * Update this room member's membership event. May fire "RoomMember.name" if - * this event updates this member's name. - * @param {MatrixEvent} event The m.room.member event - * @param {RoomState} roomState Optional. The room state to take into account - * when calculating (e.g. for disambiguating users with the same name). - * @fires module:client~MatrixClient#event:"RoomMember.name" - */ - setMembershipEvent: function(event, roomState) { - if (event.getType() !== "m.room.member") { - return; - } - var displayName = event.getContent().displayname; - var selfUserId = this.userId; - if (!displayName) { - this.name = selfUserId; - return; - } - if (!roomState) { - this.name = displayName; - return; - } +/** + * Update this room member's membership event. May fire "RoomMember.name" if + * this event updates this member's name. + * @param {MatrixEvent} event The m.room.member event + * @param {RoomState} roomState Optional. The room state to take into account + * when calculating (e.g. for disambiguating users with the same name). + * @fires module:client~MatrixClient#event:"RoomMember.name" + * @fires module:client~MatrixClient#event:"RoomMember.membership" + */ +RoomMember.prototype.setMembershipEvent = function(event, roomState) { + if (event.getType() !== "m.room.member") { + return; + } + var oldMembership = this.membership; + this.membership = event.getContent().membership; - var stateEvents = utils.filter( - roomState.getStateEvents("m.room.member"), - function(e) { - return e.getContent().displayname === displayName && - e.getSender() !== selfUserId; - } - ); - if (stateEvents.length > 1) { - this.name = displayName + " (" + selfUserId + ")"; - return; - } + var oldName = this.name; + this.name = calculateDisplayName(this, event, roomState); - this.name = displayName; - }, - - /** - * Update this room member's power level event. May fire - * "RoomMember.powerLevel" if this event updates this member's power levels. - * @param {MatrixEvent} powerLevelEvent The m.room.power_levels - * event - * @fires module:client~MatrixClient#event:"RoomMember.powerLevel" - */ - setPowerLevelEvent: function(powerLevelEvent) { - if (powerLevelEvent.getType() !== "m.room.power_levels") { - return; - } - var maxLevel = powerLevelEvent.getContent().users_default || 0; - utils.forEach(utils.values(powerLevelEvent.getContent().users), function(lvl) { - maxLevel = Math.max(maxLevel, lvl); - }); - this.powerLevel = ( - powerLevelEvent.getContent().users[this.userId] || - powerLevelEvent.getContent().users_default || - 0 - ); - this.powerLevelNorm = 0; - if (maxLevel > 0) { - this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; - } - }, - - /** - * Update this room member's typing event. May fire "RoomMember.typing" if - * this event changes this member's typing state. - * @param {MatrixEvent} event The typing event - * @fires module:client~MatrixClient#event:"RoomMember.typing" - */ - setTypingEvent: function(event) { - if (event.getType() !== "m.typing") { - return; - } - this.typing = false; - var typingList = event.getContent().user_ids; - if (!utils.isArray(typingList)) { - // malformed event :/ bail early. TODO: whine? - return; - } - if (typingList.indexOf(this.userId) !== -1) { - this.typing = true; - } - }, - - /** - * Get the membership state of this room member. - * @return {string} The membership state e.g. 'join'. - */ - getMembershipState: function() { - return this.event.getContent().membership; + if (oldMembership !== this.membership) { + this.emit("RoomMember.membership", event, this); + } + if (oldName !== this.name) { + this.emit("RoomMember.name", event, this); } }; +/** + * Update this room member's power level event. May fire + * "RoomMember.powerLevel" if this event updates this member's power levels. + * @param {MatrixEvent} powerLevelEvent The m.room.power_levels + * event + * @fires module:client~MatrixClient#event:"RoomMember.powerLevel" + */ +RoomMember.prototype.setPowerLevelEvent = function(powerLevelEvent) { + if (powerLevelEvent.getType() !== "m.room.power_levels") { + return; + } + var maxLevel = powerLevelEvent.getContent().users_default || 0; + utils.forEach(utils.values(powerLevelEvent.getContent().users), function(lvl) { + maxLevel = Math.max(maxLevel, lvl); + }); + var oldPowerLevel = this.powerLevel; + var oldPowerLevelNorm = this.powerLevelNorm; + this.powerLevel = ( + powerLevelEvent.getContent().users[this.userId] || + powerLevelEvent.getContent().users_default || + 0 + ); + this.powerLevelNorm = 0; + if (maxLevel > 0) { + this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; + } + + // emit for changes in powerLevelNorm as well (since the app will need to + // redraw everyone's level if the max has changed) + if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { + this.emit("RoomMember.powerLevel", powerLevelEvent, this); + } +}; + +/** + * Update this room member's typing event. May fire "RoomMember.typing" if + * this event changes this member's typing state. + * @param {MatrixEvent} event The typing event + * @fires module:client~MatrixClient#event:"RoomMember.typing" + */ +RoomMember.prototype.setTypingEvent = function(event) { + if (event.getType() !== "m.typing") { + return; + } + var oldTyping = this.typing; + this.typing = false; + var typingList = event.getContent().user_ids; + if (!utils.isArray(typingList)) { + // malformed event :/ bail early. TODO: whine? + return; + } + if (typingList.indexOf(this.userId) !== -1) { + this.typing = true; + } + if (oldTyping !== this.typing) { + this.emit("RoomMember.typing", event, this); + } +}; + +/** + * Get the membership state of this room member. + * @return {string} The membership state e.g. 'join'. + */ +RoomMember.prototype.getMembershipState = function() { + return this.event.getContent().membership; +}; + + +function calculateDisplayName(member, event, roomState) { + var displayName = event.getContent().displayname; + var selfUserId = member.userId; + if (!displayName) { + return selfUserId; + } + if (!roomState) { + return displayName; + } + + var stateEvents = utils.filter( + roomState.getStateEvents("m.room.member"), + function(e) { + return e.getContent().displayname === displayName && + e.getSender() !== selfUserId; + } + ); + if (stateEvents.length > 1) { + // need to disambiguate + return displayName + " (" + selfUserId + ")"; + } + + return displayName; +} + /** * The RoomMember class. */ @@ -140,6 +170,17 @@ module.exports = RoomMember; * }); */ +/** + * Fires whenever any room member's membership state changes. + * @event module:client~MatrixClient#"RoomMember.membership" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.membership changed. + * @example + * matrixClient.on("RoomMember.membership", function(event, member){ + * var newState = member.membership; + * }); + */ + /** * Fires whenever any room member's typing state changes. * @event module:client~MatrixClient#"RoomMember.typing" diff --git a/lib/models/room-state.js b/lib/models/room-state.js index 49309c883..d44a592e8 100644 --- a/lib/models/room-state.js +++ b/lib/models/room-state.js @@ -2,6 +2,8 @@ /** * @module models/room-state */ +var EventEmitter = require("events").EventEmitter; + var utils = require("../utils"); var RoomMember = require("./room-member"); @@ -25,84 +27,90 @@ function RoomState(roomId) { }; this.paginationToken = null; } -RoomState.prototype = { - /** - * Get all RoomMembers in this room. - * @return {Array} A list of RoomMembers. - */ - getMembers: function() { - return utils.values(this.members); - }, +utils.inherits(RoomState, EventEmitter); - /** - * Get state events from the state of the room. - * @param {string} eventType The event type of the state event. - * @param {string} stateKey Optional. The state_key of the state event. If - * this is undefined then all matching state events will be - * returned. - * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was - * undefined, else a single event (or null if no match found). - */ - getStateEvents: function(eventType, stateKey) { - if (!this.events[eventType]) { - // no match - return stateKey === undefined ? [] : null; - } - if (stateKey === undefined) { // return all values - return utils.values(this.events[eventType]); - } - var event = this.events[eventType][stateKey]; - return event ? event : null; - }, +/** + * Get all RoomMembers in this room. + * @return {Array} A list of RoomMembers. + */ +RoomState.prototype.getMembers = function() { + return utils.values(this.members); +}; - /** - * Add an array of one or more state MatrixEvents, overwriting - * any existing state with the same {type, stateKey} tuple. Will fire - * "RoomState.events" for every event added. May fire "RoomState.members" - * if there are m.room.member events. - * @param {MatrixEvent[]} stateEvents a list of state events for this room. - * @fires module:client~MatrixClient#event:"RoomState.members" - * @fires module:client~MatrixClient#event:"RoomState.events" - */ - setStateEvents: function(stateEvents) { - var self = this; - utils.forEach(stateEvents, function(event) { - if (event.getRoomId() !== self.roomId) { return; } - if (!event.isState()) { return; } - - if (self.events[event.getType()] === undefined) { - self.events[event.getType()] = {}; - } - self.events[event.getType()][event.getStateKey()] = event; - - if (event.getType() === "m.room.member") { - var member = new RoomMember(event.getRoomId(), event.getSender()); - member.setMembershipEvent(event, self); - // this member may have a power level already, so set it. - var pwrLvlEvent = self.getStateEvents("m.room.power_levels", ""); - if (pwrLvlEvent) { - member.setPowerLevelEvent(pwrLvlEvent); - } - self.members[event.getStateKey()] = member; - } - else if (event.getType() === "m.room.power_levels") { - var members = utils.values(self.members); - utils.forEach(members, function(member) { - member.setPowerLevelEvent(event); - }); - } - }); - }, - - /** - * Set the current typing event for this room. - * @param {MatrixEvent} event The typing event - */ - setTypingEvent: function(event) { - utils.forEach(utils.values(this.members), function(member) { - member.setTypingEvent(event); - }); +/** + * Get state events from the state of the room. + * @param {string} eventType The event type of the state event. + * @param {string} stateKey Optional. The state_key of the state event. If + * this is undefined then all matching state events will be + * returned. + * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was + * undefined, else a single event (or null if no match found). + */ +RoomState.prototype.getStateEvents = function(eventType, stateKey) { + if (!this.events[eventType]) { + // no match + return stateKey === undefined ? [] : null; } + if (stateKey === undefined) { // return all values + return utils.values(this.events[eventType]); + } + var event = this.events[eventType][stateKey]; + return event ? event : null; +}; + +/** + * Add an array of one or more state MatrixEvents, overwriting + * any existing state with the same {type, stateKey} tuple. Will fire + * "RoomState.events" for every event added. May fire "RoomState.members" + * if there are m.room.member events. + * @param {MatrixEvent[]} stateEvents a list of state events for this room. + * @fires module:client~MatrixClient#event:"RoomState.members" + * @fires module:client~MatrixClient#event:"RoomState.events" + */ +RoomState.prototype.setStateEvents = function(stateEvents) { + var self = this; + utils.forEach(stateEvents, function(event) { + if (event.getRoomId() !== self.roomId) { return; } + if (!event.isState()) { return; } + + if (self.events[event.getType()] === undefined) { + self.events[event.getType()] = {}; + } + self.events[event.getType()][event.getStateKey()] = event; + self.emit("RoomState.events", event, self); + + if (event.getType() === "m.room.member") { + var member = self.members[event.getStateKey()]; + if (!member) { + member = new RoomMember(event.getRoomId(), event.getSender()); + self.emit("RoomState.newMember", event, self, member); + } + member.setMembershipEvent(event, self); + // this member may have a power level already, so set it. + var pwrLvlEvent = self.getStateEvents("m.room.power_levels", ""); + if (pwrLvlEvent) { + member.setPowerLevelEvent(pwrLvlEvent); + } + self.members[event.getStateKey()] = member; + self.emit("RoomState.members", event, self, member); + } + else if (event.getType() === "m.room.power_levels") { + var members = utils.values(self.members); + utils.forEach(members, function(member) { + member.setPowerLevelEvent(event); + }); + } + }); +}; + +/** + * Set the current typing event for this room. + * @param {MatrixEvent} event The typing event + */ +RoomState.prototype.setTypingEvent = function(event) { + utils.forEach(utils.values(this.members), function(member) { + member.setTypingEvent(event); + }); }; /** @@ -123,7 +131,7 @@ module.exports = RoomState; */ /** - * Fires whenever the member dictionary in room state is updated. + * Fires whenever a member in the members dictionary is updated in any way. * @event module:client~MatrixClient#"RoomState.members" * @param {MatrixEvent} event The matrix event which caused this event to fire. * @param {RoomState} state The room state whose RoomState.members dictionary @@ -134,3 +142,17 @@ module.exports = RoomState; * var newMembershipState = member.getMembershipState(); * }); */ + + /** + * Fires whenever a member is added to the members dictionary. The RoomMember + * will not be fully populated yet (e.g. no membership state). + * @event module:client~MatrixClient#"RoomState.newMember" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.members dictionary + * was updated with a new entry. + * @param {RoomMember} member The room member that was added. + * @example + * matrixClient.on("RoomState.newMember", function(event, state, member){ + * // add event listeners on 'member' + * }); + */ diff --git a/spec/integ/matrix-client.spec.js b/spec/integ/matrix-client.spec.js index 5f1245f66..62e3df71b 100644 --- a/spec/integ/matrix-client.spec.js +++ b/spec/integ/matrix-client.spec.js @@ -86,7 +86,10 @@ describe("MatrixClient", function() { ] }, state: [ - utils.mkMembership("!erufh:bar", "join", "@foo:bar") + utils.mkMembership("!erufh:bar", "join", "@foo:bar"), + utils.mkEvent("m.room.create", "!erufh:bar", "@foo:bar", { + creator: "@foo:bar" + }) ] }] }; @@ -95,7 +98,10 @@ describe("MatrixClient", function() { end: "e_6_7", chunk: [ utils.mkMessage("!erufh:bar", "@foo:bar", "ello ello"), - utils.mkMessage("!erufh:bar", "@foo:bar", ":D") + utils.mkMessage("!erufh:bar", "@foo:bar", ":D"), + utils.mkEvent("m.typing", "!erufh:bar", "bar", { + user_ids: ["@foo:bar"] + }) ] }; @@ -107,7 +113,7 @@ describe("MatrixClient", function() { // that should be emitted and we'll just pick them off one by one, // so long as this is emptied we're good. var initialSyncEventTypes = [ - "m.presence", "m.room.member", "m.room.message" + "m.presence", "m.room.member", "m.room.message", "m.room.create" ]; var chunkIndex = 0; client.on("event", function(event) { @@ -171,11 +177,11 @@ describe("MatrixClient", function() { it("should emit Room events", function(done) { httpBackend.when("GET", "/initialSync").respond(200, initialSync); httpBackend.when("GET", "/events").respond(200, eventData); - var firedRoom = false; - var firedName = false; + var roomInvokeCount = 0; + var roomNameInvokeCount = 0; var timelineFireCount = 0; client.on("Room", function(room) { - firedRoom = true; + roomInvokeCount++; expect(room.roomId).toEqual("!erufh:bar"); }); client.on("Room.timeline", function(event, room) { @@ -183,20 +189,115 @@ describe("MatrixClient", function() { expect(room.roomId).toEqual("!erufh:bar"); }); client.on("Room.name", function(room) { - firedName = true; + roomNameInvokeCount++; }); client.startClient(); httpBackend.flush().done(function() { - expect(firedRoom).toBe(true, "Room didn't fire."); - expect(firedName).toBe(true, "Room.name didn't fire."); + expect(roomInvokeCount).toEqual( + 1, "Room fired wrong number of times." + ); + expect(roomNameInvokeCount).toEqual( + 1, "Room.name fired wrong number of times." + ); expect(timelineFireCount).toEqual( 3, "Room.timeline fired the wrong number of times" ); done(); }); }); + + it("should emit RoomState events", function(done) { + httpBackend.when("GET", "/initialSync").respond(200, initialSync); + httpBackend.when("GET", "/events").respond(200, eventData); + + var roomStateEventTypes = [ + "m.room.member", "m.room.create" + ]; + var eventsInvokeCount = 0; + var membersInvokeCount = 0; + var newMemberInvokeCount = 0; + client.on("RoomState.events", function(event, state) { + eventsInvokeCount++; + var index = roomStateEventTypes.indexOf(event.getType()); + expect(index).not.toEqual( + -1, "Unexpected room state event type: " + event.getType() + ); + if (index >= 0) { + roomStateEventTypes.splice(index, 1); + } + }); + client.on("RoomState.members", function(event, state, member) { + membersInvokeCount++; + expect(member.roomId).toEqual("!erufh:bar"); + expect(member.userId).toEqual("@foo:bar"); + expect(member.membership).toEqual("join"); + }); + client.on("RoomState.newMember", function(event, state, member) { + newMemberInvokeCount++; + expect(member.roomId).toEqual("!erufh:bar"); + expect(member.userId).toEqual("@foo:bar"); + expect(member.membership).toBeFalsy(); + }); + + client.startClient(); + + httpBackend.flush().done(function() { + expect(membersInvokeCount).toEqual( + 1, "RoomState.members fired wrong number of times" + ); + expect(newMemberInvokeCount).toEqual( + 1, "RoomState.newMember fired wrong number of times" + ); + expect(eventsInvokeCount).toEqual( + 2, "RoomState.events fired wrong number of times" + ); + done(); + }); + }); + + it("should emit RoomMember events", function(done) { + httpBackend.when("GET", "/initialSync").respond(200, initialSync); + httpBackend.when("GET", "/events").respond(200, eventData); + + var typingInvokeCount = 0; + var powerLevelInvokeCount = 0; + var nameInvokeCount = 0; + var membershipInvokeCount = 0; + client.on("RoomMember.name", function(event, member) { + nameInvokeCount++; + }); + client.on("RoomMember.typing", function(event, member) { + typingInvokeCount++; + expect(member.typing).toBe(true); + }); + client.on("RoomMember.powerLevel", function(event, member) { + powerLevelInvokeCount++; + }); + client.on("RoomMember.membership", function(event, member) { + membershipInvokeCount++; + expect(member.membership).toEqual("join"); + }); + + client.startClient(); + + httpBackend.flush().done(function() { + expect(typingInvokeCount).toEqual( + 1, "RoomMember.typing fired wrong number of times" + ); + expect(powerLevelInvokeCount).toEqual( + 0, "RoomMember.powerLevel fired wrong number of times" + ); + expect(nameInvokeCount).toEqual( + 0, "RoomMember.name fired wrong number of times" + ); + expect(membershipInvokeCount).toEqual( + 1, "RoomMember.membership fired wrong number of times" + ); + done(); + }); + }); }); describe("room state", function() {