Device manager - device list filtering (PSG-648) (#9181)

* add device filtering

* improve dropdown styling

* test device filtering

* update type imports

* fix types

* security card margin

* more specific type for onFilterOptionChange
This commit is contained in:
Kerry 2022-08-16 16:05:10 +02:00 committed by GitHub
parent aa9191bc34
commit 6f2c761fb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 529 additions and 43 deletions

View file

@ -22,7 +22,7 @@ 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 { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter";
import { DeviceWithVerification } from "./types";
export interface DeviceTileProps {
device: DeviceWithVerification;
@ -64,12 +64,11 @@ const getInactiveMetadata = (device: DeviceWithVerification): { id: string, valu
if (!isInactive) {
return undefined;
}
const inactiveAgeDays = Math.round(INACTIVE_DEVICE_AGE_MS / MS_DAY);
return { id: 'inactive', value: (
<>
<InactiveIcon className="mx_DeviceTile_inactiveIcon" />
{
_t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays }) +
_t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }) +
` (${formatLastActivity(device.last_seen_ts)})`
}
</>),

View file

@ -16,40 +16,178 @@ limitations under the License.
import React from 'react';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import Dropdown from '../../elements/Dropdown';
import DeviceSecurityCard from './DeviceSecurityCard';
import DeviceTile from './DeviceTile';
import { filterDevicesBySecurityRecommendation } from './filter';
import { DevicesDictionary, DeviceWithVerification } from './types';
import {
filterDevicesBySecurityRecommendation,
INACTIVE_DEVICE_AGE_DAYS,
} from './filter';
import {
DevicesDictionary,
DeviceSecurityVariation,
DeviceWithVerification,
} from './types';
interface Props {
devices: DevicesDictionary;
filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
}
// 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 getFilteredSortedDevices = (devices: DevicesDictionary) =>
filterDevicesBySecurityRecommendation(Object.values(devices), [])
const getFilteredSortedDevices = (devices: DevicesDictionary, filter: DeviceSecurityVariation) =>
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
.sort(sortDevicesByLatestActivity);
const ALL_FILTER_ID = 'ALL';
const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }> = ({ filter }) => {
switch (filter) {
case DeviceSecurityVariation.Verified:
return <div className='mx_FilteredDeviceList_securityCard'>
<DeviceSecurityCard
variation={DeviceSecurityVariation.Verified}
heading={_t('Verified sessions')}
description={_t(
`For best security, sign out from any session` +
` that you don't recognize or use anymore.`,
)}
/>
</div>
;
case DeviceSecurityVariation.Unverified:
return <div className='mx_FilteredDeviceList_securityCard'>
<DeviceSecurityCard
variation={DeviceSecurityVariation.Unverified}
heading={_t('Unverified sessions')}
description={_t(
`Verify your sessions for enhanced secure messaging or sign out`
+ ` from those you don't recognize or use anymore.`,
)}
/>
</div>
;
case DeviceSecurityVariation.Inactive:
return <div className='mx_FilteredDeviceList_securityCard'>
<DeviceSecurityCard
variation={DeviceSecurityVariation.Inactive}
heading={_t('Inactive sessions')}
description={_t(
`Consider signing out from old sessions ` +
`(%(inactiveAgeDays)s days or older) you don't use anymore`,
{ inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
)}
/>
</div>
;
default:
return null;
}
};
const getNoResultsMessage = (filter: DeviceSecurityVariation): string => {
switch (filter) {
case DeviceSecurityVariation.Verified:
return _t('No verified sessions found.');
case DeviceSecurityVariation.Unverified:
return _t('No unverified sessions found.');
case DeviceSecurityVariation.Inactive:
return _t('No inactive sessions found.');
default:
return _t('No sessions found.');
}
};
interface NoResultsProps { filter: DeviceSecurityVariation, clearFilter: () => void}
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
<div className='mx_FilteredDeviceList_noResults'>
{ getNoResultsMessage(filter) }
{
/* No clear filter button when filter is falsy (ie 'All') */
!!filter &&
<>
&nbsp;
<AccessibleButton
kind='link_inline'
onClick={clearFilter}
data-testid='devices-clear-filter-btn'
>
{ _t('Show all') }
</AccessibleButton>
</>
}
</div>;
/**
* Filtered list of devices
* Sorted by latest activity descending
* TODO(kerrya) Filtering to added as part of PSG-648
*/
const FilteredDeviceList: React.FC<Props> = ({ devices }) => {
const sortedDevices = getFilteredSortedDevices(devices);
const FilteredDeviceList: React.FC<Props> = ({ devices, filter, onFilterChange }) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);
return <ol className='mx_FilteredDeviceList'>
{ sortedDevices.map((device) =>
<li key={device.device_id}>
<DeviceTile
device={device}
/>
</li>,
const options = [
{ id: ALL_FILTER_ID, label: _t('All') },
{
id: DeviceSecurityVariation.Verified,
label: _t('Verified'),
description: _t('Ready for secure messaging'),
},
{
id: DeviceSecurityVariation.Unverified,
label: _t('Unverified'),
description: _t('Not ready for secure messaging'),
},
{
id: DeviceSecurityVariation.Inactive,
label: _t('Inactive'),
description: _t(
'Inactive for %(inactiveAgeDays)s days or longer',
{ inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
),
},
];
) }
</ol>;
const onFilterOptionChange = (filterId: DeviceSecurityVariation | typeof ALL_FILTER_ID) => {
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
};
return <div className='mx_FilteredDeviceList'>
<div className='mx_FilteredDeviceList_header'>
<span className='mx_FilteredDeviceList_headerLabel'>
{ _t('Sessions') }
</span>
<Dropdown
id='device-list-filter'
label={_t('Filter devices')}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
>
{ options.map(({ id, label }) =>
<div data-test-id={`device-filter-option-${id}`} key={id}>{ label }</div>,
) }
</Dropdown>
</div>
{ !!sortedDevices.length
? <FilterSecurityCard filter={filter} />
: <NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
}
<ol className='mx_FilteredDeviceList_list'>
{ sortedDevices.map((device) =>
<li key={device.device_id}>
<DeviceTile
device={device}
/>
</li>,
) }
</ol>
</div>
;
};
export default FilteredDeviceList;

View file

@ -20,16 +20,19 @@ import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import SettingsSubsection from '../shared/SettingsSubsection';
import DeviceSecurityCard from './DeviceSecurityCard';
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_MS } from './filter';
import { DevicesDictionary, DeviceSecurityVariation } from './types';
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import {
DeviceSecurityVariation,
DeviceWithVerification,
DevicesDictionary,
} from './types';
interface Props {
devices: DevicesDictionary;
}
const MS_DAY = 24 * 60 * 60 * 1000;
const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
const devicesArray = Object.values(devices);
const devicesArray = Object.values<DeviceWithVerification>(devices);
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
devicesArray,
@ -44,7 +47,7 @@ const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
return null;
}
const inactiveAgeDays = INACTIVE_DEVICE_AGE_MS / MS_DAY;
const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS;
// TODO(kerrya) stubbed until PSG-640/652
const noop = () => {};

View file

@ -18,7 +18,9 @@ import { DeviceWithVerification, DeviceSecurityVariation } from "./types";
type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
const MS_DAY = 24 * 60 * 60 * 1000;
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
export const INACTIVE_DEVICE_AGE_DAYS = INACTIVE_DEVICE_AGE_MS / MS_DAY;
export const isDeviceInactive: DeviceFilterCondition = device =>
!!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { useState } from 'react';
import { _t } from "../../../../../languageHandler";
import { useOwnDevices } from '../../devices/useOwnDevices';
@ -22,10 +22,12 @@ import SettingsSubsection from '../../shared/SettingsSubsection';
import FilteredDeviceList from '../../devices/FilteredDeviceList';
import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
import SecurityRecommendations from '../../devices/SecurityRecommendations';
import { DeviceSecurityVariation } from '../../devices/types';
import SettingsTab from '../SettingsTab';
const SessionManagerTab: React.FC = () => {
const { devices, currentDeviceId, isLoading } = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>();
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
@ -46,7 +48,11 @@ const SessionManagerTab: React.FC = () => {
)}
data-testid='other-sessions-section'
>
<FilteredDeviceList devices={otherDevices} />
<FilteredDeviceList
devices={otherDevices}
filter={filter}
onFilterChange={setFilter}
/>
</SettingsSubsection>
}
</SettingsTab>;