diff --git a/src/component-index.js b/src/component-index.js index e250838cc4..19a016aec8 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -107,6 +107,8 @@ module.exports.components['views.rooms.UserTile'] = require('./components/views/ module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar'); module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName'); module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword'); +module.exports.components['views.settings.DevicesPanel'] = require('./components/views/settings/DevicesPanel'); +module.exports.components['views.settings.DevicesPanelEntry'] = require('./components/views/settings/DevicesPanelEntry'); module.exports.components['views.settings.EnableNotificationsButton'] = require('./components/views/settings/EnableNotificationsButton'); module.exports.components['views.voip.CallView'] = require('./components/views/voip/CallView'); module.exports.components['views.voip.IncomingCallBox'] = require('./components/views/voip/IncomingCallBox'); diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 75fe1f0825..6555668ff4 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -319,7 +319,7 @@ module.exports = React.createClass({ ); }, - _renderDeviceInfo: function() { + _renderCryptoInfo: function() { if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { return null; } @@ -340,6 +340,45 @@ module.exports = React.createClass({ ); }, + _renderDevicesPanel: function() { + if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { + return null; + } + var DevicesPanel = sdk.getComponent('settings.DevicesPanel'); + return ( + <div> + <h3>Devices</h3> + <DevicesPanel className="mx_UserSettings_section" /> + </div> + ); + }, + + _renderLabs: function () { + let features = LABS_FEATURES.map(feature => ( + <div key={feature.id} className="mx_UserSettings_toggle"> + <input + type="checkbox" + id={feature.id} + name={feature.id} + defaultChecked={UserSettingsStore.isFeatureEnabled(feature.id)} + onChange={e => { + UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked); + this.forceUpdate(); + }}/> + <label htmlFor={feature.id}>{feature.name}</label> + </div> + )); + return ( + <div> + <h3>Labs</h3> + <div className="mx_UserSettings_section"> + <p>These are experimental features that may break in unexpected ways. Use with caution.</p> + {features} + </div> + </div> + ) + }, + render: function() { var self = this; var Loader = sdk.getComponent("elements.Spinner"); @@ -360,6 +399,7 @@ module.exports = React.createClass({ var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); var Notifications = sdk.getComponent("settings.Notifications"); var EditableText = sdk.getComponent('elements.EditableText'); + var avatarUrl = ( this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null ); @@ -434,30 +474,6 @@ module.exports = React.createClass({ </div>); } - this._renderLabs = function () { - let features = LABS_FEATURES.map(feature => ( - <div key={feature.id} className="mx_UserSettings_toggle"> - <input - type="checkbox" - id={feature.id} - name={feature.id} - defaultChecked={UserSettingsStore.isFeatureEnabled(feature.id)} - onChange={e => UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked)} /> - <label htmlFor={feature.id}>{feature.name}</label> - </div> - )); - return ( - <div> - <h3>Labs</h3> - - <div className="mx_UserSettings_section"> - <p>These are experimental features that may break in unexpected ways. Use with caution.</p> - {features} - </div> - </div> - ) - }; - return ( <div className="mx_UserSettings"> <SimpleRoomHeader title="Settings" onCancelClick={ this.props.onClose }/> @@ -510,10 +526,9 @@ module.exports = React.createClass({ {notification_area} {this._renderUserInterfaceSettings()} - - {this._renderDeviceInfo()} - {this._renderLabs()} + {this._renderDevicesPanel()} + {this._renderCryptoInfo()} <h3>Advanced</h3> diff --git a/src/components/views/settings/DevicesPanel.js b/src/components/views/settings/DevicesPanel.js new file mode 100644 index 0000000000..8dd6bb9230 --- /dev/null +++ b/src/components/views/settings/DevicesPanel.js @@ -0,0 +1,138 @@ +/* +Copyright 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. +*/ + +import React from 'react'; +import classNames from 'classnames'; + +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; + + +export default class DevicesPanel extends React.Component { + constructor(props, context) { + super(props, context); + + this.state = { + devices: undefined, + deviceLoadError: undefined, + }; + + this._unmounted = false; + + this._renderDevice = this._renderDevice.bind(this); + } + + componentDidMount() { + this._loadDevices(); + } + + componentWillUnmount() { + this._unmounted = true; + } + + _loadDevices() { + MatrixClientPeg.get().getDevices().done( + (resp) => { + if (this._unmounted) { return; } + this.setState({devices: resp.devices || []}); + }, + (error) => { + if (this._unmounted) { return; } + var errtxt; + if (err.httpStatus == 404) { + // 404 probably means the HS doesn't yet support the API. + errtxt = "Your home server does not support device management."; + } else { + console.error("Error loading devices:", error); + errtxt = "Unable to load device list."; + } + this.setState({deviceLoadError: errtxt}); + } + ); + } + + + /** + * compare two devices, sorting from most-recently-seen to least-recently-seen + * (and then, for stability, by device id) + */ + _deviceCompare(a, b) { + // return < 0 if a comes before b, > 0 if a comes after b. + const lastSeenDelta = + (b.last_seen_ts || 0) - (a.last_seen_ts || 0); + + if (lastSeenDelta !== 0) { return lastSeenDelta; } + + const idA = a.device_id; + const idB = b.device_id; + return (idA < idB) ? -1 : (idA > idB) ? 1 : 0; + } + + _onDeviceDeleted(device) { + if (this._unmounted) { return; } + + // delete the removed device from our list. + const removed_id = device.device_id; + this.setState((state, props) => { + const newDevices = state.devices.filter( + d => { return d.device_id != removed_id } + ); + return { devices: newDevices }; + }); + } + + _renderDevice(device) { + var DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry'); + return ( + <DevicesPanelEntry key={device.device_id} device={device} + onDeleted={()=>{this._onDeviceDeleted(device)}} /> + ); + } + + render() { + const Spinner = sdk.getComponent("elements.Spinner"); + + if (this.state.deviceLoadError !== undefined) { + const classes = classNames(this.props.className, "error"); + return ( + <div className={classes}> + {this.state.deviceLoadError} + </div> + ); + } + + const devices = this.state.devices; + if (devices === undefined) { + // still loading + const classes = this.props.className; + return <Spinner className={classes}/>; + } + + devices.sort(this._deviceCompare); + + const classes = classNames(this.props.className, "mx_DevicesPanel"); + return ( + <div className={classes}> + <div className="mx_DevicesPanel_header"> + <div className="mx_DevicesPanel_deviceName">Name</div> + <div className="mx_DevicesPanel_deviceLastSeen">Last seen</div> + <div className="mx_DevicesPanel_deviceButtons"></div> + </div> + {devices.map(this._renderDevice)} + </div> + ); + } +} diff --git a/src/components/views/settings/DevicesPanelEntry.js b/src/components/views/settings/DevicesPanelEntry.js new file mode 100644 index 0000000000..6858e62102 --- /dev/null +++ b/src/components/views/settings/DevicesPanelEntry.js @@ -0,0 +1,134 @@ +/* +Copyright 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. +*/ + +import React from 'react'; +import classNames from 'classnames'; +import q from 'q'; + +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import DateUtils from '../../../DateUtils'; + +export default class DevicesPanelEntry extends React.Component { + constructor(props, context) { + super(props, context); + + this.state = { + deleting: false, + deleteError: undefined, + }; + + this._unmounted = false; + + this._onDeleteClick = this._onDeleteClick.bind(this); + this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this); + } + + componentWillUnmount() { + this._unmounted = true; + } + + _onDisplayNameChanged(value) { + const device = this.props.device; + return MatrixClientPeg.get().setDeviceDetails(device.device_id, { + display_name: value, + }).catch((e) => { + console.error("Error setting device display name", e); + throw new Error("Failed to set display name"); + }); + } + + _onDeleteClick() { + const device = this.props.device; + this.setState({deleting: true}); + + MatrixClientPeg.get().deleteDevice(device.device_id).done( + () => { + this.props.onDeleted(); + if (this._unmounted) { return; } + this.setState({ deleting: false }); + }, + (e) => { + console.error("Error deleting device", e); + if (this._unmounted) { return; } + this.setState({ + deleting: false, + deleteError: "Failed to delete device", + }); + } + ); + } + + render() { + const EditableTextContainer = sdk.getComponent('elements.EditableTextContainer'); + + const device = this.props.device; + + if (this.state.deleting) { + const Spinner = sdk.getComponent("elements.Spinner"); + + return ( + <div className="mx_DevicesPanel_device"> + <Spinner /> + </div> + ); + } + + let lastSeen = ""; + if (device.last_seen_ts) { + // todo: format the timestamp as "5 minutes ago" or whatever. + const lastSeenDate = new Date(device.last_seen_ts); + lastSeen = device.last_seen_ip + " @ " + + lastSeenDate.toLocaleString(); + } + + let deleteButton; + if (this.state.deleteError) { + deleteButton = <div className="error">{this.state.deleteError}</div> + } else { + deleteButton = ( + <div className="textButton" + onClick={this._onDeleteClick}> + Delete + </div> + ); + } + + return ( + <div className="mx_DevicesPanel_device"> + <div className="mx_DevicesPanel_deviceName"> + <EditableTextContainer initialValue={device.display_name} + onSubmit={this._onDisplayNameChanged} /> + </div> + <div className="mx_DevicesPanel_lastSeen"> + {lastSeen} + </div> + <div className="mx_DevicesPanel_deviceButtons"> + {deleteButton} + </div> + </div> + ); + } +} + +DevicesPanelEntry.propTypes = { + device: React.PropTypes.object.isRequired, + onDeleted: React.PropTypes.func, +}; + +DevicesPanelEntry.defaultProps = { + onDeleted: function() {}, +};