From 0be622e7f0dbcd4e6f282ae9fbf069d4e4766887 Mon Sep 17 00:00:00 2001 From: Kerry Date: Thu, 11 Aug 2022 11:45:35 +0200 Subject: [PATCH] Device manager - other sessions list (PSG-637) (#9155) * add session manager tab to user settings * fussy import ordering * i18n * basic sorted list outline * rename to filtered device list * exclude current device session from other sessions list * test other sessions section --- res/css/_components.pcss | 1 + .../settings/devices/_FilteredDeviceList.pcss | 23 +++++++ .../settings/devices/FilteredDeviceList.tsx | 53 +++++++++++++++ .../views/settings/devices/useOwnDevices.ts | 3 +- .../settings/tabs/user/SessionManagerTab.tsx | 18 ++++- src/i18n/strings/en_EN.json | 2 + .../devices/FilteredDeviceList-test.tsx | 65 +++++++++++++++++++ .../tabs/user/SessionManagerTab-test.tsx | 32 ++++++++- 8 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 res/css/components/views/settings/devices/_FilteredDeviceList.pcss create mode 100644 src/components/views/settings/devices/FilteredDeviceList.tsx create mode 100644 test/components/views/settings/devices/FilteredDeviceList-test.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index f9b3ac059d..cf4a7914ca 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -29,6 +29,7 @@ @import "./components/views/messages/shared/_MediaProcessingError.pcss"; @import "./components/views/settings/devices/_DeviceDetails.pcss"; @import "./components/views/settings/devices/_DeviceTile.pcss"; +@import "./components/views/settings/devices/_FilteredDeviceList.pcss"; @import "./components/views/settings/devices/_SelectableDeviceTile.pcss"; @import "./components/views/settings/shared/_SettingsSubsection.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss"; diff --git a/res/css/components/views/settings/devices/_FilteredDeviceList.pcss b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss new file mode 100644 index 0000000000..307241c78f --- /dev/null +++ b/res/css/components/views/settings/devices/_FilteredDeviceList.pcss @@ -0,0 +1,23 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_FilteredDeviceList { + list-style-type: none; + display: grid; + grid-gap: $spacing-16; + margin: 0; + padding: 0 $spacing-8; +} diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx new file mode 100644 index 0000000000..757f0fda4f --- /dev/null +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -0,0 +1,53 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 DeviceTile from './DeviceTile'; +import { DevicesDictionary, DeviceWithVerification } from './useOwnDevices'; + +interface Props { + devices: DevicesDictionary; +} + +// devices without timestamp metadata should be sorted last +const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) => + (right.last_seen_ts || 0) - (left.last_seen_ts || 0); + +const getSortedDevices = (devices: DevicesDictionary) => + Object.values(devices).sort(sortDevicesByLatestActivity); + +/** + * Filtered list of devices + * Sorted by latest activity descending + * TODO(kerrya) Filtering to added as part of PSG-648 + */ +const FilteredDeviceList: React.FC = ({ devices }) => { + const sortedDevices = getSortedDevices(devices); + + return
    + { sortedDevices.map((device) => +
  1. + +
  2. , + + ) } +
; +}; + +export default FilteredDeviceList; diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index ad9523cc14..f48d35acdd 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -60,8 +60,9 @@ export enum OwnDevicesError { Unsupported = 'Unsupported', Default = 'Default', } +export type DevicesDictionary = Record; type DevicesState = { - devices: Record; + devices: DevicesDictionary; currentDeviceId: string; isLoading: boolean; error?: OwnDevicesError; diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index 7d65ce83da..cba3234cae 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -22,11 +22,14 @@ import { useOwnDevices } from '../../devices/useOwnDevices'; import DeviceTile from '../../devices/DeviceTile'; import SettingsSubsection from '../../shared/SettingsSubsection'; import SettingsTab from '../SettingsTab'; +import FilteredDeviceList from '../../devices/FilteredDeviceList'; const SessionManagerTab: React.FC = () => { const { devices, currentDeviceId, isLoading } = useOwnDevices(); - const currentDevice = devices[currentDeviceId]; + const { [currentDeviceId]: currentDevice, ...otherDevices } = devices; + const shouldShowOtherSessions = Object.keys(otherDevices).length > 0; + return { device={currentDevice} /> } + { + shouldShowOtherSessions && + + + + } ; }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2fe0618900..e97849c9fd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1555,6 +1555,8 @@ "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.", "Sessions": "Sessions", "Current session": "Current session", + "Other sessions": "Other sessions", + "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.", "Sidebar": "Sidebar", "Spaces to show": "Spaces to show", "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.": "Spaces are ways to group rooms and people. Alongside the spaces you're in, you can use some pre-built ones too.", diff --git a/test/components/views/settings/devices/FilteredDeviceList-test.tsx b/test/components/views/settings/devices/FilteredDeviceList-test.tsx new file mode 100644 index 0000000000..3545e0b261 --- /dev/null +++ b/test/components/views/settings/devices/FilteredDeviceList-test.tsx @@ -0,0 +1,65 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +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 { render } from '@testing-library/react'; + +import FilteredDeviceList from '../../../../../src/components/views/settings/devices/FilteredDeviceList'; + +describe('', () => { + const noMetaDevice = { device_id: 'no-meta-device', isVerified: true }; + const oldDevice = { device_id: 'old', last_seen_ts: new Date(1993, 7, 3, 4).getTime(), isVerified: true }; + const newDevice = { + device_id: 'new', + last_seen_ts: new Date().getTime() - 500, + last_seen_ip: '123.456.789', + display_name: 'My Device', + isVerified: true, + }; + const defaultProps = { + devices: { + [noMetaDevice.device_id]: noMetaDevice, + [oldDevice.device_id]: oldDevice, + [newDevice.device_id]: newDevice, + }, + }; + const getComponent = (props = {}) => + (); + + it('renders devices in correct order', () => { + const { container } = render(getComponent()); + const tiles = container.querySelectorAll('.mx_DeviceTile'); + expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`); + expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${oldDevice.device_id}`); + expect(tiles[2].getAttribute('data-testid')).toEqual(`device-tile-${noMetaDevice.device_id}`); + }); + + it('updates list order when devices change', () => { + const updatedOldDevice = { ...oldDevice, last_seen_ts: new Date().getTime() }; + const updatedDevices = { + [oldDevice.device_id]: updatedOldDevice, + [newDevice.device_id]: newDevice, + }; + const { container, rerender } = render(getComponent()); + + rerender(getComponent({ devices: updatedDevices })); + + const tiles = container.querySelectorAll('.mx_DeviceTile'); + expect(tiles.length).toBe(2); + expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${oldDevice.device_id}`); + expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`); + }); +}); diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 4118cf765a..f01c69a485 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -40,6 +40,12 @@ describe('', () => { }; const alicesMobileDevice = { device_id: 'alices_mobile_device', + last_seen_ts: Date.now(), + }; + + const alicesOlderMobileDevice = { + device_id: 'alices_older_mobile_device', + last_seen_ts: Date.now() - 600000, }; const mockCrossSigningInfo = { @@ -139,8 +145,6 @@ describe('', () => { it('renders current session section', async () => { mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] }); - const noCryptoError = new Error("End-to-end encryption disabled"); - mockClient.getStoredDevice.mockImplementation(() => { throw noCryptoError; }); const { getByTestId } = render(getComponent()); await act(async () => { @@ -149,4 +153,28 @@ describe('', () => { expect(getByTestId('current-session-section')).toMatchSnapshot(); }); + + it('does not render other sessions section when user has only one device', async () => { + mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice] }); + const { queryByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + expect(queryByTestId('other-sessions-section')).toBeFalsy(); + }); + + it('renders other sessions section', async () => { + mockClient.getDevices.mockResolvedValue({ + devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice], + }); + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + expect(getByTestId('other-sessions-section')).toBeTruthy(); + }); });