diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index ce1a0a26f04..f4d3d6ba7c3 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -48,6 +48,7 @@ import { } from "./utils/device/clientInformation"; import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; import { UIFeature } from "./settings/UIFeature"; +import { isBulkUnverifiedDeviceReminderSnoozed } from "./utils/device/snoozeBulkUnverifiedDeviceReminder"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -335,12 +336,15 @@ export default class DeviceListener { logger.debug("New unverified sessions: " + Array.from(newUnverifiedDeviceIds).join(',')); logger.debug("Currently showing toasts for: " + Array.from(this.displayingToastsForDeviceIds).join(',')); + const isBulkUnverifiedSessionsReminderSnoozed = isBulkUnverifiedDeviceReminderSnoozed(); + // Display or hide the batch toast for old unverified sessions // don't show the toast if the current device is unverified if ( oldUnverifiedDeviceIds.size > 0 && isCurrentDeviceTrusted && this.enableBulkUnverifiedSessionsReminder + && !isBulkUnverifiedSessionsReminderSnoozed ) { showBulkUnverifiedSessionsToast(oldUnverifiedDeviceIds); } else { diff --git a/src/toasts/BulkUnverifiedSessionsToast.ts b/src/toasts/BulkUnverifiedSessionsToast.ts index ae512df7ed4..439d7811269 100644 --- a/src/toasts/BulkUnverifiedSessionsToast.ts +++ b/src/toasts/BulkUnverifiedSessionsToast.ts @@ -20,6 +20,7 @@ import DeviceListener from '../DeviceListener'; import GenericToast from "../components/views/toasts/GenericToast"; import ToastStore from "../stores/ToastStore"; import { Action } from "../dispatcher/actions"; +import { snoozeBulkUnverifiedDeviceReminder } from '../utils/device/snoozeBulkUnverifiedDeviceReminder'; const TOAST_KEY = "reviewsessions"; @@ -34,6 +35,7 @@ export const showToast = (deviceIds: Set) => { const onReject = () => { DeviceListener.sharedInstance().dismissUnverifiedSessions(deviceIds); + snoozeBulkUnverifiedDeviceReminder(); }; ToastStore.sharedInstance().addOrReplaceToast({ diff --git a/src/utils/device/snoozeBulkUnverifiedDeviceReminder.ts b/src/utils/device/snoozeBulkUnverifiedDeviceReminder.ts new file mode 100644 index 00000000000..80f107b18ad --- /dev/null +++ b/src/utils/device/snoozeBulkUnverifiedDeviceReminder.ts @@ -0,0 +1,40 @@ +/* +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 { logger } from "matrix-js-sdk/src/logger"; + +const SNOOZE_KEY = 'mx_snooze_bulk_unverified_device_nag'; +// one week +const snoozePeriod = 1000 * 60 * 60 * 24 * 7; +export const snoozeBulkUnverifiedDeviceReminder = () => { + try { + localStorage.setItem(SNOOZE_KEY, String(Date.now())); + } catch (error) { + logger.error('Failed to persist bulk unverified device nag snooze', error); + } +}; + +export const isBulkUnverifiedDeviceReminderSnoozed = () => { + try { + const snoozedTimestamp = localStorage.getItem(SNOOZE_KEY); + + const parsedTimestamp = Number.parseInt(snoozedTimestamp || '', 10); + + return Number.isInteger(parsedTimestamp) && (parsedTimestamp + snoozePeriod) > Date.now(); + } catch (error) { + return false; + } +}; diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index 03ad29956e4..20adbfd45dc 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -35,6 +35,7 @@ import SettingsStore from "../src/settings/SettingsStore"; import { SettingLevel } from "../src/settings/SettingLevel"; import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils"; import { UIFeature } from "../src/settings/UIFeature"; +import { isBulkUnverifiedDeviceReminderSnoozed } from "../src/utils/device/snoozeBulkUnverifiedDeviceReminder"; // don't litter test console with logs jest.mock("matrix-js-sdk/src/logger"); @@ -48,6 +49,10 @@ jest.mock("../src/SecurityManager", () => ({ isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), })); +jest.mock("../src/utils/device/snoozeBulkUnverifiedDeviceReminder", () => ({ + isBulkUnverifiedDeviceReminderSnoozed: jest.fn(), +})); + const userId = '@user:server'; const deviceId = 'my-device-id'; const mockDispatcher = mocked(dis); @@ -95,6 +100,7 @@ describe('DeviceListener', () => { }); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + mocked(isBulkUnverifiedDeviceReminderSnoozed).mockClear().mockReturnValue(false); }); const createAndStart = async (): Promise => { @@ -451,6 +457,23 @@ describe('DeviceListener', () => { expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled(); }); + it('hides toast when reminder is snoozed', async () => { + mocked(isBulkUnverifiedDeviceReminderSnoozed).mockReturnValue(true); + // currentDevice, device2 are verified, device3 is unverified + mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { + switch (deviceId) { + case currentDevice.deviceId: + case device2.deviceId: + return deviceTrustVerified; + default: + return deviceTrustUnverified; + } + }); + await createAndStart(); + expect(BulkUnverifiedSessionsToast.showToast).not.toHaveBeenCalled(); + expect(BulkUnverifiedSessionsToast.hideToast).toHaveBeenCalled(); + }); + it('shows toast with unverified devices at app start', async () => { // currentDevice, device2 are verified, device3 is unverified mockClient!.checkDeviceTrust.mockImplementation((_userId, deviceId) => { diff --git a/test/utils/device/snoozeBulkUnverifiedDeviceReminder-test.ts b/test/utils/device/snoozeBulkUnverifiedDeviceReminder-test.ts new file mode 100644 index 00000000000..e7abf4b56ab --- /dev/null +++ b/test/utils/device/snoozeBulkUnverifiedDeviceReminder-test.ts @@ -0,0 +1,98 @@ +/* +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 { logger } from "matrix-js-sdk/src/logger"; + +import { + isBulkUnverifiedDeviceReminderSnoozed, + snoozeBulkUnverifiedDeviceReminder, +} from "../../../src/utils/device/snoozeBulkUnverifiedDeviceReminder"; + +const SNOOZE_KEY = 'mx_snooze_bulk_unverified_device_nag'; + +describe('snooze bulk unverified device nag', () => { + const localStorageSetSpy = jest.spyOn(localStorage.__proto__, 'setItem'); + const localStorageGetSpy = jest.spyOn(localStorage.__proto__, 'getItem'); + const localStorageRemoveSpy = jest.spyOn(localStorage.__proto__, 'removeItem'); + + // 14.03.2022 16:15 + const now = 1647270879403; + + beforeEach(() => { + localStorageSetSpy.mockClear().mockImplementation(() => {}); + localStorageGetSpy.mockClear().mockReturnValue(null); + localStorageRemoveSpy.mockClear().mockImplementation(() => {}); + + jest.spyOn(Date, 'now').mockReturnValue(now); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + describe('snoozeBulkUnverifiedDeviceReminder()', () => { + it('sets the current time in local storage', () => { + snoozeBulkUnverifiedDeviceReminder(); + + expect(localStorageSetSpy).toHaveBeenCalledWith(SNOOZE_KEY, now.toString()); + }); + + it('catches an error from localstorage', () => { + const loggerErrorSpy = jest.spyOn(logger, 'error'); + localStorageSetSpy.mockImplementation(() => { throw new Error('oups'); }); + snoozeBulkUnverifiedDeviceReminder(); + expect(loggerErrorSpy).toHaveBeenCalled(); + }); + }); + + describe('isBulkUnverifiedDeviceReminderSnoozed()', () => { + it('returns false when there is no snooze in storage', () => { + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(localStorageGetSpy).toHaveBeenCalledWith(SNOOZE_KEY); + expect(result).toBe(false); + }); + + it('catches an error from localstorage and returns false', () => { + const loggerErrorSpy = jest.spyOn(logger, 'error'); + localStorageGetSpy.mockImplementation(() => { throw new Error('oups'); }); + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(result).toBe(false); + expect(loggerErrorSpy).toHaveBeenCalled(); + }); + + it('returns false when snooze timestamp in storage is not a number', () => { + localStorageGetSpy.mockReturnValue('test'); + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(result).toBe(false); + }); + + it('returns false when snooze timestamp in storage is over a week ago', () => { + const msDay = 1000 * 60 * 60 * 24; + // snoozed 8 days ago + localStorageGetSpy.mockReturnValue(now - (msDay * 8)); + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(result).toBe(false); + }); + + it('returns true when snooze timestamp in storage is less than a week ago', () => { + const msDay = 1000 * 60 * 60 * 24; + // snoozed 8 days ago + localStorageGetSpy.mockReturnValue(now - (msDay * 6)); + const result = isBulkUnverifiedDeviceReminderSnoozed(); + expect(result).toBe(true); + }); + }); +});