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

Implement push notification toggle in device detail #9308

Merged
merged 19 commits into from
Sep 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions res/css/components/views/settings/devices/_DeviceDetails.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ limitations under the License.

.mx_DeviceDetails_sectionHeading {
margin: 0;

.mx_DeviceDetails_sectionSubheading {
germain-gg marked this conversation as resolved.
Show resolved Hide resolved
display: block;
font-size: $font-12px;
color: $secondary-content;
line-height: $font-14px;
}
}

.mx_DeviceDetails_metadataTable {
Expand Down Expand Up @@ -81,3 +88,10 @@ limitations under the License.
align-items: center;
gap: $spacing-4;
}

.mx_DeviceDetails_pushNotifications {
display: block;
.mx_ToggleSwitch {
float: right;
}
}
4 changes: 4 additions & 0 deletions res/css/views/elements/_ToggleSwitch.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ limitations under the License.

background-color: $togglesw-off-color;
opacity: 0.5;

&[aria-disabled="true"] {
cursor: not-allowed;
}
}

.mx_ToggleSwitch_enabled {
Expand Down
31 changes: 31 additions & 0 deletions src/components/views/settings/devices/DeviceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,27 @@ limitations under the License.
*/

import React from 'react';
import { IPusher } from 'matrix-js-sdk/src/@types/PushRules';
import { PUSHER_ENABLED } from 'matrix-js-sdk/src/@types/event';

import { formatDate } from '../../../../DateUtils';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import Spinner from '../../elements/Spinner';
import ToggleSwitch from '../../elements/ToggleSwitch';
import { DeviceDetailHeading } from './DeviceDetailHeading';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { DeviceWithVerification } from './types';

interface Props {
device: DeviceWithVerification;
pusher?: IPusher | undefined;
isSigningOut: boolean;
onVerifyDevice?: () => void;
onSignOutDevice: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
setPusherEnabled?: (deviceId: string, enabled: boolean) => Promise<void> | undefined;
supportsMSC3881?: boolean | undefined;
}

interface MetadataTable {
Expand All @@ -39,10 +45,13 @@ interface MetadataTable {

const DeviceDetails: React.FC<Props> = ({
device,
pusher,
isSigningOut,
onVerifyDevice,
onSignOutDevice,
saveDeviceName,
setPusherEnabled,
supportsMSC3881,
}) => {
const metadata: MetadataTable[] = [
{
Expand Down Expand Up @@ -93,6 +102,28 @@ const DeviceDetails: React.FC<Props> = ({
</table>,
) }
</section>
{ pusher && (
<section
className='mx_DeviceDetails_section mx_DeviceDetails_pushNotifications'
data-testid='device-detail-push-notification'
>
<ToggleSwitch
// For backwards compatibility, if `enabled` is missing
// default to `true`
checked={pusher?.[PUSHER_ENABLED.name] ?? true}
disabled={!supportsMSC3881}
onChange={(checked) => setPusherEnabled?.(device.device_id, checked)}
aria-label={_t("Toggle push notifications on this session.")}
data-testid='device-detail-push-notification-checkbox'
/>
<p className='mx_DeviceDetails_sectionHeading'>
{ _t('Push notifications') }
<small className='mx_DeviceDetails_sectionSubheading'>
{ _t('Receive push notifications on this session.') }
</small>
</p>
</section>
) }
<section className='mx_DeviceDetails_section'>
<AccessibleButton
onClick={onSignOutDevice}
Expand Down
24 changes: 24 additions & 0 deletions src/components/views/settings/devices/FilteredDeviceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ limitations under the License.
*/

import React, { ForwardedRef, forwardRef } from 'react';
import { IPusher } from 'matrix-js-sdk/src/@types/PushRules';
import { PUSHER_DEVICE_ID } from 'matrix-js-sdk/src/@types/event';

import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
Expand All @@ -36,6 +38,7 @@ import { DevicesState } from './useOwnDevices';

interface Props {
devices: DevicesDictionary;
pushers: IPusher[];
expandedDeviceIds: DeviceWithVerification['device_id'][];
signingOutDeviceIds: DeviceWithVerification['device_id'][];
filter?: DeviceSecurityVariation;
Expand All @@ -44,6 +47,8 @@ interface Props {
onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void;
saveDeviceName: DevicesState['saveDeviceName'];
onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void;
setPusherEnabled: (deviceId: string, enabled: boolean) => Promise<void>;
supportsMSC3881?: boolean | undefined;
}

// devices without timestamp metadata should be sorted last
Expand Down Expand Up @@ -135,20 +140,26 @@ const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>

const DeviceListItem: React.FC<{
device: DeviceWithVerification;
pusher?: IPusher | undefined;
isExpanded: boolean;
isSigningOut: boolean;
onDeviceExpandToggle: () => void;
onSignOutDevice: () => void;
saveDeviceName: (deviceName: string) => Promise<void>;
onRequestDeviceVerification?: () => void;
setPusherEnabled: (deviceId: string, enabled: boolean) => Promise<void>;
supportsMSC3881?: boolean | undefined;
}> = ({
device,
pusher,
isExpanded,
isSigningOut,
onDeviceExpandToggle,
onSignOutDevice,
saveDeviceName,
onRequestDeviceVerification,
setPusherEnabled,
supportsMSC3881,
}) => <li className='mx_FilteredDeviceList_listItem'>
<DeviceTile
device={device}
Expand All @@ -162,10 +173,13 @@ const DeviceListItem: React.FC<{
isExpanded &&
<DeviceDetails
device={device}
pusher={pusher}
isSigningOut={isSigningOut}
onVerifyDevice={onRequestDeviceVerification}
onSignOutDevice={onSignOutDevice}
saveDeviceName={saveDeviceName}
setPusherEnabled={setPusherEnabled}
supportsMSC3881={supportsMSC3881}
/>
}
</li>;
Expand All @@ -177,6 +191,7 @@ const DeviceListItem: React.FC<{
export const FilteredDeviceList =
forwardRef(({
devices,
pushers,
filter,
expandedDeviceIds,
signingOutDeviceIds,
Expand All @@ -185,9 +200,15 @@ export const FilteredDeviceList =
saveDeviceName,
onSignOutDevices,
onRequestDeviceVerification,
setPusherEnabled,
supportsMSC3881,
}: Props, ref: ForwardedRef<HTMLDivElement>) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);

function getPusherForDevice(device: DeviceWithVerification): IPusher | undefined {
return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id);
}

const options: FilterDropdownOption<DeviceFilterKey>[] = [
{ id: ALL_FILTER_ID, label: _t('All') },
{
Expand Down Expand Up @@ -236,6 +257,7 @@ export const FilteredDeviceList =
{ sortedDevices.map((device) => <DeviceListItem
key={device.device_id}
device={device}
pusher={getPusherForDevice(device)}
isExpanded={expandedDeviceIds.includes(device.device_id)}
isSigningOut={signingOutDeviceIds.includes(device.device_id)}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
Expand All @@ -246,6 +268,8 @@ export const FilteredDeviceList =
? () => onRequestDeviceVerification(device.device_id)
: undefined
}
setPusherEnabled={setPusherEnabled}
supportsMSC3881={supportsMSC3881}
/>,
) }
</ol>
Expand Down
36 changes: 35 additions & 1 deletion src/components/views/settings/devices/useOwnDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ limitations under the License.
*/

import { useCallback, useContext, useEffect, useState } from "react";
import { IMyDevice, MatrixClient } from "matrix-js-sdk/src/matrix";
import { IMyDevice, IPusher, MatrixClient, PUSHER_DEVICE_ID, PUSHER_ENABLED } from "matrix-js-sdk/src/matrix";
import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import { MatrixError } from "matrix-js-sdk/src/http-api";
Expand Down Expand Up @@ -76,13 +76,16 @@ export enum OwnDevicesError {
}
export type DevicesState = {
devices: DevicesDictionary;
pushers: IPusher[];
currentDeviceId: string;
isLoadingDeviceList: boolean;
// not provided when current session cannot request verification
requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise<VerificationRequest>;
refreshDevices: () => Promise<void>;
saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise<void>;
setPusherEnabled: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise<void>;
error?: OwnDevicesError;
supportsMSC3881?: boolean | undefined;
};
export const useOwnDevices = (): DevicesState => {
const matrixClient = useContext(MatrixClientContext);
Expand All @@ -91,10 +94,18 @@ export const useOwnDevices = (): DevicesState => {
const userId = matrixClient.getUserId();

const [devices, setDevices] = useState<DevicesState['devices']>({});
const [pushers, setPushers] = useState<DevicesState['pushers']>([]);
const [isLoadingDeviceList, setIsLoadingDeviceList] = useState(true);
const [supportsMSC3881, setSupportsMSC3881] = useState(true); // optimisticly saying yes!

const [error, setError] = useState<OwnDevicesError>();

useEffect(() => {
matrixClient.doesServerSupportUnstableFeature("org.matrix.msc3881").then(hasSupport => {
setSupportsMSC3881(hasSupport);
});
}, [matrixClient]);

const refreshDevices = useCallback(async () => {
setIsLoadingDeviceList(true);
try {
Expand All @@ -105,6 +116,10 @@ export const useOwnDevices = (): DevicesState => {
}
const devices = await fetchDevicesWithVerification(matrixClient, userId);
setDevices(devices);

const { pushers } = await matrixClient.getPushers();
setPushers(pushers);

setIsLoadingDeviceList(false);
} catch (error) {
if ((error as MatrixError).httpStatus == 404) {
Expand Down Expand Up @@ -154,13 +169,32 @@ export const useOwnDevices = (): DevicesState => {
}
}, [matrixClient, devices, refreshDevices]);

const setPusherEnabled = useCallback(
async (deviceId: DeviceWithVerification['device_id'], enabled: boolean): Promise<void> => {
const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId);
try {
await matrixClient.setPusher({
...pusher,
[PUSHER_ENABLED.name]: enabled,
});
await refreshDevices();
} catch (error) {
logger.error("Error setting pusher state", error);
throw new Error(_t("Failed to set pusher state"));
}
}, [matrixClient, pushers, refreshDevices],
);

return {
devices,
pushers,
currentDeviceId,
isLoadingDeviceList,
error,
requestDeviceVerification,
refreshDevices,
saveDeviceName,
setPusherEnabled,
supportsMSC3881,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,14 @@ const useSignOut = (
const SessionManagerTab: React.FC = () => {
const {
devices,
pushers,
currentDeviceId,
isLoadingDeviceList,
requestDeviceVerification,
refreshDevices,
saveDeviceName,
setPusherEnabled,
supportsMSC3881,
} = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>();
const [expandedDeviceIds, setExpandedDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
Expand Down Expand Up @@ -186,6 +189,7 @@ const SessionManagerTab: React.FC = () => {
>
<FilteredDeviceList
devices={otherDevices}
pushers={pushers}
filter={filter}
expandedDeviceIds={expandedDeviceIds}
signingOutDeviceIds={signingOutDeviceIds}
Expand All @@ -194,7 +198,9 @@ const SessionManagerTab: React.FC = () => {
onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined}
onSignOutDevices={onSignOutOtherDevices}
saveDeviceName={saveDeviceName}
setPusherEnabled={setPusherEnabled}
ref={filteredDeviceListRef}
supportsMSC3881={supportsMSC3881}
/>
</SettingsSubsection>
}
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1719,6 +1719,9 @@
"Device": "Device",
"IP address": "IP address",
"Session details": "Session details",
"Toggle push notifications on this session.": "Toggle push notifications on this session.",
"Push notifications": "Push notifications",
"Receive push notifications on this session.": "Receive push notifications on this session.",
"Sign out of this session": "Sign out of this session",
"Toggle device details": "Toggle device details",
"Inactive for %(inactiveAgeDays)s+ days": "Inactive for %(inactiveAgeDays)s+ days",
Expand Down Expand Up @@ -1751,6 +1754,7 @@
"Security recommendations": "Security recommendations",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
"View all": "View all",
"Failed to set pusher state": "Failed to set pusher state",
"Unable to remove contact information": "Unable to remove contact information",
"Remove %(email)s?": "Remove %(email)s?",
"Invalid Email Address": "Invalid Email Address",
Expand Down
13 changes: 13 additions & 0 deletions test/components/views/settings/DevicesPanel-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import { act } from 'react-dom/test-utils';
import { CrossSigningInfo } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo';
import { sleep } from 'matrix-js-sdk/src/utils';
import { PUSHER_DEVICE_ID, PUSHER_ENABLED } from 'matrix-js-sdk/src/@types/event';

import DevicesPanel from "../../../../src/components/views/settings/DevicesPanel";
import {
flushPromises,
getMockClientWithEventEmitter,
mkPusher,
mockClientMethodsUser,
} from "../../../test-utils";

Expand All @@ -40,6 +42,8 @@ describe('<DevicesPanel />', () => {
getStoredCrossSigningForUser: jest.fn().mockReturnValue(new CrossSigningInfo(userId, {}, {})),
getStoredDevice: jest.fn().mockReturnValue(new DeviceInfo('id')),
generateClientSecret: jest.fn(),
getPushers: jest.fn(),
setPusher: jest.fn(),
});

const getComponent = () => <DevicesPanel />;
Expand All @@ -50,6 +54,15 @@ describe('<DevicesPanel />', () => {
mockClient.getDevices
.mockReset()
.mockResolvedValue({ devices: [device1, device2, device3] });

mockClient.getPushers
.mockReset()
.mockResolvedValue({
pushers: [mkPusher({
[PUSHER_DEVICE_ID.name]: device1.device_id,
[PUSHER_ENABLED.name]: true,
})],
});
});

it('renders device panel with devices', async () => {
Expand Down
Loading