Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Device manager - device list filtering (PSG-648) (#9181)
Browse files Browse the repository at this point in the history
* add device filtering

* improve dropdown styling

* test device filtering

* update type imports

* fix types

* security card margin

* more specific type for onFilterOptionChange
  • Loading branch information
Kerry authored Aug 16, 2022
1 parent aa9191b commit 6f2c761
Show file tree
Hide file tree
Showing 11 changed files with 530 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ limitations under the License.
margin: 0 0 $spacing-4 0;
}
.mx_DeviceSecurityCard_description {
margin: 0 0 $spacing-8 0;
margin: 0;
font-size: $font-12px;
color: $secondary-content;
}
36 changes: 36 additions & 0 deletions res/css/components/views/settings/devices/_FilteredDeviceList.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,45 @@ limitations under the License.
*/

.mx_FilteredDeviceList {
.mx_Dropdown {
flex: 1 0 80px;
}
}

.mx_FilteredDeviceList_header {
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;

width: 100%;
height: 48px;
padding: 0 $spacing-16;
margin-bottom: $spacing-32;

background-color: $system;
border-radius: 8px;
color: $secondary-content;
}

.mx_FilteredDeviceList_headerLabel {
flex: 1 1 100%;
}

.mx_FilteredDeviceList_list {
list-style-type: none;
display: grid;
grid-gap: $spacing-16;
margin: 0;
padding: 0 $spacing-8;
}

.mx_FilteredDeviceList_securityCard {
margin-bottom: $spacing-32;
}

.mx_FilteredDeviceList_noResults {
width: 100%;
text-align: center;
margin-bottom: $spacing-32;
}
5 changes: 2 additions & 3 deletions src/components/views/settings/devices/DeviceTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)})`
}
</>),
Expand Down
172 changes: 155 additions & 17 deletions src/components/views/settings/devices/FilteredDeviceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

return <ol className='mx_FilteredDeviceList'>
{ sortedDevices.map((device) =>
<li key={device.device_id}>
<DeviceTile
device={device}
/>
</li>,
const FilteredDeviceList: React.FC<Props> = ({ devices, filter, onFilterChange }) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);

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 },
),
},
];

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>;
) }
</ol>
</div>
;
};

export default FilteredDeviceList;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = () => {};
Expand Down
2 changes: 2 additions & 0 deletions src/components/views/settings/devices/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/components/views/settings/tabs/user/SessionManagerTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@ 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';
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;
Expand All @@ -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>;
Expand Down
20 changes: 16 additions & 4 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1708,13 +1708,26 @@
"This session is ready for secure messaging.": "This session is ready for secure messaging.",
"Unverified session": "Unverified session",
"Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.",
"Security recommendations": "Security recommendations",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
"Verified sessions": "Verified sessions",
"For best security, sign out from any session that you don't recognize or use anymore.": "For best security, sign out from any session that you don't recognize or use anymore.",
"Unverified sessions": "Unverified sessions",
"Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.",
"View all": "View all",
"Inactive sessions": "Inactive sessions",
"Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore",
"No verified sessions found.": "No verified sessions found.",
"No unverified sessions found.": "No unverified sessions found.",
"No inactive sessions found.": "No inactive sessions found.",
"No sessions found.": "No sessions found.",
"Show all": "Show all",
"All": "All",
"Ready for secure messaging": "Ready for secure messaging",
"Not ready for secure messaging": "Not ready for secure messaging",
"Inactive": "Inactive",
"Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer",
"Filter devices": "Filter devices",
"Security recommendations": "Security recommendations",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
"View all": "View all",
"Unable to remove contact information": "Unable to remove contact information",
"Remove %(email)s?": "Remove %(email)s?",
"Invalid Email Address": "Invalid Email Address",
Expand Down Expand Up @@ -2234,7 +2247,6 @@
"Error decrypting video": "Error decrypting video",
"Error processing voice message": "Error processing voice message",
"Add reaction": "Add reaction",
"Show all": "Show all",
"Reactions": "Reactions",
"%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s",
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",
Expand Down
2 changes: 1 addition & 1 deletion test/components/views/settings/DevicesPanel-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('<DevicesPanel />', () => {

await flushPromises();
// modal rendering has some weird sleeps
await sleep(10);
await sleep(100);

expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([device2.device_id], undefined);

Expand Down
Loading

0 comments on commit 6f2c761

Please sign in to comment.