diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 4eb2adad5d..d4e7df3a16 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -141,8 +141,7 @@ var commands = { return reject("Usage: /join #alias:domain"); } if (!room_alias.match(/:/)) { - var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); - room_alias += ':' + domain; + room_alias += ':' + MatrixClientPeg.get().getDomain(); } // Try to find a room with this alias @@ -188,8 +187,7 @@ var commands = { return reject(this.getUsage()); } if (!room_alias.match(/:/)) { - var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); - room_alias += ':' + domain; + room_alias += ':' + MatrixClientPeg.get().getDomain(); } // Try to find a room with this alias diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 5296ef833e..f2ae22a1bb 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -66,7 +66,7 @@ function textForMemberEvent(ev) { function textForTopicEvent(ev) { var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return senderDisplayName + ' changed the topic to, "' + ev.getContent().topic + '"'; + return senderDisplayName + ' changed the topic to "' + ev.getContent().topic + '"'; }; function textForRoomNameEvent(ev) { diff --git a/src/component-index.js b/src/component-index.js index 3f84ac6392..2446b26b8d 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -23,7 +23,6 @@ limitations under the License. module.exports.components = {}; module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); -module.exports.components['structures.login.ForgotPassword'] = require('./components/structures/login/ForgotPassword'); module.exports.components['structures.login.Login'] = require('./components/structures/login/Login'); module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration'); module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); @@ -32,6 +31,10 @@ module.exports.components['structures.RoomView'] = require('./components/structu module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); +module.exports.components['structures.login.ForgotPassword'] = require('./components/structures/login/ForgotPassword'); +module.exports.components['structures.login.Login'] = require('./components/structures/login/Login'); +module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration'); +module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar'); module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar'); @@ -41,7 +44,9 @@ module.exports.components['views.create_room.RoomAlias'] = require('./components module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog'); module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt'); module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); +module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog'); module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText'); +module.exports.components['views.elements.PowerSelector'] = require('./components/views/elements/PowerSelector'); module.exports.components['views.elements.ProgressBar'] = require('./components/views/elements/ProgressBar'); module.exports.components['views.elements.TintableSvg'] = require('./components/views/elements/TintableSvg'); module.exports.components['views.elements.UserSelector'] = require('./components/views/elements/UserSelector'); diff --git a/src/components/structures/CreateRoom.js b/src/components/structures/CreateRoom.js index c21bc80c6b..116202d324 100644 --- a/src/components/structures/CreateRoom.js +++ b/src/components/structures/CreateRoom.js @@ -251,7 +251,7 @@ module.exports = React.createClass({ var UserSelector = sdk.getComponent("elements.UserSelector"); var RoomHeader = sdk.getComponent("rooms.RoomHeader"); - var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); + var domain = MatrixClientPeg.get().getDomain(); return (
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index e5af2a86b5..462933cbc6 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -64,7 +64,7 @@ module.exports = React.createClass({ collapse_lhs: false, collapse_rhs: false, ready: false, - width: 10000 + width: 10000, }; if (s.logged_in) { if (MatrixClientPeg.get().getRooms().length) { @@ -304,7 +304,7 @@ module.exports = React.createClass({ }); break; case 'view_room': - this._viewRoom(payload.room_id); + this._viewRoom(payload.room_id, payload.show_settings); break; case 'view_prev_room': roomIndexDelta = -1; @@ -357,8 +357,29 @@ module.exports = React.createClass({ this.notifyNewScreen('settings'); break; case 'view_create_room': - this._setPage(this.PageTypes.CreateRoom); - this.notifyNewScreen('new'); + //this._setPage(this.PageTypes.CreateRoom); + //this.notifyNewScreen('new'); + + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + var Loader = sdk.getComponent("elements.Spinner"); + var modal = Modal.createDialog(Loader); + + MatrixClientPeg.get().createRoom({ + preset: "private_chat" + }).done(function(res) { + modal.close(); + dis.dispatch({ + action: 'view_room', + room_id: res.room_id, + show_settings: true, + }); + }, function(err) { + modal.close(); + Modal.createDialog(ErrorDialog, { + title: "Failed to create room", + description: err.toString() + }); + }); break; case 'view_room_directory': this._setPage(this.PageTypes.RoomDirectory); @@ -399,7 +420,7 @@ module.exports = React.createClass({ }); }, - _viewRoom: function(roomId) { + _viewRoom: function(roomId, showSettings) { // before we switch room, record the scroll state of the current room this._updateScrollMap(); @@ -437,6 +458,9 @@ module.exports = React.createClass({ var scrollState = this.scrollStateMap[roomId]; this.refs.roomView.restoreScrollState(scrollState); } + if (this.refs.roomView && showSettings) { + this.refs.roomView.showSettings(true); + } }, // update scrollStateMap according to the current scroll state of the @@ -522,7 +546,9 @@ module.exports = React.createClass({ UserActivity.start(); Presence.start(); cli.startClient({ - pendingEventOrdering: "end" + pendingEventOrdering: "end", + // deliberately huge limit for now to avoid hitting gappy /sync's until gappy /sync performance improves + initialSyncLimit: 250, }); }, @@ -636,6 +662,8 @@ module.exports = React.createClass({ onUserClick: function(event, userId) { event.preventDefault(); + + /* var MemberInfo = sdk.getComponent('rooms.MemberInfo'); var member = new Matrix.RoomMember(null, userId); ContextualMenu.createMenu(MemberInfo, { @@ -643,6 +671,14 @@ module.exports = React.createClass({ right: window.innerWidth - event.pageX, top: event.pageY }); + */ + + var member = new Matrix.RoomMember(null, userId); + if (!member) { return; } + dis.dispatch({ + action: 'view_user', + member: member, + }); }, onLogoutClick: function(event) { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index b20f615a3f..b333a18331 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -142,16 +142,16 @@ module.exports = React.createClass({ // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - if (this.refs.messagePanel) { - // disconnect the D&D event listeners from the message panel. This - // is really just for hygiene - the messagePanel is going to be + if (this.refs.roomView) { + // disconnect the D&D event listeners from the room view. This + // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. - var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); - messagePanel.removeEventListener('drop', this.onDrop); - messagePanel.removeEventListener('dragover', this.onDragOver); - messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd); - messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd); + var roomView = ReactDOM.findDOMNode(this.refs.roomView); + roomView.removeEventListener('drop', this.onDrop); + roomView.removeEventListener('dragover', this.onDragOver); + roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); + roomView.removeEventListener('dragend', this.onDragLeaveOrEnd); } dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { @@ -414,6 +414,14 @@ module.exports = React.createClass({ window.addEventListener('resize', this.onResize); this.onResize(); + if (this.refs.roomView) { + var roomView = ReactDOM.findDOMNode(this.refs.roomView); + roomView.addEventListener('drop', this.onDrop); + roomView.addEventListener('dragover', this.onDragOver); + roomView.addEventListener('dragleave', this.onDragLeaveOrEnd); + roomView.addEventListener('dragend', this.onDragLeaveOrEnd); + } + this._updateTabCompleteList(this.state.room); }, @@ -432,11 +440,6 @@ module.exports = React.createClass({ var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); this.refs.messagePanel.initialised = true; - messagePanel.addEventListener('drop', this.onDrop); - messagePanel.addEventListener('dragover', this.onDragOver); - messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd); - messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd); - this.scrollToBottom(); this.sendReadReceipt(); @@ -884,9 +887,27 @@ module.exports = React.createClass({ old_history_visibility = "shared"; } + var old_guest_read = (old_history_visibility === "world_readable"); + + var old_guest_join = this.state.room.currentState.getStateEvents('m.room.guest_access', ''); + if (old_guest_join) { + old_guest_join = (old_guest_join.getContent().guest_access === "can_join"); + } + else { + old_guest_join = false; + } + + var old_canonical_alias = this.state.room.currentState.getStateEvents('m.room.canonical_alias', ''); + if (old_canonical_alias) { + old_canonical_alias = old_canonical_alias.getContent().alias; + } + else { + old_canonical_alias = ""; + } + var deferreds = []; - if (old_name != newVals.name && newVals.name != undefined && newVals.name) { + if (old_name != newVals.name && newVals.name != undefined) { deferreds.push( MatrixClientPeg.get().setRoomName(this.state.room.roomId, newVals.name) ); @@ -919,6 +940,13 @@ module.exports = React.createClass({ ); } + // setRoomMutePushRule will do nothing if there is no change + deferreds.push( + MatrixClientPeg.get().setRoomMutePushRule( + "global", this.state.room.roomId, newVals.are_notifications_muted + ) + ); + if (newVals.power_levels) { deferreds.push( MatrixClientPeg.get().sendStateEvent( @@ -927,6 +955,83 @@ module.exports = React.createClass({ ); } + if (newVals.alias_operations) { + var oplist = []; + for (var i = 0; i < newVals.alias_operations.length; i++) { + var alias_operation = newVals.alias_operations[i]; + switch (alias_operation.type) { + case 'put': + oplist.push( + MatrixClientPeg.get().createAlias( + alias_operation.alias, this.state.room.roomId + ) + ); + break; + case 'delete': + oplist.push( + MatrixClientPeg.get().deleteAlias( + alias_operation.alias + ) + ); + break; + default: + console.log("Unknown alias operation, ignoring: " + alias_operation.type); + } + } + + if (oplist.length) { + var deferred = oplist[0]; + oplist.splice(1).forEach(function (f) { + deferred = deferred.then(f); + }); + deferreds.push(deferred); + } + } + + if (newVals.tag_operations) { + // FIXME: should probably be factored out with alias_operations above + var oplist = []; + for (var i = 0; i < newVals.tag_operations.length; i++) { + var tag_operation = newVals.tag_operations[i]; + switch (tag_operation.type) { + case 'put': + oplist.push( + MatrixClientPeg.get().setRoomTag( + this.props.roomId, tag_operation.tag, {} + ) + ); + break; + case 'delete': + oplist.push( + MatrixClientPeg.get().deleteRoomTag( + this.props.roomId, tag_operation.tag + ) + ); + break; + default: + console.log("Unknown tag operation, ignoring: " + tag_operation.type); + } + } + + if (oplist.length) { + var deferred = oplist[0]; + oplist.splice(1).forEach(function (f) { + deferred = deferred.then(f); + }); + deferreds.push(deferred); + } + } + + if (old_canonical_alias !== newVals.canonical_alias) { + deferreds.push( + MatrixClientPeg.get().sendStateEvent( + this.state.room.roomId, "m.room.canonical_alias", { + alias: newVals.canonical_alias + }, "" + ) + ); + } + if (newVals.color_scheme) { deferreds.push( MatrixClientPeg.get().setRoomAccountData( @@ -935,26 +1040,43 @@ module.exports = React.createClass({ ); } - deferreds.push( - MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, { - allowRead: newVals.guest_read, - allowJoin: newVals.guest_join - }) - ); + if (old_guest_read != newVals.guest_read || + old_guest_join != newVals.guest_join) + { + deferreds.push( + MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, { + allowRead: newVals.guest_read, + allowJoin: newVals.guest_join + }) + ); + } if (deferreds.length) { var self = this; - q.all(deferreds).fail(function(err) { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failed to set state", - description: err.toString() + q.allSettled(deferreds).then( + function(results) { + var fails = results.filter(function(result) { return result.state !== "fulfilled" }); + if (fails.length) { + fails.forEach(function(result) { + console.error(result.reason); + }); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failed to set state", + description: fails.map(function(result) { return result.reason }).join("\n"), + }); + self.refs.room_settings.resetState(); + } + else { + self.setState({ + editingRoomSettings: false + }); + } + }).finally(function() { + self.setState({ + uploadingRoomSettings: false, + }); }); - }).finally(function() { - self.setState({ - uploadingRoomSettings: false, - }); - }); } else { this.setState({ editingRoomSettings: false, @@ -1022,16 +1144,19 @@ module.exports = React.createClass({ onSaveClick: function() { this.setState({ - editingRoomSettings: false, uploadingRoomSettings: true, }); this.uploadNewState({ name: this.refs.header.getRoomName(), - topic: this.refs.room_settings.getTopic(), + topic: this.refs.header.getTopic(), join_rule: this.refs.room_settings.getJoinRules(), history_visibility: this.refs.room_settings.getHistoryVisibility(), + are_notifications_muted: this.refs.room_settings.areNotificationsMuted(), power_levels: this.refs.room_settings.getPowerLevels(), + alias_operations: this.refs.room_settings.getAliasOperations(), + tag_operations: this.refs.room_settings.getTagOperations(), + canonical_alias: this.refs.room_settings.getCanonicalAlias(), guest_join: this.refs.room_settings.canGuestsJoin(), guest_read: this.refs.room_settings.canGuestsRead(), color_scheme: this.refs.room_settings.getColorScheme(), @@ -1187,26 +1312,32 @@ module.exports = React.createClass({ // a minimum of the height of the video element, whilst also capping it from pushing out the page // so we have to do it via JS instead. In this implementation we cap the height by putting // a maxHeight on the underlying remote video tag. - var auxPanelMaxHeight; + + // header + footer + status + give us at least 120px of scrollback at all times. + var auxPanelMaxHeight = window.innerHeight - + (83 + // height of RoomHeader + 36 + // height of the status area + 72 + // minimum height of the message compmoser + 120); // amount of desired scrollback + + // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway + // but it's better than the video going missing entirely + if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; + if (this.refs.callView) { var video = this.refs.callView.getVideoView().getRemoteVideoElement(); - // header + footer + status + give us at least 100px of scrollback at all times. - auxPanelMaxHeight = window.innerHeight - - (83 + 72 + - sdk.getComponent('rooms.MessageComposer').MAX_HEIGHT + - 100); - - // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway - // but it's better than the video going missing entirely - if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; - video.style.maxHeight = auxPanelMaxHeight + "px"; // the above might have made the video panel resize itself, so now // we need to tell the gemini panel to adapt. this.onChildResize(); } + + // we need to do this for general auxPanels too + if (this.refs.auxPanel) { + this.refs.auxPanel.style.maxHeight = auxPanelMaxHeight + "px"; + } }, onFullscreenClick: function() { @@ -1249,6 +1380,13 @@ module.exports = React.createClass({ } }, + showSettings: function(show) { + // XXX: this is a bit naughty; we should be doing this via props + if (show) { + this.setState({editingRoomSettings: true}); + } + }, + render: function() { var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var MessageComposer = sdk.getComponent('rooms.MessageComposer'); @@ -1399,7 +1537,7 @@ module.exports = React.createClass({ var aux = null; if (this.state.editingRoomSettings) { - aux = ; + aux = ; } else if (this.state.uploadingRoomSettings) { var Loader = sdk.getComponent("elements.Spinner"); @@ -1433,7 +1571,7 @@ module.exports = React.createClass({ fileDropTarget =

- Drop File Here + Drop file here to upload
; } @@ -1534,7 +1672,7 @@ module.exports = React.createClass({ ); return ( -
+
- { fileDropTarget } -
+
+ { fileDropTarget } { conferenceCallNotification } diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index ddf4229170..44b7b9a973 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -21,6 +21,7 @@ var dis = require("../../dispatcher"); var q = require('q'); var version = require('../../../package.json').version; var UserSettingsStore = require('../../UserSettingsStore'); +var GeminiScrollbar = require('react-gemini-scrollbar'); module.exports = React.createClass({ displayName: 'UserSettings', @@ -83,6 +84,12 @@ module.exports = React.createClass({ } }, + onAvatarPickerClick: function(ev) { + if (this.refs.file_label) { + this.refs.file_label.click(); + } + }, + onAvatarSelected: function(ev) { var self = this; var changeAvatar = this.refs.changeAvatar; @@ -145,10 +152,6 @@ module.exports = React.createClass({ this.logoutModal.closeDialog(); }, - onEnableNotificationsChange: function(event) { - UserSettingsStore.setEnableNotifications(event.target.checked); - }, - render: function() { switch (this.state.phase) { case "UserSettings.LOADING": @@ -166,6 +169,7 @@ module.exports = React.createClass({ var ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName"); var ChangePassword = sdk.getComponent("views.settings.ChangePassword"); var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); + var Notifications = sdk.getComponent("settings.Notifications"); var avatarUrl = ( this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null ); @@ -175,7 +179,7 @@ module.exports = React.createClass({ if (MatrixClientPeg.get().isGuest()) { accountJsx = (
- Upgrade (It's free!) + Create an account
); } @@ -196,6 +200,8 @@ module.exports = React.createClass({
+ +

Profile

@@ -225,13 +231,15 @@ module.exports = React.createClass({
- +
+ +
-
@@ -241,34 +249,18 @@ module.exports = React.createClass({

Account

- {accountJsx} -
- -
-
+ +
Log out
+ + {accountJsx}

Notifications

-
-
-
- -
-
- -
-
-
+

Advanced

@@ -281,6 +273,8 @@ module.exports = React.createClass({ Version {this.state.clientVersion}
+ +
); } diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index ed9364df60..d06cf2de84 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -48,7 +48,7 @@ module.exports = React.createClass({ render: function() { return (
-
+
{this.props.title}
diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index f415201e45..4eeecd64b3 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -46,7 +46,7 @@ module.exports = React.createClass({ render: function() { return (
-
+
{this.props.title}
diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js new file mode 100644 index 0000000000..3cda852449 --- /dev/null +++ b/src/components/views/dialogs/TextInputDialog.js @@ -0,0 +1,94 @@ +/* +Copyright 2015, 2016 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 = React.createClass({ + displayName: 'TextInputDialog', + propTypes: { + title: React.PropTypes.string, + description: React.PropTypes.string, + value: React.PropTypes.string, + button: React.PropTypes.string, + focus: React.PropTypes.bool, + onFinished: React.PropTypes.func.isRequired + }, + + getDefaultProps: function() { + return { + title: "", + value: "", + description: "", + button: "OK", + focus: true + }; + }, + + componentDidMount: function() { + if (this.props.focus) { + // Set the cursor at the end of the text input + this.refs.textinput.value = this.props.value; + } + }, + + onOk: function() { + this.props.onFinished(true, this.refs.textinput.value); + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + onKeyDown: function(e) { + if (e.keyCode === 27) { // escape + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(false); + } + else if (e.keyCode === 13) { // enter + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(true, this.refs.textinput.value); + } + }, + + render: function() { + return ( +
+
+ {this.props.title} +
+
+
+ +
+
+ +
+
+
+ + + +
+
+ ); + } +}); diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index beedfc35c8..683cfe4fc8 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -18,13 +18,22 @@ limitations under the License. var React = require('react'); +const KEY_TAB = 9; +const KEY_SHIFT = 16; +const KEY_WINDOWS = 91; + module.exports = React.createClass({ displayName: 'EditableText', propTypes: { onValueChanged: React.PropTypes.func, initialValue: React.PropTypes.string, label: React.PropTypes.string, - placeHolder: React.PropTypes.string, + placeholder: React.PropTypes.string, + className: React.PropTypes.string, + labelClassName: React.PropTypes.string, + placeholderClassName: React.PropTypes.string, + blurToCancel: React.PropTypes.bool, + editable: React.PropTypes.bool, }, Phases: { @@ -36,38 +45,62 @@ module.exports = React.createClass({ return { onValueChanged: function() {}, initialValue: '', - label: 'Click to set', + label: '', placeholder: '', + editable: true, }; }, getInitialState: function() { return { - value: this.props.initialValue, phase: this.Phases.Display, } }, componentWillReceiveProps: function(nextProps) { - this.setState({ - value: nextProps.initialValue - }); + if (nextProps.initialValue !== this.props.initialValue) { + this.value = nextProps.initialValue; + if (this.refs.editable_div) { + this.showPlaceholder(!this.value); + } + } + }, + + componentWillMount: function() { + // we track value as an JS object field rather than in React state + // as React doesn't play nice with contentEditable. + this.value = ''; + this.placeholder = false; + }, + + componentDidMount: function() { + this.value = this.props.initialValue; + if (this.refs.editable_div) { + this.showPlaceholder(!this.value); + } + }, + + showPlaceholder: function(show) { + if (show) { + this.refs.editable_div.textContent = this.props.placeholder; + this.refs.editable_div.setAttribute("class", this.props.className + " " + this.props.placeholderClassName); + this.placeholder = true; + this.value = ''; + } + else { + this.refs.editable_div.textContent = this.value; + this.refs.editable_div.setAttribute("class", this.props.className); + this.placeholder = false; + } }, getValue: function() { - return this.state.value; + return this.value; }, - setValue: function(val, shouldSubmit, suppressListener) { - var self = this; - this.setState({ - value: val, - phase: this.Phases.Display, - }, function() { - if (!suppressListener) { - self.onValueChanged(shouldSubmit); - } - }); + setValue: function(value) { + this.value = value; + this.showPlaceholder(!this.value); }, edit: function() { @@ -80,65 +113,106 @@ module.exports = React.createClass({ this.setState({ phase: this.Phases.Display, }); + this.value = this.props.initialValue; + this.showPlaceholder(!this.value); this.onValueChanged(false); }, onValueChanged: function(shouldSubmit) { - this.props.onValueChanged(this.state.value, shouldSubmit); + this.props.onValueChanged(this.value, shouldSubmit); + }, + + onKeyDown: function(ev) { + // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); + + if (this.placeholder) { + this.showPlaceholder(false); + } + + if (ev.key == "Enter") { + ev.stopPropagation(); + ev.preventDefault(); + } + + // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); }, onKeyUp: function(ev) { + // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); + + if (!ev.target.textContent) { + this.showPlaceholder(true); + } + else if (!this.placeholder) { + this.value = ev.target.textContent; + } + if (ev.key == "Enter") { this.onFinish(ev); } else if (ev.key == "Escape") { this.cancelEdit(); } + + // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); }, - onClickDiv: function() { + onClickDiv: function(ev) { + if (!this.props.editable) return; + this.setState({ phase: this.Phases.Edit, }) }, onFocus: function(ev) { - ev.target.setSelectionRange(0, ev.target.value.length); - }, + //ev.target.setSelectionRange(0, ev.target.textContent.length); - onFinish: function(ev) { - if (ev.target.value) { - this.setValue(ev.target.value, ev.key === "Enter"); - } else { - this.cancelEdit(); + var node = ev.target.childNodes[0]; + if (node) { + var range = document.createRange(); + range.setStart(node, 0); + range.setEnd(node, node.length); + + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); } }, - onBlur: function() { - this.cancelEdit(); + onFinish: function(ev) { + var self = this; + var submit = (ev.key === "Enter"); + this.setState({ + phase: this.Phases.Display, + }, function() { + self.onValueChanged(submit); + }); + }, + + onBlur: function(ev) { + var sel = window.getSelection(); + sel.removeAllRanges(); + + if (this.props.blurToCancel) + this.cancelEdit(); + else + this.onFinish(ev); + + this.showPlaceholder(!this.value); }, render: function() { var editable_el; - if (this.state.phase == this.Phases.Display) { - if (this.state.value) { - editable_el =
{this.state.value}
; - } else { - editable_el =
{this.props.label}
; - } - } else if (this.state.phase == this.Phases.Edit) { - editable_el = ( -
- -
- ); + if (!this.props.editable || (this.state.phase == this.Phases.Display && (this.props.label || this.props.labelClassName) && !this.value)) { + // show the label + editable_el =
{ this.props.label || this.props.initialValue }
; + } else { + // show the content editable div, but manually manage its contents as react and contentEditable don't play nice together + editable_el =
; } - return ( -
- {editable_el} -
- ); + return editable_el; } }); diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js new file mode 100644 index 0000000000..c47c9f3809 --- /dev/null +++ b/src/components/views/elements/PowerSelector.js @@ -0,0 +1,108 @@ +/* +Copyright 2015, 2016 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. +*/ + +'use strict'; + +var React = require('react'); + +var roles = { + 0: 'User', + 50: 'Moderator', + 100: 'Admin', +}; + +var reverseRoles = {}; +Object.keys(roles).forEach(function(key) { + reverseRoles[roles[key]] = key; +}); + +module.exports = React.createClass({ + displayName: 'PowerSelector', + + propTypes: { + value: React.PropTypes.number.isRequired, + disabled: React.PropTypes.bool, + onChange: React.PropTypes.func, + }, + + getInitialState: function() { + return { + custom: (roles[this.props.value] === undefined), + }; + }, + + onSelectChange: function(event) { + this.state.custom = (event.target.value === "Custom"); + this.props.onChange(this.getValue()); + }, + + onCustomBlur: function(event) { + this.props.onChange(this.getValue()); + }, + + onCustomKeyDown: function(event) { + if (event.key == "Enter") { + this.props.onChange(this.getValue()); + } + }, + + getValue: function() { + var value; + if (this.refs.select) { + value = reverseRoles[ this.refs.select.value ]; + if (this.refs.custom) { + if (value === undefined) value = parseInt( this.refs.custom.value ); + } + } + return value; + }, + + render: function() { + var customPicker; + if (this.state.custom) { + var input; + if (this.props.disabled) { + input = { this.props.value } + } + else { + input = + } + customPicker = of { input }; + } + + var selectValue = roles[this.props.value] || "Custom"; + var select; + if (this.props.disabled) { + select = { selectValue }; + } + else { + select = + + } + + return ( + + { select } + { customPicker } + + ); + } +}); diff --git a/src/components/views/login/CustomServerDialog.js b/src/components/views/login/CustomServerDialog.js index 8a67dfd7e6..dc6a49abd6 100644 --- a/src/components/views/login/CustomServerDialog.js +++ b/src/components/views/login/CustomServerDialog.js @@ -22,7 +22,7 @@ module.exports = React.createClass({ render: function() { return (
-
+
Custom Server Options
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index fe763d06bf..e3613ef9a3 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -36,6 +36,9 @@ module.exports = React.createClass({ }, componentDidUpdate: function() { + // XXX: why don't we linkify here? + // XXX: why do we bother doing this on update at all, given events are immutable? + if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") HtmlUtils.highlightDom(ReactDOM.findDOMNode(this)); }, diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index b5f0b88b40..a8a601c2d6 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -58,15 +58,16 @@ module.exports = React.createClass({ var roomId = this.props.member.roomId; var target = this.props.member.userId; 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) { - Modal.createDialog(ErrorDialog, { - title: "Kick error", - description: err.message - }); - }); + // 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) { + Modal.createDialog(ErrorDialog, { + title: "Kick error", + description: err.message + }); + } + ); this.props.onFinished(); }, @@ -74,16 +75,18 @@ module.exports = React.createClass({ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var roomId = this.props.member.roomId; var target = this.props.member.userId; - 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) { - Modal.createDialog(ErrorDialog, { - title: "Ban error", - description: err.message - }); - }); + 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) { + Modal.createDialog(ErrorDialog, { + title: "Ban error", + description: err.message + }); + } + ); this.props.onFinished(); }, @@ -118,16 +121,17 @@ module.exports = React.createClass({ } 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) { - Modal.createDialog(ErrorDialog, { - title: "Mute error", - description: err.message - }); - }); + 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) { + Modal.createDialog(ErrorDialog, { + title: "Mute error", + description: err.message + }); + } + ); this.props.onFinished(); }, @@ -154,22 +158,55 @@ module.exports = React.createClass({ } var defaultLevel = powerLevelEvent.getContent().users_default; var modLevel = me.powerLevel - 1; + if (modLevel > 50 && defaultLevel < 50) modLevel = 50; // try to stick with the vector level defaults // 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) { - Modal.createDialog(ErrorDialog, { - title: "Mod error", - description: err.message - }); - }); + 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) { + Modal.createDialog(ErrorDialog, { + title: "Mod error", + description: err.message + }); + } + ); this.props.onFinished(); }, + onPowerChange: function(powerLevel) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + var roomId = this.props.member.roomId; + var target = this.props.member.userId; + var room = MatrixClientPeg.get().getRoom(roomId); + if (!room) { + this.props.onFinished(); + return; + } + var powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "" + ); + if (!powerLevelEvent) { + this.props.onFinished(); + return; + } + MatrixClientPeg.get().setPowerLevel(roomId, target, powerLevel, 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("Power change success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Failure to change power level", + description: err.message + }); + } + ); + this.props.onFinished(); + }, + 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. @@ -209,20 +246,22 @@ module.exports = React.createClass({ MatrixClientPeg.get().createRoom({ invite: [this.props.member.userId], preset: "private_chat" - }).done(function(res) { - self.setState({ creatingRoom: false }); - dis.dispatch({ - action: 'view_room', - room_id: res.room_id - }); - self.props.onFinished(); - }, function(err) { - self.setState({ creatingRoom: false }); - console.error( - "Failed to create room: %s", JSON.stringify(err) - ); - self.props.onFinished(); - }); + }).done( + function(res) { + self.setState({ creatingRoom: false }); + dis.dispatch({ + action: 'view_room', + room_id: res.room_id + }); + self.props.onFinished(); + }, function(err) { + self.setState({ creatingRoom: false }); + console.error( + "Failed to create room: %s", JSON.stringify(err) + ); + self.props.onFinished(); + } + ); } }, @@ -291,9 +330,15 @@ module.exports = React.createClass({ (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default ); + var levelToSend = ( + (powerLevels.events ? powerLevels.events["m.room.message"] : null) || + powerLevels.events_default + ); + can.kick = me.powerLevel >= powerLevels.kick; can.ban = me.powerLevel >= powerLevels.ban; can.mute = me.powerLevel >= editPowerLevel; + can.toggleMod = me.powerLevel > them.powerLevel && them.powerLevel >= levelToSend; can.modifyLevel = me.powerLevel > them.powerLevel; return can; }, @@ -317,12 +362,11 @@ module.exports = React.createClass({ }, render: function() { - var interactButton, kickButton, banButton, muteButton, giveModButton, spinner; - if (this.props.member.userId === MatrixClientPeg.get().credentials.userId) { - interactButton =
Leave room
; - } - else { - interactButton =
Start chat
; + var startChat, kickButton, banButton, muteButton, giveModButton, spinner; + if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) { + // FIXME: we're referring to a vector component from react-sdk + var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile'); + startChat = } if (this.state.creatingRoom) { @@ -346,35 +390,56 @@ module.exports = React.createClass({ {muteLabel}
; } - if (this.state.can.modifyLevel) { - var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod"; + if (this.state.can.toggleMod) { + var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator"; giveModButton =
{giveOpLabel}
} + // TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet + // e.g. clicking on a linkified userid in a room + + var adminTools; + if (kickButton || banButton || muteButton || giveModButton) { + adminTools = +
+

Admin tools

+ +
+ {muteButton} + {kickButton} + {banButton} + {giveModButton} +
+
+ } + var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + var PowerSelector = sdk.getComponent('elements.PowerSelector'); return (
+

{ this.props.member.name }

-
- { this.props.member.userId } -
-
- power: { this.props.member.powerLevelNorm }% -
-
- {interactButton} - {muteButton} - {kickButton} - {banButton} - {giveModButton} - {spinner} + +
+
+ { this.props.member.userId } +
+
+ Level: +
+ + { startChat } + + { adminTools } + + { spinner }
); } diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index 7a7c7fed75..9136e848f1 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -63,7 +63,7 @@ module.exports = React.createClass({ }, getPowerLabel: function() { - return this.props.member.userId; + return this.props.member.userId + " (power " + this.props.member.powerLevel + ")"; }, render: function() { @@ -79,6 +79,14 @@ module.exports = React.createClass({ var av = ( ); + var power; + var powerLevel = this.props.member.powerLevel; + if (powerLevel >= 50 && powerLevel < 99) { + power = Mod; + } + if (powerLevel >= 99) { + power = Admin; + } if (member.user) { this.user_last_modified_time = member.user.getLastModifiedTime(); @@ -94,7 +102,7 @@ module.exports = React.createClass({ + name={name} powerLevel={this.props.member.powerLevel} /> ); } }); diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 930725570b..d5aaaa1128 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -209,23 +209,18 @@ module.exports = React.createClass({ this.sentHistory.push(input); this.onEnter(ev); } - else if (ev.keyCode === KeyCode.UP) { - var input = this.refs.textarea.value; - var offset = this.refs.textarea.selectionStart || 0; - if (ev.ctrlKey || !input.substr(0, offset).match(/\n/)) { - this.sentHistory.next(1); - ev.preventDefault(); - this.resizeInput(); - } - } - else if (ev.keyCode === KeyCode.DOWN) { - var input = this.refs.textarea.value; - var offset = this.refs.textarea.selectionStart || 0; - if (ev.ctrlKey || !input.substr(offset).match(/\n/)) { - this.sentHistory.next(-1); - ev.preventDefault(); - this.resizeInput(); - } + else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) { + var oldSelectionStart = this.refs.textarea.selectionStart; + // Remember the keyCode because React will recycle the synthetic event + var keyCode = ev.keyCode; + // set a callback so we can see if the cursor position changes as + // a result of this event. If it doesn't, we cycle history. + setTimeout(() => { + if (this.refs.textarea.selectionStart == oldSelectionStart) { + this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1); + this.resizeInput(); + } + }, 0); } if (this.props.tabComplete) { diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index a05c6c30ac..5340798875 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -21,6 +21,12 @@ var sdk = require('../../../index'); var dis = require("../../../dispatcher"); var MatrixClientPeg = require('../../../MatrixClientPeg'); +var linkify = require('linkifyjs'); +var linkifyElement = require('linkifyjs/element'); +var linkifyMatrix = require('../../../linkify-matrix'); + +linkifyMatrix(linkify); + module.exports = React.createClass({ displayName: 'RoomHeader', @@ -41,6 +47,25 @@ module.exports = React.createClass({ }; }, + componentWillReceiveProps: function(newProps) { + if (newProps.editing) { + var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); + var name = this.props.room.currentState.getStateEvents('m.room.name', ''); + + this.setState({ + name: name ? name.getContent().name : '', + defaultName: this.props.room.getDefaultRoomName(MatrixClientPeg.get().credentials.userId), + topic: topic ? topic.getContent().topic : '', + }); + } + }, + + componentDidUpdate: function() { + if (this.refs.topic) { + linkifyElement(this.refs.topic, linkifyMatrix.options); + } + }, + onVideoClick: function(e) { dis.dispatch({ action: 'place_call', @@ -57,26 +82,59 @@ module.exports = React.createClass({ }); }, - onNameChange: function(new_name) { - if (this.props.room.name != new_name && new_name) { - MatrixClientPeg.get().setRoomName(this.props.room.roomId, new_name); + onNameChanged: function(value) { + this.setState({ name : value }); + }, + + onTopicChanged: function(value) { + this.setState({ topic : value }); + }, + + onAvatarPickerClick: function(ev) { + if (this.refs.file_label) { + this.refs.file_label.click(); } }, + onAvatarSelected: function(ev) { + var self = this; + var changeAvatar = this.refs.changeAvatar; + if (!changeAvatar) { + console.error("No ChangeAvatar found to upload image to!"); + return; + } + changeAvatar.onFileSelected(ev).done(function() { + // dunno if the avatar changed, re-check it. + self._refreshFromServer(); + }, function(err) { + var errMsg = (typeof err === "string") ? err : (err.error || ""); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Error", + description: "Failed to set avatar. " + errMsg + }); + }); + }, + getRoomName: function() { - return this.refs.name_edit.value; + return this.state.name; + }, + + getTopic: function() { + return this.state.topic; }, render: function() { var EditableText = sdk.getComponent("elements.EditableText"); - var RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); + var RoomAvatar = sdk.getComponent("avatars.RoomAvatar"); + var ChangeAvatar = sdk.getComponent("settings.ChangeAvatar"); var TintableSvg = sdk.getComponent("elements.TintableSvg"); var header; if (this.props.simpleHeader) { var cancel; if (this.props.onCancelClick) { - cancel = Close + cancel = Close } header =
@@ -87,27 +145,72 @@ module.exports = React.createClass({
} else { - var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); - var name = null; var searchStatus = null; var topic_el = null; var cancel_button = null; var save_button = null; var settings_button = null; - var actual_name = this.props.room.currentState.getStateEvents('m.room.name', ''); - if (actual_name) actual_name = actual_name.getContent().name; if (this.props.editing) { - name = -
- -
- // if (topic) topic_el =
- cancel_button =
Cancel
- save_button =
Save Changes
- } else { - // + // calculate permissions. XXX: this should be done on mount or something, and factored out with RoomSettings + var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', ''); + var events_levels = (power_levels ? power_levels.events : {}) || {}; + var user_id = MatrixClientPeg.get().credentials.userId; + + if (power_levels) { + power_levels = power_levels.getContent(); + var default_user_level = parseInt(power_levels.users_default || 0); + var user_levels = power_levels.users || {}; + var current_user_level = user_levels[user_id]; + if (current_user_level == undefined) current_user_level = default_user_level; + } else { + var default_user_level = 0; + var user_levels = []; + var current_user_level = 0; + } + var state_default = parseInt((power_levels ? power_levels.state_default : 0) || 0); + + var room_avatar_level = state_default; + if (events_levels['m.room.avatar'] !== undefined) { + room_avatar_level = events_levels['m.room.avatar']; + } + var can_set_room_avatar = current_user_level >= room_avatar_level; + + var room_name_level = state_default; + if (events_levels['m.room.name'] !== undefined) { + room_name_level = events_levels['m.room.name']; + } + var can_set_room_name = current_user_level >= room_name_level; + + var room_topic_level = state_default; + if (events_levels['m.room.topic'] !== undefined) { + room_topic_level = events_levels['m.room.topic']; + } + var can_set_room_topic = current_user_level >= room_topic_level; + + var placeholderName = "Unnamed Room"; + if (this.state.defaultName && this.state.defaultName !== '?') { + placeholderName += " (" + this.state.defaultName + ")"; + } + + save_button =
Save
+ cancel_button =
Cancel
+ } + + if (can_set_room_name) { + name = +
+ +
+ } + else { var searchStatus; // don't display the search count until the search completes and // gives us a valid (possibly zero) searchCount. @@ -116,21 +219,55 @@ module.exports = React.createClass({ } name = -
+
{ this.props.room.name }
{ searchStatus }
- if (topic) topic_el =
{ topic.getContent().topic }
; + } + + if (can_set_room_topic) { + topic_el = + + } else { + var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); + if (topic) topic_el =
{ topic.getContent().topic }
; } var roomAvatar = null; if (this.props.room) { - roomAvatar = ( - - ); + if (can_set_room_avatar) { + roomAvatar = ( +
+
+ +
+
+ + +
+
+ ); + } + else { + roomAvatar = ( +
+ +
+ ); + } } var leave_button; @@ -149,9 +286,21 @@ module.exports = React.createClass({
; } + var right_row; + if (!this.props.editing) { + right_row = +
+ { forget_button } + { leave_button } +
+ +
+
; + } + header =
-
+
{ roomAvatar }
@@ -160,20 +309,14 @@ module.exports = React.createClass({ { topic_el }
- {cancel_button} {save_button} -
- { forget_button } - { leave_button } -
- -
-
+ {cancel_button} + {right_row}
} return ( -
+
{ header }
); diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index c5e37521c5..284bee41c2 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -18,6 +18,7 @@ var React = require('react'); var MatrixClientPeg = require('../../../MatrixClientPeg'); var Tinter = require('../../../Tinter'); var sdk = require('../../../index'); +var Modal = require('../../../Modal'); var room_colors = [ // magic room default values courtesy of Ribot @@ -38,6 +39,16 @@ module.exports = React.createClass({ propTypes: { room: React.PropTypes.object.isRequired, + onSaveClick: React.PropTypes.func, + onCancelClick: React.PropTypes.func, + }, + + componentDidMount: function() { + // XXX: dirty hack to gutwrench to focus on the invite box + if (this.props.room.getJoinedMembers().length == 1) { + var inviteBox = document.getElementById("mx_MemberList_invite"); + if (inviteBox) setTimeout(function() { inviteBox.focus(); }, 0); + } }, getInitialState: function() { @@ -68,13 +79,35 @@ module.exports = React.createClass({ room_color_index = 0; } + // get the aliases + var aliases = {}; + var domain = MatrixClientPeg.get().getDomain(); + var alias_events = this.props.room.currentState.getStateEvents('m.room.aliases'); + for (var i = 0; i < alias_events.length; i++) { + aliases[alias_events[i].getStateKey()] = alias_events[i].getContent().aliases.slice(); // shallow copy + } + aliases[domain] = aliases[domain] || []; + + var tags = {}; + Object.keys(this.props.room.tags).forEach(function(tagName) { + tags[tagName] = {}; + }); + return { power_levels_changed: false, color_scheme_changed: false, color_scheme_index: room_color_index, + aliases_changed: false, + aliases: aliases, + tags_changed: false, + tags: tags, }; }, + resetState: function() { + this.set.state(this.getInitialState()); + }, + canGuestsJoin: function() { return this.refs.guests_join.checked; }, @@ -84,7 +117,7 @@ module.exports = React.createClass({ }, getTopic: function() { - return this.refs.topic.value; + return this.refs.topic ? this.refs.topic.value : ""; }, getJoinRules: function() { @@ -95,6 +128,10 @@ module.exports = React.createClass({ return this.refs.share_history.checked ? "shared" : "invited"; }, + areNotificationsMuted: function() { + return this.refs.are_notifications_muted.checked; + }, + getPowerLevels: function() { if (!this.state.power_levels_changed) return undefined; @@ -102,13 +139,13 @@ module.exports = React.createClass({ power_levels = power_levels.getContent(); var new_power_levels = { - ban: parseInt(this.refs.ban.value), - kick: parseInt(this.refs.kick.value), - redact: parseInt(this.refs.redact.value), - invite: parseInt(this.refs.invite.value), - events_default: parseInt(this.refs.events_default.value), - state_default: parseInt(this.refs.state_default.value), - users_default: parseInt(this.refs.users_default.value), + ban: parseInt(this.refs.ban.getValue()), + kick: parseInt(this.refs.kick.getValue()), + redact: parseInt(this.refs.redact.getValue()), + invite: parseInt(this.refs.invite.getValue()), + events_default: parseInt(this.refs.events_default.getValue()), + state_default: parseInt(this.refs.state_default.getValue()), + users_default: parseInt(this.refs.users_default.getValue()), users: power_levels.users, events: power_levels.events, }; @@ -116,6 +153,112 @@ module.exports = React.createClass({ return new_power_levels; }, + getCanonicalAlias: function() { + return this.refs.canonical_alias ? this.refs.canonical_alias.value : ""; + }, + + getAliasOperations: function() { + if (!this.state.aliases_changed) return undefined; + + // work out the delta from room state to UI state + var ops = []; + + // calculate original ("old") aliases + var oldAliases = {}; + var aliases = this.state.aliases; + var alias_events = this.props.room.currentState.getStateEvents('m.room.aliases'); + for (var i = 0; i < alias_events.length; i++) { + var domain = alias_events[i].getStateKey(); + oldAliases[domain] = alias_events[i].getContent().aliases.slice(); // shallow copy + } + + // FIXME: this whole delta-based set comparison function used for domains, aliases & tags + // should be factored out asap rather than duplicated like this. + + // work out whether any domains have entirely disappeared or appeared + var domainDelta = {} + Object.keys(oldAliases).forEach(function(domain) { + domainDelta[domain] = domainDelta[domain] || 0; + domainDelta[domain]--; + }); + Object.keys(aliases).forEach(function(domain) { + domainDelta[domain] = domainDelta[domain] || 0; + domainDelta[domain]++; + }); + + Object.keys(domainDelta).forEach(function(domain) { + switch (domainDelta[domain]) { + case 1: // entirely new domain + aliases[domain].forEach(function(alias) { + ops.push({ type: "put", alias : alias }); + }); + break; + case -1: // entirely removed domain + oldAliases[domain].forEach(function(alias) { + ops.push({ type: "delete", alias : alias }); + }); + break; + case 0: // mix of aliases in this domain. + // compare old & new aliases for this domain + var delta = {}; + oldAliases[domain].forEach(function(item) { + delta[item] = delta[item] || 0; + delta[item]--; + }); + aliases[domain].forEach(function(item) { + delta[item] = delta[item] || 0; + delta[item]++; + }); + + Object.keys(delta).forEach(function(alias) { + if (delta[alias] == 1) { + ops.push({ type: "put", alias: alias }); + } else if (delta[alias] == -1) { + ops.push({ type: "delete", alias: alias }); + } else { + console.error("Calculated alias delta of " + delta[alias] + + " - this should never happen!"); + } + }); + break; + default: + console.error("Calculated domain delta of " + domainDelta[domain] + + " - this should never happen!"); + break; + } + }); + + return ops; + }, + + getTagOperations: function() { + if (!this.state.tags_changed) return undefined; + + var ops = []; + + var delta = {}; + Object.keys(this.props.room.tags).forEach(function(oldTag) { + delta[oldTag] = delta[oldTag] || 0; + delta[oldTag]--; + }); + Object.keys(this.state.tags).forEach(function(newTag) { + delta[newTag] = delta[newTag] || 0; + delta[newTag]++; + }); + Object.keys(delta).forEach(function(tag) { + if (delta[tag] == 1) { + ops.push({ type: "put", tag: tag }); + } else if (delta[tag] == -1) { + ops.push({ type: "delete", tag: tag }); + } else { + console.error("Calculated tag delta of " + delta[tag] + + " - this should never happen!"); + } + }); + + return ops; + }, + onPowerLevelsChanged: function() { this.setState({ power_levels_changed: true @@ -141,11 +284,100 @@ module.exports = React.createClass({ }); }, - render: function() { - var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); + onAliasChanged: function(domain, index, alias) { + if (alias === "") return; // hit the delete button to delete please + var oldAlias; + if (this.isAliasValid(alias)) { + oldAlias = this.state.aliases[domain][index]; + this.state.aliases[domain][index] = alias; + this.setState({ aliases_changed : true }); + } + else { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Invalid alias format", + description: "'" + alias + "' is not a valid format for an alias", + }); + } + }, - var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); - if (topic) topic = topic.getContent().topic; + onAliasDeleted: function(domain, index) { + // It's a bit naughty to directly manipulate this.state, and React would + // normally whine at you, but it can't see us doing the splice. Given we + // promptly setState anyway, it's just about acceptable. The alternative + // would be to arbitrarily deepcopy to a temp variable and then setState + // that, but why bother when we can cut this corner. + var alias = this.state.aliases[domain].splice(index, 1); + this.setState({ + aliases: this.state.aliases + }); + + this.setState({ aliases_changed : true }); + }, + + onAliasAdded: function(alias) { + if (alias === "") return; // ignore attempts to create blank aliases + if (alias === undefined) { + alias = this.refs.add_alias ? this.refs.add_alias.getValue() : undefined; + if (alias === undefined || alias === "") return; + } + + if (this.isAliasValid(alias)) { + var domain = alias.replace(/^.*?:/, ''); + // XXX: do we need to deep copy aliases before editing it? + this.state.aliases[domain] = this.state.aliases[domain] || []; + this.state.aliases[domain].push(alias); + this.setState({ + aliases: this.state.aliases + }); + + // reset the add field + this.refs.add_alias.setValue(''); + + this.setState({ aliases_changed : true }); + } + else { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Invalid alias format", + description: "'" + alias + "' is not a valid format for an alias", + }); + } + }, + + isAliasValid: function(alias) { + // XXX: FIXME SPEC-1 + return (alias.match(/^#([^\/:,]+?):(.+)$/) && encodeURI(alias) === alias); + }, + + onTagChange: function(tagName, event) { + if (event.target.checked) { + if (tagName === 'm.favourite') { + delete this.state.tags['m.lowpriority']; + } + else if (tagName === 'm.lowpriority') { + delete this.state.tags['m.favourite']; + } + + this.state.tags[tagName] = this.state.tags[tagName] || {}; + } + else { + delete this.state.tags[tagName]; + } + + // XXX: hacky say to deep-edit state + this.setState({ + tags: this.state.tags, + tags_changed: true + }); + }, + + render: function() { + // TODO: go through greying out things you don't have permission to change + // (or turning them into informative stuff) + + var EditableText = sdk.getComponent('elements.EditableText'); + var PowerSelector = sdk.getComponent('elements.PowerSelector'); var join_rule = this.props.room.currentState.getStateEvents('m.room.join_rules', ''); if (join_rule) join_rule = join_rule.getContent().join_rule; @@ -159,7 +391,18 @@ module.exports = React.createClass({ guest_access = guest_access.getContent().guest_access; } - var events_levels = power_levels.events || {}; + var are_notifications_muted; + var roomPushRule = MatrixClientPeg.get().getRoomPushRule("global", this.props.room.roomId); + if (roomPushRule) { + if (0 <= roomPushRule.actions.indexOf("dont_notify")) { + are_notifications_muted = true; + } + } + + var events_levels = (power_levels ? power_levels.events : {}) || {}; + + var user_id = MatrixClientPeg.get().credentials.userId; + if (power_levels) { power_levels = power_levels.getContent(); @@ -178,8 +421,6 @@ module.exports = React.createClass({ var user_levels = power_levels.users || {}; - var user_id = MatrixClientPeg.get().credentials.userId; - var current_user_level = user_levels[user_id]; if (current_user_level == undefined) current_user_level = default_user_level; @@ -208,14 +449,127 @@ module.exports = React.createClass({ var can_change_levels = false; } - var room_avatar_level = parseInt(power_levels.state_default || 0); - if (events_levels['m.room.avatar'] !== undefined) { - room_avatar_level = events_levels['m.room.avatar']; + var state_default = (parseInt(power_levels ? power_levels.state_default : 0) || 0); + + var room_aliases_level = state_default; + if (events_levels['m.room.aliases'] !== undefined) { + room_avatar_level = events_levels['m.room.aliases']; } - var can_set_room_avatar = current_user_level >= room_avatar_level; + var can_set_room_aliases = current_user_level >= room_aliases_level; + + var canonical_alias_level = state_default; + if (events_levels['m.room.canonical_alias'] !== undefined) { + room_avatar_level = events_levels['m.room.canonical_alias']; + } + var can_set_canonical_alias = current_user_level >= canonical_alias_level; + + var tag_level = state_default; + if (events_levels['m.tag'] !== undefined) { + tag_level = events_levels['m.tag']; + } + var can_set_tag = current_user_level >= tag_level; var self = this; + var canonical_alias_event = this.props.room.currentState.getStateEvents('m.room.canonical_alias', ''); + var canonical_alias = canonical_alias_event ? canonical_alias_event.getContent().alias : ""; + var domain = MatrixClientPeg.get().getDomain(); + + var remote_domains = Object.keys(this.state.aliases).filter(function(alias) { return alias !== domain }); + + var remote_aliases_section; + if (remote_domains.length) { + remote_aliases_section = +
+
+ This room can be found elsewhere as: +
+
+ { remote_domains.map(function(state_key, i) { + self.state.aliases[state_key].map(function(alias, j) { + return ( +
+ +
+
+
+ ); + }); + })} +
+
+ } + + var canonical_alias_section; + if (can_set_canonical_alias) { + canonical_alias_section = + + } + else { + canonical_alias_section = { canonical_alias || "not set" }; + } + + var aliases_section = +
+

Directory

+
+ { this.state.aliases[domain].length + ? "This room can be found on " + domain + " as:" + : "This room is not findable on " + domain } +
+
+ { this.state.aliases[domain].map(function(alias, i) { + var deleteButton; + if (can_set_room_aliases) { + deleteButton = Delete; + } + return ( +
+ +
+ { deleteButton } +
+
+ ); + })} + +
+ +
+ Add +
+
+
+ + { remote_aliases_section } + +
The official way to refer to this room is: { canonical_alias_section }
+
; + var room_colors_section =

Room Colour

@@ -242,35 +596,30 @@ module.exports = React.createClass({
; - var change_avatar; - if (can_set_room_avatar) { - change_avatar = + var user_levels_section; + if (user_levels.length) { + user_levels_section =
-

Room Icon

- -
; - } - - var banned = this.props.room.getMembersWithMembership("ban"); - - var events_levels_section; - if (events_levels.length) { - events_levels_section = -
-

Event levels

-
- {Object.keys(events_levels).map(function(event_type, i) { +
+ Users with specific roles are: +
+
+ {Object.keys(user_levels).map(function(user, i) { return ( -
- - +
+ { user } is a +
); })}
; } + else { + user_levels_section =
No users have specific privileges in this room.
+ } + var banned = this.props.room.getMembersWithMembership("ban"); var banned_users_section; if (banned.length) { banned_users_section = @@ -288,79 +637,120 @@ module.exports = React.createClass({
; } + var create_event = this.props.room.currentState.getStateEvents('m.room.create', ''); + var unfederatable_section; + if (create_event.getContent()["m.federate"] === false) { + unfederatable_section =
Ths room is not accessible by remote Matrix servers.
+ } + + // TODO: support editing custom events_levels + // TODO: support editing custom user_levels + + var tags = [ + { name: "m.favourite", label: "Favourite", ref: "tag_favourite" }, + { name: "m.lowpriority", label: "Low priority", ref: "tag_lowpriority" }, + ]; + + Object.keys(this.state.tags).sort().forEach(function(tagName) { + if (tagName !== 'm.favourite' && tagName !== 'm.lowpriority') { + tags.push({ name: tagName, label: tagName }); + } + }); + + var tags_section = +
+ This room is tagged as + { can_set_tag ? + tags.map(function(tag, i) { + return (); + }) : tags.map(function(tag) { return tag.label; }).join(", ") + } +
+ return (
-