diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx
index 757f0fda4f..5920d67132 100644
--- a/src/components/views/settings/devices/FilteredDeviceList.tsx
+++ b/src/components/views/settings/devices/FilteredDeviceList.tsx
@@ -17,6 +17,7 @@ limitations under the License.
import React from 'react';
import DeviceTile from './DeviceTile';
+import { filterDevicesBySecurityRecommendation } from './filter';
import { DevicesDictionary, DeviceWithVerification } from './useOwnDevices';
interface Props {
@@ -27,8 +28,9 @@ interface Props {
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);
+const getFilteredSortedDevices = (devices: DevicesDictionary) =>
+ filterDevicesBySecurityRecommendation(Object.values(devices), [])
+ .sort(sortDevicesByLatestActivity);
/**
* Filtered list of devices
@@ -36,7 +38,7 @@ const getSortedDevices = (devices: DevicesDictionary) =>
* TODO(kerrya) Filtering to added as part of PSG-648
*/
const FilteredDeviceList: React.FC
= ({ devices }) => {
- const sortedDevices = getSortedDevices(devices);
+ const sortedDevices = getFilteredSortedDevices(devices);
return
{ sortedDevices.map((device) =>
diff --git a/src/components/views/settings/devices/filter.ts b/src/components/views/settings/devices/filter.ts
new file mode 100644
index 0000000000..82bd9e5905
--- /dev/null
+++ b/src/components/views/settings/devices/filter.ts
@@ -0,0 +1,47 @@
+/*
+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 { DeviceWithVerification } from "./useOwnDevices";
+
+export enum DeviceSecurityVariation {
+ Verified = 'Verified',
+ Unverified = 'Unverified',
+ Inactive = 'Inactive',
+}
+
+type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
+
+export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
+
+export const isDeviceInactive: DeviceFilterCondition = device =>
+ !!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;
+
+const filters: Record = {
+ [DeviceSecurityVariation.Verified]: device => !!device.isVerified,
+ [DeviceSecurityVariation.Unverified]: device => !device.isVerified,
+ [DeviceSecurityVariation.Inactive]: isDeviceInactive,
+};
+
+export const filterDevicesBySecurityRecommendation = (
+ devices: DeviceWithVerification[],
+ securityVariations: DeviceSecurityVariation[],
+) => {
+ const activeFilters = securityVariations.map(variation => filters[variation]);
+ if (!activeFilters.length) {
+ return devices;
+ }
+ return devices.filter(device => activeFilters.every(filter => filter(device)));
+};
diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx
index 707759fbca..ebda5ebe82 100644
--- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx
+++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx
@@ -20,10 +20,11 @@ import { _t } from "../../../../../languageHandler";
import Spinner from '../../../elements/Spinner';
import { useOwnDevices } from '../../devices/useOwnDevices';
import DeviceTile from '../../devices/DeviceTile';
-import DeviceSecurityCard, { DeviceSecurityVariation } from '../../devices/DeviceSecurityCard';
+import DeviceSecurityCard from '../../devices/DeviceSecurityCard';
import SettingsSubsection from '../../shared/SettingsSubsection';
-import SettingsTab from '../SettingsTab';
import FilteredDeviceList from '../../devices/FilteredDeviceList';
+import { DeviceSecurityVariation } from '../../devices/filter';
+import SettingsTab from '../SettingsTab';
const SessionManagerTab: React.FC = () => {
const { devices, currentDeviceId, isLoading } = useOwnDevices();
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index cfd67aba9b..5b53f98978 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1703,6 +1703,7 @@
"Device": "Device",
"IP address": "IP address",
"Session details": "Session details",
+ "Inactive for %(inactiveAgeDays)s+ days": "Inactive for %(inactiveAgeDays)s+ days",
"Verified": "Verified",
"Unverified": "Unverified",
"Unable to remove contact information": "Unable to remove contact information",
diff --git a/test/components/views/settings/devices/DeviceSecurityCard-test.tsx b/test/components/views/settings/devices/DeviceSecurityCard-test.tsx
index 045478fa80..89fb9931b1 100644
--- a/test/components/views/settings/devices/DeviceSecurityCard-test.tsx
+++ b/test/components/views/settings/devices/DeviceSecurityCard-test.tsx
@@ -17,9 +17,8 @@ limitations under the License.
import { render } from '@testing-library/react';
import React from 'react';
-import DeviceSecurityCard, {
- DeviceSecurityVariation,
-} from '../../../../../src/components/views/settings/devices/DeviceSecurityCard';
+import DeviceSecurityCard from '../../../../../src/components/views/settings/devices/DeviceSecurityCard';
+import { DeviceSecurityVariation } from '../../../../../src/components/views/settings/devices/filter';
describe('', () => {
const defaultProps = {
diff --git a/test/components/views/settings/devices/DeviceTile-test.tsx b/test/components/views/settings/devices/DeviceTile-test.tsx
index 4083945fd6..c8bc873390 100644
--- a/test/components/views/settings/devices/DeviceTile-test.tsx
+++ b/test/components/views/settings/devices/DeviceTile-test.tsx
@@ -109,5 +109,18 @@ describe('', () => {
const { getByTestId } = render(getComponent({ device }));
expect(getByTestId('device-metadata-lastActivity').textContent).toEqual('Last activity Dec 29, 2021');
});
+
+ it('renders with inactive notice when last activity was more than 90 days ago', () => {
+ const device: IMyDevice = {
+ device_id: '123',
+ last_seen_ip: '1.2.3.4',
+ last_seen_ts: now - (MS_DAY * 100),
+ };
+ const { getByTestId, queryByTestId } = render(getComponent({ device }));
+ expect(getByTestId('device-metadata-inactive').textContent).toEqual('Inactive for 90+ days (Dec 4, 2021)');
+ // last activity and verification not shown when inactive
+ expect(queryByTestId('device-metadata-lastActivity')).toBeFalsy();
+ expect(queryByTestId('device-metadata-verificationStatus')).toBeFalsy();
+ });
});
});
diff --git a/test/components/views/settings/devices/filter-test.ts b/test/components/views/settings/devices/filter-test.ts
new file mode 100644
index 0000000000..073efc79cd
--- /dev/null
+++ b/test/components/views/settings/devices/filter-test.ts
@@ -0,0 +1,77 @@
+/*
+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 {
+ DeviceSecurityVariation,
+ filterDevicesBySecurityRecommendation,
+} from "../../../../../src/components/views/settings/devices/filter";
+
+const MS_DAY = 86400000;
+describe('filterDevicesBySecurityRecommendation()', () => {
+ const unverifiedNoMetadata = { device_id: 'unverified-no-metadata', isVerified: false };
+ const verifiedNoMetadata = { device_id: 'verified-no-metadata', isVerified: true };
+ const hundredDaysOld = { device_id: '100-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 100) };
+ const hundredDaysOldUnverified = {
+ device_id: 'unverified-100-days-old',
+ isVerified: false,
+ last_seen_ts: Date.now() - (MS_DAY * 100),
+ };
+ const fiftyDaysOld = { device_id: '50-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 50) };
+
+ const devices = [
+ unverifiedNoMetadata,
+ verifiedNoMetadata,
+ hundredDaysOld,
+ hundredDaysOldUnverified,
+ fiftyDaysOld,
+ ];
+
+ it('returns all devices when no securityRecommendations are passed', () => {
+ expect(filterDevicesBySecurityRecommendation(devices, [])).toBe(devices);
+ });
+
+ it('returns devices older than 90 days as inactive', () => {
+ expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Inactive])).toEqual([
+ // devices without ts metadata are not filtered as inactive
+ hundredDaysOld,
+ hundredDaysOldUnverified,
+ ]);
+ });
+
+ it('returns correct devices for verified filter', () => {
+ expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Verified])).toEqual([
+ verifiedNoMetadata,
+ hundredDaysOld,
+ fiftyDaysOld,
+ ]);
+ });
+
+ it('returns correct devices for unverified filter', () => {
+ expect(filterDevicesBySecurityRecommendation(devices, [DeviceSecurityVariation.Unverified])).toEqual([
+ unverifiedNoMetadata,
+ hundredDaysOldUnverified,
+ ]);
+ });
+
+ it('returns correct devices for combined verified and inactive filters', () => {
+ expect(filterDevicesBySecurityRecommendation(
+ devices,
+ [DeviceSecurityVariation.Unverified, DeviceSecurityVariation.Inactive],
+ )).toEqual([
+ hundredDaysOldUnverified,
+ ]);
+ });
+});