diff --git a/src/components/views/settings/DevicesPanel.js b/src/components/views/settings/DevicesPanel.js index 1484dd98ba..ba78f52a4f 100644 --- a/src/components/views/settings/DevicesPanel.js +++ b/src/components/views/settings/DevicesPanel.js @@ -20,7 +20,9 @@ import classNames from 'classnames'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; +import Modal from '../../../Modal'; +const AUTH_CACHE_AGE = 5 * 60 * 1000; // 5 minutes export default class DevicesPanel extends React.Component { constructor(props, context) { @@ -29,11 +31,16 @@ export default class DevicesPanel extends React.Component { this.state = { devices: undefined, deviceLoadError: undefined, + + selectedDevices: [], + deleting: false, }; this._unmounted = false; this._renderDevice = this._renderDevice.bind(this); + this._onDeviceSelectionToggled = this._onDeviceSelectionToggled.bind(this); + this._onDeleteClick = this._onDeleteClick.bind(this); } componentDidMount() { @@ -82,25 +89,85 @@ export default class DevicesPanel extends React.Component { return (idA < idB) ? -1 : (idA > idB) ? 1 : 0; } - _onDeviceDeleted(device) { + _onDeviceSelectionToggled(device) { if (this._unmounted) { return; } - // delete the removed device from our list. - const removed_id = device.device_id; + const deviceId = device.device_id; this.setState((state, props) => { - const newDevices = state.devices.filter( - (d) => { return d.device_id != removed_id; }, - ); - return { devices: newDevices }; + // Make a copy of the selected devices, then add or remove the device + const selectedDevices = state.selectedDevices.slice(); + + const i = selectedDevices.indexOf(deviceId); + if (i === -1) { + selectedDevices.push(deviceId); + } else { + selectedDevices.splice(i, 1); + } + + return {selectedDevices}; }); } + _onDeleteClick() { + if (this.context.authCache.lastUpdate < Date.now() - AUTH_CACHE_AGE) { + this.context.authCache.auth = null; + } + + this.setState({ + deleting: true, + }); + + // try with auth cache (which is null, so no interactive auth, to start off) + this._makeDeleteRequest(this.context.authCache.auth).catch((error) => { + if (this._unmounted) { return; } + if (error.httpStatus !== 401 || !error.data || !error.data.flows) { + // doesn't look like an interactive-auth failure + throw error; + } + + // pop up an interactive auth dialog + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + + Modal.createTrackedDialog('Delete Device Dialog', '', InteractiveAuthDialog, { + title: _t("Authentication"), + matrixClient: MatrixClientPeg.get(), + authData: error.data, + makeRequest: this._makeDeleteRequest.bind(this), + }); + }).catch((e) => { + console.error("Error deleting devices", e); + if (this._unmounted) { return; } + }).finally(() => { + this.setState({ + deleting: false, + }); + }); + } + + _makeDeleteRequest(auth) { + this.context.authCache.auth = auth; + this.context.authCache.lastUpdate = Date.now(); + return MatrixClientPeg.get().deleteMultipleDevices(this.state.selectedDevices, auth).then( + () => { + // Remove the deleted devices from `devices`, reset selection to [] + this.setState({ + devices: this.state.devices.filter( + (d) => !this.state.selectedDevices.includes(d.device_id) + ), + selectedDevices: [], + }); + }, + ); + } + _renderDevice(device) { const DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry'); - return ( - {this._onDeviceDeleted(device);}} /> - ); + return ; } render() { @@ -124,6 +191,12 @@ export default class DevicesPanel extends React.Component { devices.sort(this._deviceCompare); + const deleteButton = this.state.deleting ? + : +
+ { _t("Delete %(count)s devices", {count: this.state.selectedDevices.length}) } +
; + const classes = classNames(this.props.className, "mx_DevicesPanel"); return (
@@ -131,7 +204,9 @@ export default class DevicesPanel extends React.Component {
{ _t("Device ID") }
{ _t("Device Name") }
{ _t("Last seen") }
-
+
+ { this.state.selectedDevices.length > 0 ? deleteButton : _t('Select devices') } +
{ devices.map(this._renderDevice) } @@ -143,3 +218,6 @@ DevicesPanel.displayName = 'MemberDeviceInfo'; DevicesPanel.propTypes = { className: React.PropTypes.string, }; +DevicesPanel.contextTypes = { + authCache: React.PropTypes.object, +}; diff --git a/src/components/views/settings/DevicesPanelEntry.js b/src/components/views/settings/DevicesPanelEntry.js index b8bef2649e..e5f9dcac46 100644 --- a/src/components/views/settings/DevicesPanelEntry.js +++ b/src/components/views/settings/DevicesPanelEntry.js @@ -19,24 +19,15 @@ import React from 'react'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import Modal from '../../../Modal'; import DateUtils from '../../../DateUtils'; -const AUTH_CACHE_AGE = 5 * 60 * 1000; // 5 minutes - 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.onDeviceToggled = this.onDeviceToggled.bind(this); this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this); - this._makeDeleteRequest = this._makeDeleteRequest.bind(this); } componentWillUnmount() { @@ -53,56 +44,8 @@ export default class DevicesPanelEntry extends React.Component { }); } - _onDeleteClick() { - this.setState({deleting: true}); - - if (this.context.authCache.lastUpdate < Date.now() - AUTH_CACHE_AGE) { - this.context.authCache.auth = null; - } - - // try with auth cache (which is null, so no interactive auth, to start off) - this._makeDeleteRequest(this.context.authCache.auth).catch((error) => { - if (this._unmounted) { return; } - if (error.httpStatus !== 401 || !error.data || !error.data.flows) { - // doesn't look like an interactive-auth failure - throw error; - } - - // pop up an interactive auth dialog - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - - Modal.createTrackedDialog('Delete Device Dialog', '', InteractiveAuthDialog, { - title: _t("Authentication"), - matrixClient: MatrixClientPeg.get(), - authData: error.data, - makeRequest: this._makeDeleteRequest, - }); - - this.setState({ - deleting: false, - }); - }).catch((e) => { - console.error("Error deleting device", e); - if (this._unmounted) { return; } - this.setState({ - deleting: false, - deleteError: _t("Failed to delete device"), - }); - }).done(); - } - - _makeDeleteRequest(auth) { - this.context.authCache.auth = auth; - this.context.authCache.lastUpdate = Date.now(); - - const device = this.props.device; - return MatrixClientPeg.get().deleteDevice(device.device_id, auth).then( - () => { - this.props.onDeleted(); - if (this._unmounted) { return; } - this.setState({ deleting: false }); - }, - ); + onDeviceToggled() { + this.props.onDeviceToggled(this.props.device); } render() { @@ -110,16 +53,6 @@ export default class DevicesPanelEntry extends React.Component { const device = this.props.device; - if (this.state.deleting) { - const Spinner = sdk.getComponent("elements.Spinner"); - - return ( -
- -
- ); - } - let lastSeen = ""; if (device.last_seen_ts) { const lastSeenDate = DateUtils.formatDate(new Date(device.last_seen_ts)); @@ -127,18 +60,6 @@ export default class DevicesPanelEntry extends React.Component { lastSeenDate.toLocaleString(); } - let deleteButton; - if (this.state.deleteError) { - deleteButton =
{ this.state.deleteError }
; - } else { - deleteButton = ( -
- { _t("Delete") } -
- ); - } - let myDeviceClass = ''; if (device.device_id === MatrixClientPeg.get().getDeviceId()) { myDeviceClass = " mx_DevicesPanel_myDevice"; @@ -159,7 +80,7 @@ export default class DevicesPanelEntry extends React.Component { { lastSeen }
- { deleteButton } +
); @@ -168,13 +89,9 @@ export default class DevicesPanelEntry extends React.Component { DevicesPanelEntry.propTypes = { device: React.PropTypes.object.isRequired, - onDeleted: React.PropTypes.func, -}; - -DevicesPanelEntry.contextTypes = { - authCache: React.PropTypes.object, + onDeviceToggled: React.PropTypes.func, }; DevicesPanelEntry.defaultProps = { - onDeleted: function() {}, + onDeviceToggled: function() {}, }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8f672fb653..f8aed3a416 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -218,13 +218,15 @@ "Change Password": "Change Password", "Your home server does not support device management.": "Your home server does not support device management.", "Unable to load device list": "Unable to load device list", + "Authentication": "Authentication", + "Failed to delete device": "Failed to delete device", + "Delete %(count)s devices|one": "Delete device", + "Delete %(count)s devices|other": "Delete %(count)s devices", "Device ID": "Device ID", "Device Name": "Device Name", "Last seen": "Last seen", + "Select devices": "Select devices", "Failed to set display name": "Failed to set display name", - "Authentication": "Authentication", - "Failed to delete device": "Failed to delete device", - "Delete": "Delete", "Disable Notifications": "Disable Notifications", "Enable Notifications": "Enable Notifications", "Cannot add any more widgets": "Cannot add any more widgets", @@ -537,7 +539,6 @@ "Something went wrong when trying to get your communities.": "Something went wrong when trying to get your communities.", "Display your community flair in rooms configured to show it.": "Display your community flair in rooms configured to show it.", "You're not currently a member of any communities.": "You're not currently a member of any communities.", - "Flair": "Flair", "Unknown Address": "Unknown Address", "NOTE: Apps are not end-to-end encrypted": "NOTE: Apps are not end-to-end encrypted", "Do you want to load widget from URL:": "Do you want to load widget from URL:", @@ -553,6 +554,7 @@ "Unverify": "Unverify", "Verify...": "Verify...", "No results": "No results", + "Delete": "Delete", "Communities": "Communities", "Home": "Home", "Integrations Error": "Integrations Error", @@ -870,10 +872,10 @@ "Please note you are logging into the %(hs)s server, not matrix.org.": "Please note you are logging into the %(hs)s server, not matrix.org.", "Guest access is disabled on this Home Server.": "Guest access is disabled on this Home Server.", "The phone number entered looks invalid": "The phone number entered looks invalid", + "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.", "Error: Problem communicating with the given homeserver.": "Error: Problem communicating with the given homeserver.", "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.", "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.", - "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.", "Login as guest": "Login as guest", "Sign in to get started": "Sign in to get started", "Failed to fetch avatar URL": "Failed to fetch avatar URL",