From 641cf28e4c4f43f5f10fa28cbd1be0dd3f9ff8d9 Mon Sep 17 00:00:00 2001 From: Germain Date: Tue, 27 Sep 2022 13:35:54 +0100 Subject: [PATCH] Implement push notification toggle in device detail (#9308) Co-authored-by: Travis Ralston --- .../settings/devices/_DeviceDetails.pcss | 14 +++ res/css/views/elements/_ToggleSwitch.pcss | 4 + .../views/settings/devices/DeviceDetails.tsx | 31 +++++++ .../settings/devices/FilteredDeviceList.tsx | 24 +++++ .../views/settings/devices/useOwnDevices.ts | 36 +++++++- .../settings/tabs/user/SessionManagerTab.tsx | 6 ++ src/i18n/strings/en_EN.json | 4 + .../views/settings/DevicesPanel-test.tsx | 13 +++ .../devices/CurrentDeviceSection-test.tsx | 1 + .../settings/devices/DeviceDetails-test.tsx | 87 ++++++++++++++++++- .../devices/FilteredDeviceList-test.tsx | 4 + .../tabs/user/SessionManagerTab-test.tsx | 36 +++++++- test/test-utils/test-utils.ts | 12 +++ 13 files changed, 269 insertions(+), 3 deletions(-) diff --git a/res/css/components/views/settings/devices/_DeviceDetails.pcss b/res/css/components/views/settings/devices/_DeviceDetails.pcss index ebb725d28e..754ee43998 100644 --- a/res/css/components/views/settings/devices/_DeviceDetails.pcss +++ b/res/css/components/views/settings/devices/_DeviceDetails.pcss @@ -46,6 +46,13 @@ limitations under the License. .mx_DeviceDetails_sectionHeading { margin: 0; + + .mx_DeviceDetails_sectionSubheading { + display: block; + font-size: $font-12px; + color: $secondary-content; + line-height: $font-14px; + } } .mx_DeviceDetails_metadataTable { @@ -81,3 +88,10 @@ limitations under the License. align-items: center; gap: $spacing-4; } + +.mx_DeviceDetails_pushNotifications { + display: block; + .mx_ToggleSwitch { + float: right; + } +} diff --git a/res/css/views/elements/_ToggleSwitch.pcss b/res/css/views/elements/_ToggleSwitch.pcss index 09c04f2c08..c4ad3e0a28 100644 --- a/res/css/views/elements/_ToggleSwitch.pcss +++ b/res/css/views/elements/_ToggleSwitch.pcss @@ -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 { diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 53c095a33b..ed6da8fcc3 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -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; + setPusherEnabled?: (deviceId: string, enabled: boolean) => Promise | undefined; + supportsMSC3881?: boolean | undefined; } interface MetadataTable { @@ -39,10 +45,13 @@ interface MetadataTable { const DeviceDetails: React.FC = ({ device, + pusher, isSigningOut, onVerifyDevice, onSignOutDevice, saveDeviceName, + setPusherEnabled, + supportsMSC3881, }) => { const metadata: MetadataTable[] = [ { @@ -93,6 +102,28 @@ const DeviceDetails: React.FC = ({ , ) } + { pusher && ( +
+ setPusherEnabled?.(device.device_id, checked)} + aria-label={_t("Toggle push notifications on this session.")} + data-testid='device-detail-push-notification-checkbox' + /> +

+ { _t('Push notifications') } + + { _t('Receive push notifications on this session.') } + +

+
+ ) }
void; saveDeviceName: DevicesState['saveDeviceName']; onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void; + setPusherEnabled: (deviceId: string, enabled: boolean) => Promise; + supportsMSC3881?: boolean | undefined; } // devices without timestamp metadata should be sorted last @@ -135,20 +140,26 @@ const NoResults: React.FC = ({ filter, clearFilter }) => const DeviceListItem: React.FC<{ device: DeviceWithVerification; + pusher?: IPusher | undefined; isExpanded: boolean; isSigningOut: boolean; onDeviceExpandToggle: () => void; onSignOutDevice: () => void; saveDeviceName: (deviceName: string) => Promise; onRequestDeviceVerification?: () => void; + setPusherEnabled: (deviceId: string, enabled: boolean) => Promise; + supportsMSC3881?: boolean | undefined; }> = ({ device, + pusher, isExpanded, isSigningOut, onDeviceExpandToggle, onSignOutDevice, saveDeviceName, onRequestDeviceVerification, + setPusherEnabled, + supportsMSC3881, }) =>
  • }
  • ; @@ -177,6 +191,7 @@ const DeviceListItem: React.FC<{ export const FilteredDeviceList = forwardRef(({ devices, + pushers, filter, expandedDeviceIds, signingOutDeviceIds, @@ -185,9 +200,15 @@ export const FilteredDeviceList = saveDeviceName, onSignOutDevices, onRequestDeviceVerification, + setPusherEnabled, + supportsMSC3881, }: Props, ref: ForwardedRef) => { 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[] = [ { id: ALL_FILTER_ID, label: _t('All') }, { @@ -236,6 +257,7 @@ export const FilteredDeviceList = { sortedDevices.map((device) => onDeviceExpandToggle(device.device_id)} @@ -246,6 +268,8 @@ export const FilteredDeviceList = ? () => onRequestDeviceVerification(device.device_id) : undefined } + setPusherEnabled={setPusherEnabled} + supportsMSC3881={supportsMSC3881} />, ) } diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 0f7d1044da..b583d4c080 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -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"; @@ -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; refreshDevices: () => Promise; saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise; + setPusherEnabled: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise; error?: OwnDevicesError; + supportsMSC3881?: boolean | undefined; }; export const useOwnDevices = (): DevicesState => { const matrixClient = useContext(MatrixClientContext); @@ -91,10 +94,18 @@ export const useOwnDevices = (): DevicesState => { const userId = matrixClient.getUserId(); const [devices, setDevices] = useState({}); + const [pushers, setPushers] = useState([]); const [isLoadingDeviceList, setIsLoadingDeviceList] = useState(true); + const [supportsMSC3881, setSupportsMSC3881] = useState(true); // optimisticly saying yes! const [error, setError] = useState(); + useEffect(() => { + matrixClient.doesServerSupportUnstableFeature("org.matrix.msc3881").then(hasSupport => { + setSupportsMSC3881(hasSupport); + }); + }, [matrixClient]); + const refreshDevices = useCallback(async () => { setIsLoadingDeviceList(true); try { @@ -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) { @@ -154,13 +169,32 @@ export const useOwnDevices = (): DevicesState => { } }, [matrixClient, devices, refreshDevices]); + const setPusherEnabled = useCallback( + async (deviceId: DeviceWithVerification['device_id'], enabled: boolean): Promise => { + 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, }; }; diff --git a/src/components/views/settings/tabs/user/SessionManagerTab.tsx b/src/components/views/settings/tabs/user/SessionManagerTab.tsx index bd26965451..ec2b7e8a61 100644 --- a/src/components/views/settings/tabs/user/SessionManagerTab.tsx +++ b/src/components/views/settings/tabs/user/SessionManagerTab.tsx @@ -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(); const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); @@ -186,6 +189,7 @@ const SessionManagerTab: React.FC = () => { > { onRequestDeviceVerification={requestDeviceVerification ? onTriggerDeviceVerification : undefined} onSignOutDevices={onSignOutOtherDevices} saveDeviceName={saveDeviceName} + setPusherEnabled={setPusherEnabled} ref={filteredDeviceListRef} + supportsMSC3881={supportsMSC3881} /> } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4feddc9e66..22abbc653f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", @@ -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", diff --git a/test/components/views/settings/DevicesPanel-test.tsx b/test/components/views/settings/DevicesPanel-test.tsx index d9a66ab7bd..ef9801adac 100644 --- a/test/components/views/settings/DevicesPanel-test.tsx +++ b/test/components/views/settings/DevicesPanel-test.tsx @@ -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"; @@ -40,6 +42,8 @@ describe('', () => { 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 = () => ; @@ -50,6 +54,15 @@ describe('', () => { 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 () => { diff --git a/test/components/views/settings/devices/CurrentDeviceSection-test.tsx b/test/components/views/settings/devices/CurrentDeviceSection-test.tsx index 22824fb1e7..dfb6bf626e 100644 --- a/test/components/views/settings/devices/CurrentDeviceSection-test.tsx +++ b/test/components/views/settings/devices/CurrentDeviceSection-test.tsx @@ -40,6 +40,7 @@ describe('', () => { isLoading: false, isSigningOut: false, }; + const getComponent = (props = {}): React.ReactElement => (); diff --git a/test/components/views/settings/devices/DeviceDetails-test.tsx b/test/components/views/settings/devices/DeviceDetails-test.tsx index dad0ce625b..0cec7f387b 100644 --- a/test/components/views/settings/devices/DeviceDetails-test.tsx +++ b/test/components/views/settings/devices/DeviceDetails-test.tsx @@ -15,9 +15,11 @@ limitations under the License. */ import React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; +import { PUSHER_ENABLED } from 'matrix-js-sdk/src/@types/event'; import DeviceDetails from '../../../../../src/components/views/settings/devices/DeviceDetails'; +import { mkPusher } from '../../../../test-utils/test-utils'; describe('', () => { const baseDevice = { @@ -26,12 +28,17 @@ describe('', () => { }; const defaultProps = { device: baseDevice, + pusher: null, isSigningOut: false, isLoading: false, onSignOutDevice: jest.fn(), saveDeviceName: jest.fn(), + setPusherEnabled: jest.fn(), + supportsMSC3881: true, }; + const getComponent = (props = {}) => ; + // 14.03.2022 16:15 const now = 1647270879403; jest.useFakeTimers(); @@ -74,4 +81,82 @@ describe('', () => { getByTestId('device-detail-sign-out-cta').getAttribute('aria-disabled'), ).toEqual("true"); }); + + it('renders the push notification section when a pusher exists', () => { + const device = { + ...baseDevice, + }; + const pusher = mkPusher({ + device_id: device.device_id, + }); + + const { getByTestId } = render(getComponent({ + device, + pusher, + isSigningOut: true, + })); + + expect(getByTestId('device-detail-push-notification')).toBeTruthy(); + }); + + it('hides the push notification section when no pusher', () => { + const device = { + ...baseDevice, + }; + + const { getByTestId } = render(getComponent({ + device, + pusher: null, + isSigningOut: true, + })); + + expect(() => getByTestId('device-detail-push-notification')).toThrow(); + }); + + it('disables the checkbox when there is no server support', () => { + const device = { + ...baseDevice, + }; + const pusher = mkPusher({ + device_id: device.device_id, + [PUSHER_ENABLED.name]: false, + }); + + const { getByTestId } = render(getComponent({ + device, + pusher, + isSigningOut: true, + supportsMSC3881: false, + })); + + const checkbox = getByTestId('device-detail-push-notification-checkbox'); + + expect(checkbox.getAttribute('aria-disabled')).toEqual("true"); + expect(checkbox.getAttribute('aria-checked')).toEqual("false"); + }); + + it('changes the pusher status when clicked', () => { + const device = { + ...baseDevice, + }; + + const enabled = false; + + const pusher = mkPusher({ + device_id: device.device_id, + [PUSHER_ENABLED.name]: enabled, + }); + + const { getByTestId } = render(getComponent({ + device, + pusher, + isSigningOut: true, + })); + + const checkbox = getByTestId('device-detail-push-notification-checkbox'); + + fireEvent.click(checkbox); + + expect(defaultProps.setPusherEnabled).toHaveBeenCalledWith(device.device_id, !enabled); + }); }); diff --git a/test/components/views/settings/devices/FilteredDeviceList-test.tsx b/test/components/views/settings/devices/FilteredDeviceList-test.tsx index 64869d31b6..181c435b60 100644 --- a/test/components/views/settings/devices/FilteredDeviceList-test.tsx +++ b/test/components/views/settings/devices/FilteredDeviceList-test.tsx @@ -45,6 +45,7 @@ describe('', () => { onDeviceExpandToggle: jest.fn(), onSignOutDevices: jest.fn(), saveDeviceName: jest.fn(), + setPusherEnabled: jest.fn(), expandedDeviceIds: [], signingOutDeviceIds: [], devices: { @@ -54,7 +55,10 @@ describe('', () => { [hundredDaysOld.device_id]: hundredDaysOld, [hundredDaysOldUnverified.device_id]: hundredDaysOldUnverified, }, + pushers: [], + supportsMSC3881: true, }; + const getComponent = (props = {}) => (); diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index c69e71c32c..12af8a18e0 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -22,13 +22,14 @@ import { logger } from 'matrix-js-sdk/src/logger'; import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; import { VerificationRequest } from 'matrix-js-sdk/src/crypto/verification/request/VerificationRequest'; import { sleep } from 'matrix-js-sdk/src/utils'; -import { IMyDevice } from 'matrix-js-sdk/src/matrix'; +import { IMyDevice, PUSHER_DEVICE_ID, PUSHER_ENABLED } from 'matrix-js-sdk/src/matrix'; import SessionManagerTab from '../../../../../../src/components/views/settings/tabs/user/SessionManagerTab'; import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext'; import { flushPromisesWithFakeTimers, getMockClientWithEventEmitter, + mkPusher, mockClientMethodsUser, } from '../../../../../test-utils'; import Modal from '../../../../../../src/Modal'; @@ -67,6 +68,9 @@ describe('', () => { deleteMultipleDevices: jest.fn(), generateClientSecret: jest.fn(), setDeviceDetails: jest.fn(), + doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(true), + getPushers: jest.fn(), + setPusher: jest.fn(), }); const defaultProps = {}; @@ -101,6 +105,15 @@ describe('', () => { mockClient.getDevices .mockReset() .mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] }); + + mockClient.getPushers + .mockReset() + .mockResolvedValue({ + pushers: [mkPusher({ + [PUSHER_DEVICE_ID.name]: alicesMobileDevice.device_id, + [PUSHER_ENABLED.name]: true, + })], + }); }); it('renders spinner while devices load', () => { @@ -668,4 +681,25 @@ describe('', () => { expect(getByTestId('device-rename-error')).toBeTruthy(); }); }); + + it("lets you change the pusher state", async () => { + const { getByTestId } = render(getComponent()); + + await act(async () => { + await flushPromisesWithFakeTimers(); + }); + + toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id); + + // device details are expanded + expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy(); + expect(getByTestId('device-detail-push-notification')).toBeTruthy(); + + const checkbox = getByTestId('device-detail-push-notification-checkbox'); + + expect(checkbox).toBeTruthy(); + fireEvent.click(checkbox); + + expect(mockClient.setPusher).toHaveBeenCalled(); + }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index aaf8bd95de..5369a3b9f5 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -30,6 +30,7 @@ import { EventType, IEventRelation, IUnsigned, + IPusher, } from 'matrix-js-sdk/src/matrix'; import { normalize } from "matrix-js-sdk/src/utils"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; @@ -541,3 +542,14 @@ export const mkSpace = ( ))); return space; }; + +export const mkPusher = (extra: Partial = {}): IPusher => ({ + app_display_name: "app", + app_id: "123", + data: {}, + device_display_name: "name", + kind: "http", + lang: "en", + pushkey: "pushpush", + ...extra, +});