Merge pull request #370 from matrix-org/rav/devices_panel
Implement device management UI
This commit is contained in:
commit
215b32b377
4 changed files with 317 additions and 28 deletions
|
@ -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');
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
138
src/components/views/settings/DevicesPanel.js
Normal file
138
src/components/views/settings/DevicesPanel.js
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
134
src/components/views/settings/DevicesPanelEntry.js
Normal file
134
src/components/views/settings/DevicesPanelEntry.js
Normal file
|
@ -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() {},
|
||||
};
|
Loading…
Reference in a new issue