diff --git a/res/css/components/views/settings/devices/_DeviceTile.pcss b/res/css/components/views/settings/devices/_DeviceTile.pcss index 159cace6ac0..d89fd9c76eb 100644 --- a/res/css/components/views/settings/devices/_DeviceTile.pcss +++ b/res/css/components/views/settings/devices/_DeviceTile.pcss @@ -18,7 +18,6 @@ limitations under the License. display: flex; flex-direction: row; align-items: center; - width: 100%; } @@ -27,15 +26,21 @@ limitations under the License. } .mx_DeviceTile_metadata { - margin-top: 2px; + margin-top: $spacing-4; font-size: $font-12px; color: $secondary-content; + line-height: $font-14px; +} + +.mx_DeviceTile_inactiveIcon { + height: 14px; + margin-right: $spacing-8; + vertical-align: middle; } .mx_DeviceTile_actions { display: grid; grid-gap: $spacing-8; grid-auto-flow: column; - margin-left: $spacing-8; } diff --git a/src/components/views/settings/devices/DeviceSecurityCard.tsx b/src/components/views/settings/devices/DeviceSecurityCard.tsx index bf5a3e5729b..d71409cb4fb 100644 --- a/src/components/views/settings/devices/DeviceSecurityCard.tsx +++ b/src/components/views/settings/devices/DeviceSecurityCard.tsx @@ -20,12 +20,7 @@ import React from 'react'; import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg'; import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg'; import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg'; - -export enum DeviceSecurityVariation { - Verified = 'Verified', - Unverified = 'Unverified', - Inactive = 'Inactive', -} +import { DeviceSecurityVariation } from './filter'; interface Props { variation: DeviceSecurityVariation; heading: string; diff --git a/src/components/views/settings/devices/DeviceTile.tsx b/src/components/views/settings/devices/DeviceTile.tsx index 9e9a520fcea..68c355316ee 100644 --- a/src/components/views/settings/devices/DeviceTile.tsx +++ b/src/components/views/settings/devices/DeviceTile.tsx @@ -16,13 +16,14 @@ limitations under the License. import React, { Fragment } from "react"; +import { Icon as InactiveIcon } from '../../../../../res/img/element-icons/settings/inactive.svg'; import { _t } from "../../../../languageHandler"; import { formatDate, formatRelativeTime } from "../../../../DateUtils"; import TooltipTarget from "../../elements/TooltipTarget"; import { Alignment } from "../../elements/Tooltip"; import Heading from "../../typography/Heading"; +import { INACTIVE_DEVICE_AGE_MS, isDeviceInactive } from "./filter"; import { DeviceWithVerification } from "./useOwnDevices"; - export interface DeviceTileProps { device: DeviceWithVerification; children?: React.ReactNode; @@ -45,7 +46,8 @@ const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device } ; }; -const MS_6_DAYS = 6 * 24 * 60 * 60 * 1000; +const MS_DAY = 24 * 60 * 60 * 1000; +const MS_6_DAYS = 6 * MS_DAY; const formatLastActivity = (timestamp: number, now = new Date().getTime()): string => { // less than a week ago if (timestamp + MS_6_DAYS >= now) { @@ -56,18 +58,40 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri return formatRelativeTime(new Date(timestamp)); }; -const DeviceMetadata: React.FC<{ value: string, id: string }> = ({ value, id }) => ( +const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => { + const isInactive = isDeviceInactive(device); + + if (!isInactive) { + return undefined; + } + const inactiveAgeDays = Math.round(INACTIVE_DEVICE_AGE_MS / MS_DAY); + return { id: 'inactive', value: ( + <> + + { + _t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays }) + + ` (${formatLastActivity(device.last_seen_ts)})` + } + ), + }; +}; + +const DeviceMetadata: React.FC<{ value: string | React.ReactNode, id: string }> = ({ value, id }) => ( value ? { value } : null ); const DeviceTile: React.FC = ({ device, children, onClick }) => { + const inactive = getInactiveMetadata(device); const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`; const verificationStatus = device.isVerified ? _t('Verified') : _t('Unverified'); - const metadata = [ - { id: 'isVerified', value: verificationStatus }, - { id: 'lastActivity', value: lastActivity }, - { id: 'lastSeenIp', value: device.last_seen_ip }, - ]; + // if device is inactive, don't display last activity or verificationStatus + const metadata = inactive + ? [inactive, { id: 'lastSeenIp', value: device.last_seen_ip }] + : [ + { id: 'isVerified', value: verificationStatus }, + { id: 'lastActivity', value: lastActivity }, + { id: 'lastSeenIp', value: device.last_seen_ip }, + ]; return
diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 757f0fda4f5..5920d671324 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 00000000000..82bd9e59059 --- /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 707759fbcab..ebda5ebe827 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 cfd67aba9b2..5b53f989786 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 045478fa803..89fb9931b1f 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 4083945fd61..c8bc8733902 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 00000000000..073efc79cd5 --- /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, + ]); + }); +});