Member list

This commit is contained in:
David Baker 2015-09-18 18:39:16 +01:00
parent 31ee667102
commit a8eb93bd6f
7 changed files with 741 additions and 21 deletions

38
src/MatrixTools.js Normal file
View file

@ -0,0 +1,38 @@
/*
Copyright 2015 OpenMarket 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.
*/
var MatrixClientPeg = require('./MatrixClientPeg');
module.exports = {
/**
* Given a room object, return the canonical alias for it
* if there is one. Otherwise return null;
*/
getCanonicalAliasForRoom: function(room) {
var aliasEvents = room.currentState.getStateEvents(
"m.room.aliases"
);
// Canonical aliases aren't implemented yet, so just return the first
for (var j = 0; j < aliasEvents.length; j++) {
var aliases = aliasEvents[j].getContent().aliases;
if (aliases && aliases.length) {
return aliases[0];
}
}
return null;
}
}

107
src/Presence.js Normal file
View file

@ -0,0 +1,107 @@
/*
Copyright 2015 OpenMarket 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.
*/
var MatrixClientPeg = require("./MatrixClientPeg");
// Time in ms after that a user is considered as unavailable/away
var UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
var PRESENCE_STATES = ["online", "offline", "unavailable"];
// The current presence state
var state, timer;
module.exports = {
/**
* Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the Home Server.
*/
start: function() {
var self = this;
this.running = true;
if (undefined === state) {
// The user is online if they move the mouse or press a key
document.onmousemove = function() { self._resetTimer(); };
document.onkeypress = function() { self._resetTimer(); };
this._resetTimer();
}
},
/**
* Stop tracking user activity
*/
stop: function() {
this.running = false;
if (timer) {
clearTimeout(timer);
timer = undefined;
}
state = undefined;
},
/**
* Get the current presence state.
* @returns {string} the presence state (see PRESENCE enum)
*/
getState: function() {
return state;
},
/**
* Set the presence state.
* If the state has changed, the Home Server will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
setState: function(newState) {
if (newState === state) {
return;
}
if (PRESENCE_STATES.indexOf(newState) === -1) {
throw new Error("Bad presence state: " + newState);
}
if (!this.running) {
return;
}
state = newState;
MatrixClientPeg.get().setPresence(state).done(function() {
console.log("Presence: %s", newState);
}, function(err) {
console.error("Failed to set presence: %s", err);
});
},
/**
* Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
* @private
*/
_onUnavailableTimerFire: function() {
this.setState("unavailable");
},
/**
* Callback called when the user made an action on the page
* @private
*/
_resetTimer: function() {
var self = this;
this.setState("online");
// Re-arm the timer
clearTimeout(timer);
timer = setTimeout(function() {
self._onUnavailableTimerFire();
}, UNAVAILABLE_TIME_MS);
}
};

View file

@ -0,0 +1,323 @@
/*
Copyright 2015 OpenMarket 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.
*/
/*
* State vars:
* 'presence' : string (online|offline|unavailable etc)
* 'active' : number (ms ago; can be -1)
* 'can': {
* kick: boolean,
* ban: boolean,
* mute: boolean,
* modifyLevel: boolean
* },
* 'muted': boolean,
* 'isTargetMod': boolean
*/
var MatrixClientPeg = require("../../MatrixClientPeg");
var dis = require("../../dispatcher");
var Modal = require("../../Modal");
var sdk = require('../../index');
module.exports = {
componentDidMount: function() {
var self = this;
// listen for presence changes
function updateUserState(event, user) {
if (!self.props.member) { return; }
if (user.userId === self.props.member.userId) {
self.setState({
presence: user.presence,
active: user.lastActiveAgo
});
}
}
MatrixClientPeg.get().on("User.presence", updateUserState);
this.userPresenceFn = updateUserState;
// listen for power level changes
function updatePowerLevel(event, member) {
if (!self.props.member) { return; }
if (member.roomId !== self.props.member.roomId) {
return;
}
// only interested in changes to us or them
var myUserId = MatrixClientPeg.get().credentials.userId;
if ([myUserId, self.props.member.userId].indexOf(member.userId) === -1) {
return;
}
self.setState(self._calculateOpsPermissions());
}
MatrixClientPeg.get().on("RoomMember.powerLevel", updatePowerLevel);
this.updatePowerLevelFn = updatePowerLevel;
// work out the current state
if (this.props.member) {
var usr = MatrixClientPeg.get().getUser(this.props.member.userId) || {};
var memberState = this._calculateOpsPermissions();
memberState.presence = usr.presence || "offline";
memberState.active = usr.lastActiveAgo || -1;
this.setState(memberState);
}
},
componentWillUnmount: function() {
MatrixClientPeg.get().removeListener("User.presence", this.userPresenceFn);
MatrixClientPeg.get().removeListener(
"RoomMember.powerLevel", this.updatePowerLevelFn
);
},
onKick: function() {
var roomId = this.props.member.roomId;
var target = this.props.member.userId;
var self = this;
MatrixClientPeg.get().kick(roomId, target).done(function() {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
console.log("Kick success");
}, function(err) {
var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Kick error",
description: err.message
});
});
},
onBan: function() {
var roomId = this.props.member.roomId;
var target = this.props.member.userId;
var self = this;
MatrixClientPeg.get().ban(roomId, target).done(function() {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
console.log("Ban success");
}, function(err) {
var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Ban error",
description: err.message
});
});
},
onMuteToggle: function() {
var roomId = this.props.member.roomId;
var target = this.props.member.userId;
var self = this;
var room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
return;
}
var powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", ""
);
if (!powerLevelEvent) {
return;
}
var isMuted = this.state.muted;
var powerLevels = powerLevelEvent.getContent();
var levelToSend = (
(powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
powerLevels.events_default
);
var level;
if (isMuted) { // unmute
level = levelToSend;
}
else { // mute
level = levelToSend - 1;
}
MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done(
function() {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
console.log("Mute toggle success");
}, function(err) {
var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Mute error",
description: err.message
});
});
},
onModToggle: function() {
var roomId = this.props.member.roomId;
var target = this.props.member.userId;
var room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
return;
}
var powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", ""
);
if (!powerLevelEvent) {
return;
}
var me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (!me) {
return;
}
var defaultLevel = powerLevelEvent.getContent().users_default;
var modLevel = me.powerLevel - 1;
// toggle the level
var newLevel = this.state.isTargetMod ? defaultLevel : modLevel;
MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done(
function() {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
console.log("Mod toggle success");
}, function(err) {
var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Mod error",
description: err.message
});
});
},
onChatClick: function() {
// check if there are any existing rooms with just us and them (1:1)
// If so, just view that room. If not, create a private room with them.
var rooms = MatrixClientPeg.get().getRooms();
var userIds = [
this.props.member.userId,
MatrixClientPeg.get().credentials.userId
];
var existingRoomId = null;
for (var i = 0; i < rooms.length; i++) {
var members = rooms[i].getJoinedMembers();
if (members.length === 2) {
var hasTargetUsers = true;
for (var j = 0; j < members.length; j++) {
if (userIds.indexOf(members[j].userId) === -1) {
hasTargetUsers = false;
break;
}
}
if (hasTargetUsers) {
existingRoomId = rooms[i].roomId;
break;
}
}
}
if (existingRoomId) {
dis.dispatch({
action: 'view_room',
room_id: existingRoomId
});
}
else {
MatrixClientPeg.get().createRoom({
invite: [this.props.member.userId],
preset: "private_chat"
}).done(function(res) {
dis.dispatch({
action: 'view_room',
room_id: res.room_id
});
}, function(err) {
console.error(
"Failed to create room: %s", JSON.stringify(err)
);
});
}
},
getInitialState: function() {
return {
presence: "offline",
active: -1,
can: {
kick: false,
ban: false,
mute: false,
modifyLevel: false
},
muted: false,
isTargetMod: false
}
},
_calculateOpsPermissions: function() {
var defaultPerms = {
can: {},
muted: false,
modifyLevel: false
};
var room = MatrixClientPeg.get().getRoom(this.props.member.roomId);
if (!room) {
return defaultPerms;
}
var powerLevels = room.currentState.getStateEvents(
"m.room.power_levels", ""
);
if (!powerLevels) {
return defaultPerms;
}
var me = room.getMember(MatrixClientPeg.get().credentials.userId);
var them = this.props.member;
return {
can: this._calculateCanPermissions(
me, them, powerLevels.getContent()
),
muted: this._isMuted(them, powerLevels.getContent()),
isTargetMod: them.powerLevel > powerLevels.getContent().users_default
};
},
_calculateCanPermissions: function(me, them, powerLevels) {
var can = {
kick: false,
ban: false,
mute: false,
modifyLevel: false
};
var canAffectUser = them.powerLevel < me.powerLevel;
if (!canAffectUser) {
//console.log("Cannot affect user: %s >= %s", them.powerLevel, me.powerLevel);
return can;
}
var editPowerLevel = (
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
powerLevels.state_default
);
can.kick = me.powerLevel >= powerLevels.kick;
can.ban = me.powerLevel >= powerLevels.ban;
can.mute = me.powerLevel >= editPowerLevel;
can.modifyLevel = me.powerLevel > them.powerLevel;
return can;
},
_isMuted: function(member, powerLevelContent) {
if (!powerLevelContent || !member) {
return false;
}
var levelToSend = (
(powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
powerLevelContent.events_default
);
return member.powerLevel < levelToSend;
}
};

View file

@ -24,6 +24,10 @@ var Loader = require("react-loader");
var MatrixClientPeg = require("../../MatrixClientPeg");
module.exports = {
getInitialState: function() {
return {};
},
onClick: function() {
dis.dispatch({
action: 'view_user',

View file

@ -0,0 +1,37 @@
/*
Copyright 2015 OpenMarket 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.
*/
var React = require("react");
module.exports = {
propTypes: {
title: React.PropTypes.string,
description: React.PropTypes.string,
button: React.PropTypes.string,
focus: React.PropTypes.bool,
onFinished: React.PropTypes.func.isRequired,
},
getDefaultProps: function() {
var self = this;
return {
title: "Error",
description: "An error has occurred.",
button: "OK",
focus: true,
};
},
};

View file

@ -14,10 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require("react");
var MatrixClientPeg = require("../../MatrixClientPeg");
var Modal = require("../../Modal");
var sdk = require('../../index');
var INITIAL_LOAD_NUM_MEMBERS = 50;
@ -32,39 +32,117 @@ module.exports = {
componentWillMount: function() {
var cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
cli.on("Room", this.onRoom); // invites
},
componentWillUnmount: function() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onRoom);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("User.presence", this.userPresenceFn);
}
},
componentDidMount: function() {
var that = this;
var self = this;
setTimeout(function() {
if (!that.isMounted()) return;
that.setState({
memberDict: that.roomMembers()
if (!self.isMounted()) return;
self.setState({
memberDict: self.roomMembers()
});
}, 50);
},
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener.
function updateUserState(event, user) {
var tile = self.refs[user.userId];
if (tile) {
tile.forceUpdate();
}
}
MatrixClientPeg.get().on("User.presence", updateUserState);
this.userPresenceFn = updateUserState;
},
// Remember to set 'key' on a MemberList to the ID of the room it's for
/*componentWillReceiveProps: function(newProps) {
},*/
onRoom: function(room) {
if (room.roomId !== this.props.roomId) {
return;
}
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
this._updateList();
},
onRoomStateMember: function(ev, state, member) {
this._updateList();
},
_updateList: function() {
var members = this.roomMembers();
this.setState({
memberDict: members
});
},
onInvite: function(inputText) {
var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
var self = this;
// sanity check the input
inputText = inputText.trim(); // react requires es5-shim so we know trim() exists
if (inputText[0] !== '@' || inputText.indexOf(":") === -1) {
console.error("Bad user ID to invite: %s", inputText);
Modal.createDialog(ErrorDialog, {
title: "Invite Error",
description: "Malformed user ID. Should look like '@localpart:domain'"
});
return;
}
self.setState({
inviting: true
});
console.log("Invite %s to %s", inputText, this.props.roomId);
MatrixClientPeg.get().invite(this.props.roomId, inputText).done(
function(res) {
console.log("Invited");
self.setState({
inviting: false
});
}, function(err) {
console.error("Failed to invite: %s", JSON.stringify(err));
Modal.createDialog(ErrorDialog, {
title: "Server error whilst inviting",
description: err.message
});
self.setState({
inviting: false
});
});
},
roomMembers: function(limit) {
if (!this.props.roomId) return {};
var cli = MatrixClientPeg.get();
var all_members = cli.getRoom(this.props.roomId).currentState.members;
var room = cli.getRoom(this.props.roomId);
if (!room) return {};
var all_members = room.currentState.members;
var all_user_ids = Object.keys(all_members);
all_user_ids.sort(function(userIdA, userIdB) {
var userA = all_members[userIdA].user;
var userB = all_members[userIdB].user;
var latA = userA ? userA.lastActiveAgo || Number.MAX_VALUE : Number.MAX_VALUE;
var latB = userB ? userB.lastActiveAgo || Number.MAX_VALUE : Number.MAX_VALUE;
return latA - latB;
});
var to_display = {};
var count = 0;
for (var i = 0; i < all_user_ids.length && (limit === undefined || count < limit); ++i) {

View file

@ -16,19 +16,44 @@ limitations under the License.
'use strict';
// should be atomised
var Loader = require("react-loader");
var MatrixClientPeg = require("../../MatrixClientPeg");
var RoomListSorter = require("../../RoomListSorter");
var Presence = require("../../Presence");
var dis = require("../../dispatcher");
var q = require("q");
var sdk = require('../../index');
var MatrixTools = require('../../MatrixTools');
module.exports = {
PageTypes: {
RoomView: "room_view",
UserSettings: "user_settings",
CreateRoom: "create_room",
RoomDirectory: "room_directory",
},
AuxPanel: {
RoomSettings: "room_settings",
},
getInitialState: function() {
return {
var s = {
logged_in: !!(MatrixClientPeg.get() && MatrixClientPeg.get().credentials),
ready: false
ready: false,
aux_panel: null,
};
if (s.logged_in) {
if (MatrixClientPeg.get().getRooms().length) {
s.page_type = this.PageTypes.RoomView;
} else {
s.page_type = this.PageTypes.RoomDirectory;
}
}
return s;
},
componentDidMount: function() {
@ -49,6 +74,7 @@ module.exports = {
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
document.removeEventListener("keydown", this.onKeyDown);
window.removeEventListener("focus", this.onFocus);
},
componentDidUpdate: function() {
@ -72,8 +98,11 @@ module.exports = {
window.localStorage.clear();
}
Notifier.stop();
Presence.stop();
MatrixClientPeg.get().stopClient();
MatrixClientPeg.get().removeAllListeners();
MatrixClientPeg.replace(null);
MatrixClientPeg.unset();
this.notifyNewScreen('');
break;
case 'start_registration':
if (this.state.logged_in) return;
@ -106,8 +135,23 @@ module.exports = {
case 'view_room':
this.focusComposer = true;
this.setState({
currentRoom: payload.room_id
currentRoom: payload.room_id,
page_type: this.PageTypes.RoomView,
});
if (this.sdkReady) {
// if the SDK is not ready yet, remember what room
// we're supposed to be on but don't notify about
// the new screen yet (we won't be showing it yet)
// The normal case where this happens is navigating
// to the room in the URL bar on page load.
var presentedId = payload.room_id;
var room = MatrixClientPeg.get().getRoom(payload.room_id);
if (room) {
var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
if (theAlias) presentedId = theAlias;
}
this.notifyNewScreen('room/'+presentedId);
}
break;
case 'view_prev_room':
roomIndexDelta = -1;
@ -123,9 +167,43 @@ module.exports = {
}
}
roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
if (roomIndex < 0) roomIndex = allRooms.length - 1;
this.focusComposer = true;
this.setState({
currentRoom: allRooms[roomIndex].roomId
});
this.notifyNewScreen('room/'+allRooms[roomIndex].roomId);
break;
case 'view_indexed_room':
var allRooms = RoomListSorter.mostRecentActivityFirst(
MatrixClientPeg.get().getRooms()
);
var roomIndex = payload.roomIndex;
if (allRooms[roomIndex]) {
this.focusComposer = true;
this.setState({
currentRoom: allRooms[roomIndex].roomId
});
this.notifyNewScreen('room/'+allRooms[roomIndex].roomId);
}
break;
case 'view_user_settings':
this.setState({
page_type: this.PageTypes.UserSettings,
});
break;
case 'view_create_room':
this.setState({
page_type: this.PageTypes.CreateRoom,
});
break;
case 'view_room_directory':
this.setState({
page_type: this.PageTypes.RoomDirectory,
});
break;
case 'notifier_enabled':
this.forceUpdate();
break;
}
},
@ -141,33 +219,67 @@ module.exports = {
startMatrixClient: function() {
var Notifier = sdk.getComponent('organisms.Notifier');
var cli = MatrixClientPeg.get();
var self = this;
cli.on('syncComplete', function() {
self.sdkReady = true;
if (!self.state.currentRoom) {
var firstRoom = null;
if (cli.getRooms() && cli.getRooms().length) {
firstRoom = RoomListSorter.mostRecentActivityFirst(
cli.getRooms()
)[0].roomId;
self.setState({ready: true, currentRoom: firstRoom, page_type: self.PageTypes.RoomView});
} else {
self.setState({ready: true, page_type: self.PageTypes.RoomDirectory});
}
self.setState({ready: true, currentRoom: firstRoom});
} else {
self.setState({ready: true, currentRoom: self.state.currentRoom});
}
// we notifyNewScreen now because now the room will actually be displayed,
// and (mostly) now we can get the correct alias.
var presentedId = self.state.currentRoom;
var room = MatrixClientPeg.get().getRoom(self.state.currentRoom);
if (room) {
var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
if (theAlias) presentedId = theAlias;
}
self.notifyNewScreen('room/'+presentedId);
dis.dispatch({action: 'focus_composer'});
});
cli.on('Call.incoming', function(call) {
dis.dispatch({
action: 'incoming_call',
call: call
});
});
Notifier.start();
Presence.start();
cli.startClient();
},
onKeyDown: function(ev) {
if (ev.altKey) {
if (ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) {
dis.dispatch({
action: 'view_indexed_room',
roomIndex: ev.keyCode - 49,
});
ev.stopPropagation();
ev.preventDefault();
return;
}
switch (ev.keyCode) {
case 38:
dis.dispatch({action: 'view_prev_room'});
ev.stopPropagation();
ev.preventDefault();
break;
case 40:
dis.dispatch({action: 'view_next_room'});
ev.stopPropagation();
ev.preventDefault();
break;
}
}
@ -188,6 +300,28 @@ module.exports = {
action: 'start_login',
params: params
});
} else if (screen.indexOf('room/') == 0) {
var roomString = screen.split('/')[1];
var defer = q.defer();
if (roomString[0] == '#') {
var self = this;
MatrixClientPeg.get().getRoomIdForAlias(roomString).done(function(result) {
if (self.sdkReady) self.setState({ready: true});
defer.resolve(result.room_id);
}, function() {
if (self.sdkReady) self.setState({ready: true});
defer.resolve(null);
});
this.setState({ready: false});
} else {
defer.resolve(roomString);
}
defer.promise.done(function(roomId) {
dis.dispatch({
action: 'view_room',
room_id: roomId
});
});
}
},
@ -197,4 +331,3 @@ module.exports = {
}
}
};