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 ( +
+

Devices

+ +
+ ); + }, + + _renderLabs: function () { + let features = LABS_FEATURES.map(feature => ( +
+ { + UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked); + this.forceUpdate(); + }}/> + +
+ )); + return ( +
+

Labs

+
+

These are experimental features that may break in unexpected ways. Use with caution.

+ {features} +
+
+ ) + }, + 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({ ); } - this._renderLabs = function () { - let features = LABS_FEATURES.map(feature => ( -
- UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked)} /> - -
- )); - return ( -
-

Labs

- -
-

These are experimental features that may break in unexpected ways. Use with caution.

- {features} -
-
- ) - }; - return (
@@ -510,10 +526,9 @@ module.exports = React.createClass({ {notification_area} {this._renderUserInterfaceSettings()} - - {this._renderDeviceInfo()} - {this._renderLabs()} + {this._renderDevicesPanel()} + {this._renderCryptoInfo()}

Advanced

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 ( + {this._onDeviceDeleted(device)}} /> + ); + } + + render() { + const Spinner = sdk.getComponent("elements.Spinner"); + + if (this.state.deviceLoadError !== undefined) { + const classes = classNames(this.props.className, "error"); + return ( +
+ {this.state.deviceLoadError} +
+ ); + } + + const devices = this.state.devices; + if (devices === undefined) { + // still loading + const classes = this.props.className; + return ; + } + + devices.sort(this._deviceCompare); + + const classes = classNames(this.props.className, "mx_DevicesPanel"); + return ( +
+
+
Name
+
Last seen
+
+
+ {devices.map(this._renderDevice)} +
+ ); + } +} 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 ( +
+ +
+ ); + } + + 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 =
{this.state.deleteError}
+ } else { + deleteButton = ( +
+ Delete +
+ ); + } + + return ( +
+
+ +
+
+ {lastSeen} +
+
+ {deleteButton} +
+
+ ); + } +} + +DevicesPanelEntry.propTypes = { + device: React.PropTypes.object.isRequired, + onDeleted: React.PropTypes.func, +}; + +DevicesPanelEntry.defaultProps = { + onDeleted: function() {}, +};