diff --git a/apps/web/src/@types/global.d.ts b/apps/web/src/@types/global.d.ts index 4a5d5e89cf..b65d95d3b5 100644 --- a/apps/web/src/@types/global.d.ts +++ b/apps/web/src/@types/global.d.ts @@ -19,7 +19,6 @@ import { type PlatformPeg } from "../PlatformPeg"; import { type IntegrationManagers } from "../integrations/IntegrationManagers"; import { type ModalManager } from "../Modal"; import type SettingsStore from "../settings/SettingsStore"; -import { type Notifier } from "../Notifier"; import type RightPanelStore from "../stores/right-panel/RightPanelStore"; import type WidgetStore from "../stores/WidgetStore"; import type LegacyCallHandler from "../LegacyCallHandler"; @@ -97,7 +96,6 @@ declare global { mxIntegrationManagers: typeof IntegrationManagers; singletonModalManager: ModalManager; mxSettingsStore: SettingsStore; - mxNotifier: typeof Notifier; mxRightPanelStore: RightPanelStore; mxWidgetStore: WidgetStore; mxWidgetLayoutStore: WidgetLayoutStore; diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index 15e19c8b64..9bd78c48fb 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -24,7 +24,6 @@ import { MatrixClientPeg, type MatrixClientPegAssignOpts } from "./MatrixClientP import { ModuleRunner } from "./modules/ModuleRunner"; import EventIndexPeg from "./indexing/EventIndexPeg"; import { createMatrixClient, createClientWithCreds, type IMatrixClientCreds } from "./utils/createMatrixClient"; -import Notifier from "./Notifier"; import UserActivity from "./UserActivity"; import Presence from "./Presence"; import dis from "./dispatcher/dispatcher"; @@ -1098,7 +1097,7 @@ async function startMatrixClient( ToastStore.sharedInstance().reset(); DialogOpener.instance.prepare(client); - Notifier.start(); + SDKContextClass.instance.notifier.start(); UserActivity.sharedInstance().start(); DMRoomMap.makeShared(client).start(); IntegrationManagers.sharedInstance().startWatching(); @@ -1229,7 +1228,7 @@ export async function clearStorage(opts?: { deleteEverything?: boolean }): Promi * on MatrixClientPeg after stopping. */ export function stopMatrixClient(unsetClient = true): void { - Notifier.stop(); + SDKContextClass.instance.notifier.stop(); LegacyCallHandler.instance.stop(); UserActivity.sharedInstance().stop(); SDKContextClass.instance.typingStore.reset(); diff --git a/apps/web/src/Notifier.ts b/apps/web/src/Notifier.ts index a38913de81..411092bbe3 100644 --- a/apps/web/src/Notifier.ts +++ b/apps/web/src/Notifier.ts @@ -27,13 +27,10 @@ import { logger } from "matrix-js-sdk/src/logger"; import { type PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged"; import { type SessionMembershipData, type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; -import { MatrixClientPeg } from "./MatrixClientPeg"; -import { PosthogAnalytics } from "./PosthogAnalytics"; import SdkConfig from "./SdkConfig"; import PlatformPeg from "./PlatformPeg"; import * as TextForEvent from "./TextForEvent"; import * as Avatar from "./Avatar"; -import dis from "./dispatcher/dispatcher"; import { _t } from "./languageHandler"; import Modal from "./Modal"; import SettingsStore from "./settings/SettingsStore"; @@ -43,12 +40,13 @@ import { isPushNotifyDisabled } from "./settings/controllers/NotificationControl import UserActivity from "./UserActivity"; import { mediaFromMxc } from "./customisations/Media"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; -import { SDKContextClass } from "./contexts/SDKContextClass"; +import { type SDKContextClass } from "./contexts/SDKContextClass.ts"; import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications"; import { getIncomingCallToastKey, getNotificationEventSendTs, IncomingCallToast } from "./toasts/IncomingCallToast"; import ToastStore from "./stores/ToastStore"; import { stripPlainReply } from "./utils/Reply"; import { BackgroundAudio } from "./audio/BackgroundAudio"; +import { type MatrixDispatcher } from "./dispatcher/dispatcher.ts"; /* * Dispatches: @@ -83,27 +81,6 @@ const MAX_PENDING_ENCRYPTED = 20; */ export const NOTIFICATION_SOUND_THROTTLE_MS = 1000; -/* -Override both the content body and the TextForEvent handler for specific msgtypes, in notifications. -This is useful when the content body contains fallback text that would explain that the client can't handle a particular -type of tile. -*/ -const msgTypeHandlers: Record string | null> = { - [MsgType.KeyVerificationRequest]: (event: MatrixEvent) => { - const name = event.sender?.name; - return _t("notifier|m.key.verification.request", { name }); - }, - [M_LOCATION.name]: (event: MatrixEvent) => { - return TextForEvent.textForLocationEvent(event)(); - }, - [M_LOCATION.altName]: (event: MatrixEvent) => { - return TextForEvent.textForLocationEvent(event)(); - }, - [MsgType.Audio]: (event: MatrixEvent): string | null => { - return TextForEvent.textForEvent(event, MatrixClientPeg.safeGet()); - }, -}; - /** * Extracts plain text from a message body, replacing any spoilered content * with '[Spoiler]' to prevent spoilers in desktop notifications. @@ -175,7 +152,7 @@ export type NotificationSound = { size?: number; }; -class NotifierClass extends TypedEventEmitter { +export default class Notifier extends TypedEventEmitter { private notifsByRoom: Record = {}; // A list of event IDs that we've received but need to wait until @@ -196,19 +173,50 @@ class NotifierClass extends TypedEventEmitter(); + private msgTypeHandlers: Record string | null>; + + public constructor( + private readonly dispatcher: MatrixDispatcher, + private readonly sdkContext: SDKContextClass, + ) { + super(); + + /* + Override both the content body and the TextForEvent handler for specific msgtypes, in notifications. + This is useful when the content body contains fallback text that would explain that the client can't handle a particular + type of tile. + */ + this.msgTypeHandlers = { + [MsgType.KeyVerificationRequest]: (event: MatrixEvent) => { + const name = event.sender?.name; + return _t("notifier|m.key.verification.request", { name }); + }, + [M_LOCATION.name]: (event: MatrixEvent) => { + return TextForEvent.textForLocationEvent(event)(); + }, + [M_LOCATION.altName]: (event: MatrixEvent) => { + return TextForEvent.textForLocationEvent(event)(); + }, + [MsgType.Audio]: (event: MatrixEvent): string | null => { + return TextForEvent.textForEvent(event, this.sdkContext.client!); + }, + }; + } + public notificationMessageForEvent(ev: MatrixEvent): string | null { + if (!this.sdkContext.client) return null; const msgType = ev.getContent().msgtype; - if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) { - return msgTypeHandlers[msgType](ev); + if (msgType && this.msgTypeHandlers.hasOwnProperty(msgType)) { + return this.msgTypeHandlers[msgType](ev); } - return TextForEvent.textForEvent(ev, MatrixClientPeg.safeGet()); + return TextForEvent.textForEvent(ev, this.sdkContext.client); } // XXX: exported for tests public displayPopupNotification(ev: MatrixEvent, room: Room): void { const plaf = PlatformPeg.get(); - const cli = MatrixClientPeg.safeGet(); - if (!plaf) { + const cli = this.sdkContext.client; + if (!plaf || !cli) { return; } if (!plaf.supportsNotifications() || !plaf.maySendNotifications()) { @@ -227,7 +235,7 @@ class NotifierClass extends TypedEventEmitter { - const cli = MatrixClientPeg.safeGet(); - if (localNotificationsAreSilenced(cli)) { + const cli = this.sdkContext.client; + if (!cli || localNotificationsAreSilenced(cli)) { return; } @@ -331,22 +339,19 @@ class NotifierClass extends TypedEventEmitter({ + this.sdkContext.posthogAnalytics.trackEvent({ eventName: "PermissionChanged", permission: "Notification", granted: true, }); - dis.dispatch({ + this.dispatcher.dispatch({ action: "notifier_enabled", value: true, }); }); } else { - PosthogAnalytics.instance.trackEvent({ + this.sdkContext.posthogAnalytics.trackEvent({ eventName: "PermissionChanged", permission: "Notification", granted: false, }); - dis.dispatch({ + this.dispatcher.dispatch({ action: "notifier_enabled", value: false, }); @@ -450,7 +455,7 @@ class NotifierClass extends TypedEventEmitter { + if (!this.sdkContext.client) return; if (state === SyncState.Syncing) { this.isSyncing = true; } else if (state === SyncState.Stopped || state === SyncState.Error) { @@ -483,7 +489,7 @@ class NotifierClass extends TypedEventEmitter { + if (!this.sdkContext.client) return; if (removed) return; // only notify for new events, not removed ones if (!data.liveEvent || !!toStartOfTimeline) return; // only notify for new things, not old. if (!this.isSyncing) return; // don't alert for any messages initially - if (ev.getSender() === MatrixClientPeg.safeGet().getUserId()) return; + if (ev.getSender() === this.sdkContext.client.getUserId()) return; if (data.timeline.getTimelineSet().threadListType !== null) return; // Ignore events on the thread list generated timelines - MatrixClientPeg.safeGet().decryptEventIfNeeded(ev); + this.sdkContext.client.decryptEventIfNeeded(ev); // If it's an encrypted event and the type is still 'm.room.encrypted', // it hasn't yet been decrypted, so wait until it is. @@ -546,20 +553,21 @@ class NotifierClass extends TypedEventEmitter { this.firstSyncComplete = true; this.firstSyncPromise.resolve(); - if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) { - showNotificationsToast(false); + if (this.stores.notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) { + showNotificationsToast(this.stores.notifier, false); } dis.fire(Action.FocusSendMessageComposer); diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index aace83f8f7..1b9bd52408 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -88,7 +88,6 @@ import { containsEmoji } from "../../effects/utils"; import { CHAT_EFFECTS } from "../../effects"; import { CallView } from "../views/voip/CallView"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; -import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; @@ -1689,8 +1688,8 @@ export class RoomView extends React.Component { if (!this.state.room) return; const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); // if they are not alone prompt the user about notifications so they don't miss replies - if (memberCount > 1 && Notifier.shouldShowPrompt()) { - showNotificationsToast(true); + if (memberCount > 1 && this.context.notifier.shouldShowPrompt()) { + showNotificationsToast(this.context.notifier, true); } } diff --git a/apps/web/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx b/apps/web/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index a66c75d65c..b510597766 100644 --- a/apps/web/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/apps/web/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -11,7 +11,6 @@ import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../../../languageHandler"; import AccessibleButton, { type ButtonEvent } from "../../../elements/AccessibleButton"; -import Notifier from "../../../../../Notifier"; import SettingsStore from "../../../../../settings/SettingsStore"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import { type RoomEchoChamber } from "../../../../../stores/local-echo/RoomEchoChamber"; @@ -26,6 +25,7 @@ import { chromeFileInputFix } from "../../../../../utils/BrowserWorkarounds"; import SettingsTab from "../SettingsTab"; import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSubsection } from "../../shared/SettingsSubsection"; +import { SDKContextClass } from "../../../../../contexts/SDKContextClass.ts"; interface IProps { roomId: string; @@ -50,7 +50,7 @@ export default class NotificationsSettingsTab extends React.Component => { - await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, true); - const cli = MatrixClientPeg.safeGet(); - const eventType = getLocalNotificationAccountDataEventType(cli.deviceId!); - cli.setAccountData(eventType, { - is_silenced: false, - }); -}; - -const onReject = (): void => { - Notifier.setPromptHidden(true); -}; - const TOAST_KEY = "desktopnotifications"; -export const showToast = (fromMessageSend: boolean): void => { +export const showToast = (notifier: Notifier, fromMessageSend: boolean): void => { + const onAccept = async (): Promise => { + await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, true); + const cli = MatrixClientPeg.safeGet(); + const eventType = getLocalNotificationAccountDataEventType(cli.deviceId!); + cli.setAccountData(eventType, { + is_silenced: false, + }); + }; + + const onReject = (): void => { + notifier.setPromptHidden(true); + }; + ToastStore.sharedInstance().addOrReplaceToast({ key: TOAST_KEY, title: fromMessageSend diff --git a/apps/web/test/unit-tests/Notifier-test.ts b/apps/web/test/unit-tests/Notifier-test.ts index 40504f17d8..e125eb2625 100644 --- a/apps/web/test/unit-tests/Notifier-test.ts +++ b/apps/web/test/unit-tests/Notifier-test.ts @@ -21,6 +21,7 @@ import { import { waitFor } from "jest-matrix-react"; import { CallMembership, type SessionMembershipData, type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; import { randomUUID } from "node:crypto"; +import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import type BasePlatform from "../../src/BasePlatform"; import Notifier, { NOTIFICATION_SOUND_THROTTLE_MS } from "../../src/Notifier"; @@ -38,7 +39,6 @@ import { mockPlatformPeg, } from "../test-utils"; import { getIncomingCallToastKey, IncomingCallToast } from "../../src/toasts/IncomingCallToast"; -import { SDKContextClass } from "../../src/contexts/SDKContextClass"; import UserActivity from "../../src/UserActivity"; import Modal from "../../src/Modal"; import { mkThread } from "../test-utils/threads"; @@ -46,6 +46,7 @@ import dis from "../../src/dispatcher/dispatcher"; import { type ThreadPayload } from "../../src/dispatcher/payloads/ThreadPayload"; import { Action } from "../../src/dispatcher/actions"; import { addReplyToMessageContent } from "../../src/utils/Reply"; +import { TestSDKContext } from "./TestSDKContext.ts"; jest.mock("../../src/utils/notifications", () => ({ // @ts-ignore @@ -61,6 +62,9 @@ jest.mock("../../src/audio/compat", () => ({ const settingsStoreGetValue = SettingsStore.getValue; describe("Notifier", () => { + const context = new TestSDKContext(); + let notifier: Notifier; + const roomId = "!room1:server"; const testEvent = mkEvent({ event: true, @@ -145,6 +149,8 @@ describe("Notifier", () => { mockClient.pushRules = { global: {}, }; + // @ts-ignore + mockClient.pushProcessor = new PushProcessor(mockClient); accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId!); testRoom = new Room(roomId, mockClient, mockClient.getSafeUserId()); @@ -154,9 +160,11 @@ describe("Notifier", () => { maySendNotifications: jest.fn().mockReturnValue(true), displayNotification: jest.fn().mockReturnValue({ close: jest.fn() }), loudNotification: jest.fn(), + requestNotificationPermission: jest.fn(), }); - Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); + notifier = new Notifier(dis, context); + notifier.isBodyEnabled = jest.fn().mockReturnValue(true); mockClient.getRoom.mockImplementation((id: string | undefined): Room | null => { if (id === roomId) return testRoom; @@ -165,13 +173,8 @@ describe("Notifier", () => { }); // @ts-ignore - Notifier.backgroundAudio.audioContext = mockAudioContext; - - // Notifier is a singleton, so its audio-notification throttle state - // (see NOTIFICATION_SOUND_THROTTLE_MS) leaks between tests. Reset it so - // each test exercises a clean instance. - // @ts-ignore - lastAudioNotificationMs is private - Notifier.lastAudioNotificationMs.clear(); + notifier.backgroundAudio.audioContext = mockAudioContext; + context._client = mockClient; }); describe("triggering notification from events", () => { @@ -191,9 +194,9 @@ describe("Notifier", () => { // and references them in stop // so blows up if stopped before it was started if (hasStartedNotiferBefore) { - Notifier.stop(); + notifier.stop(); } - Notifier.start(); + notifier.start(); hasStartedNotiferBefore = true; mockClient.getRoom.mockReturnValue(testRoom); mockClient.getPushActionsForEvent.mockReturnValue({ @@ -215,7 +218,7 @@ describe("Notifier", () => { }); afterAll(() => { - Notifier.stop(); + notifier.stop(); }); it("does not create notifications before syncing has started", () => { @@ -328,13 +331,13 @@ describe("Notifier", () => { ]; it.each(testCases)("does not dispatch when notifications are silenced", ({ event, count }) => { mockClient.setAccountData(accountDataEventKey, event!); - Notifier.displayPopupNotification(testEvent, testRoom); + notifier.displayPopupNotification(testEvent, testRoom); expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count); }); it("should display a notification for a voice message", () => { const audioEvent = mkAudioEvent(); - Notifier.displayPopupNotification(audioEvent, testRoom); + notifier.displayPopupNotification(audioEvent, testRoom); expect(MockPlatform.displayNotification).toHaveBeenCalledWith( "@user:example.com (!room1:server)", "@user:example.com: test audio message", @@ -358,7 +361,7 @@ describe("Notifier", () => { room: testRoom.roomId, }); addReplyToMessageContent(reply.getContent(), event); - Notifier.displayPopupNotification(reply, testRoom); + notifier.displayPopupNotification(reply, testRoom); expect(MockPlatform.displayNotification).toHaveBeenCalledWith( "@bob:example.org (!room1:server)", "This was a triumph", @@ -398,7 +401,7 @@ describe("Notifier", () => { formatted_body: formattedBody, }, }); - Notifier.displayPopupNotification(spoilerEvent, testRoom); + notifier.displayPopupNotification(spoilerEvent, testRoom); expect(MockPlatform.displayNotification).toHaveBeenCalledWith( "@bob:example.org (!room1:server)", expected, @@ -414,7 +417,7 @@ describe("Notifier", () => { jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => { return { url: { content_uri: "foobar" } }; }); - expect(Notifier.getSoundForRoom("!roomId:server")).toBeNull(); + expect(notifier.getSoundForRoom("!roomId:server")).toBeNull(); }); }); @@ -427,11 +430,11 @@ describe("Notifier", () => { it.each(testCases)("does not dispatch when notifications are silenced", ({ event, count }) => { // It's not ideal to only look at whether this function has been called // but avoids starting to look into DOM stuff - Notifier.getSoundForRoom = jest.fn(); + notifier.getSoundForRoom = jest.fn(); mockClient.setAccountData(accountDataEventKey, event!); - Notifier.playAudioNotification(testEvent, testRoom); - expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); + notifier.playAudioNotification(testEvent, testRoom); + expect(notifier.getSoundForRoom).toHaveBeenCalledTimes(count); }); }); @@ -454,10 +457,10 @@ describe("Notifier", () => { mockClient.setAccountData(accountDataEventKey, { is_silenced: false }); // Default sound path (no custom room sound). - Notifier.getSoundForRoom = jest.fn().mockReturnValue(null); + jest.spyOn(notifier, "getSoundForRoom").mockReturnValue(null); // @ts-ignore - backgroundAudio is private - playSpy = jest.spyOn(Notifier.backgroundAudio, "pickFormatAndPlay").mockResolvedValue({} as any); + playSpy = jest.spyOn(notifier.backgroundAudio, "pickFormatAndPlay").mockResolvedValue({} as any); }); afterEach(() => { @@ -467,36 +470,36 @@ describe("Notifier", () => { it("plays at most one sound for a burst of notifications within the throttle window", async () => { // Simulate a backlog of notifications arriving back-to-back on wake. - await Notifier.playAudioNotification(testEvent, testRoom); - await Notifier.playAudioNotification(testEvent, testRoom); - await Notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); expect(playSpy).toHaveBeenCalledTimes(1); }); it("plays again once the throttle window has elapsed", async () => { - await Notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); expect(playSpy).toHaveBeenCalledTimes(1); // Advance the clock just past the throttle window. jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS + 1); - await Notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); expect(playSpy).toHaveBeenCalledTimes(2); }); it("throttles right up to the window boundary, then plays again (strict `<`)", async () => { - await Notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); expect(playSpy).toHaveBeenCalledTimes(1); // One ms before the window elapses: still throttled. jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS - 1); - await Notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); expect(playSpy).toHaveBeenCalledTimes(1); // Exactly at the window boundary: plays again (the comparison is a strict `<`). jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS); - await Notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); expect(playSpy).toHaveBeenCalledTimes(2); }); @@ -504,16 +507,16 @@ describe("Notifier", () => { const soundA = { url: "sound-a.mp3", name: "A", type: "audio/mpeg", size: 1 }; const soundB = { url: "sound-b.mp3", name: "B", type: "audio/mpeg", size: 1 }; const otherRoom = new Room("!other:server", mockClient, mockClient.getSafeUserId()); - (Notifier.getSoundForRoom as jest.Mock).mockImplementation((roomId: string) => + jest.mocked(notifier.getSoundForRoom).mockImplementation((roomId: string) => roomId === testRoom.roomId ? soundA : soundB, ); // @ts-ignore - backgroundAudio is private - const customPlaySpy = jest.spyOn(Notifier.backgroundAudio, "play").mockResolvedValue({} as any); + const customPlaySpy = jest.spyOn(notifier.backgroundAudio, "play").mockResolvedValue({} as any); // Two different sounds back-to-back within the window: BOTH must play (only identical // backlogged sounds are coalesced). - await Notifier.playAudioNotification(testEvent, testRoom); - await Notifier.playAudioNotification(testEvent, otherRoom); + await notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, otherRoom); expect(customPlaySpy).toHaveBeenCalledTimes(2); expect(customPlaySpy).toHaveBeenNthCalledWith(1, soundA.url); @@ -524,8 +527,8 @@ describe("Notifier", () => { it("does not play, and does not arm the throttle, when notifications are silenced", async () => { mockClient.setAccountData(accountDataEventKey, { is_silenced: true }); - await Notifier.playAudioNotification(testEvent, testRoom); - await Notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); // Silencing gate short-circuits before the sound is played. expect(playSpy).not.toHaveBeenCalled(); @@ -533,7 +536,7 @@ describe("Notifier", () => { // ...and the silenced calls did NOT arm the throttle: once un-silenced, the next event plays // immediately (a regression arming the throttle on silenced events would suppress this). mockClient.setAccountData(accountDataEventKey, { is_silenced: false }); - await Notifier.playAudioNotification(testEvent, testRoom); + await notifier.playAudioNotification(testEvent, testRoom); expect(playSpy).toHaveBeenCalledTimes(1); }); }); @@ -567,8 +570,8 @@ describe("Notifier", () => { } return undefined; }); - Notifier.start(); - Notifier.onSyncStateChange(SyncState.Syncing, null); + notifier.start(); + notifier.onSyncStateChange(SyncState.Syncing, null); }); afterEach(() => { @@ -738,15 +741,15 @@ describe("Notifier", () => { // and references them in stop // so blows up if stopped before it was started if (hasStartedNotiferBefore) { - Notifier.stop(); + notifier.stop(); } - Notifier.start(); + notifier.start(); hasStartedNotiferBefore = true; createLocalNotificationSettingsIfNeededMock.mockClear(); }); afterAll(() => { - Notifier.stop(); + notifier.stop(); }); it("does not create local notifications event after a sync error", () => { @@ -774,14 +777,14 @@ describe("Notifier", () => { describe("evaluateEvent", () => { beforeEach(() => { - jest.spyOn(SDKContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(testRoom.roomId); + jest.spyOn(context.roomViewStore, "getRoomId").mockReturnValue(testRoom.roomId); jest.spyOn(UserActivity.sharedInstance(), "userActiveRecently").mockReturnValue(true); jest.spyOn(Modal, "hasDialogs").mockReturnValue(false); - jest.spyOn(Notifier, "displayPopupNotification").mockReset(); - jest.spyOn(Notifier, "isEnabled").mockReturnValue(true); + jest.spyOn(notifier, "displayPopupNotification").mockReset(); + jest.spyOn(notifier, "isEnabled").mockReturnValue(true); mockClient.getPushActionsForEvent.mockReturnValue({ notify: true, @@ -792,9 +795,9 @@ describe("Notifier", () => { }); it("should show a pop-up", () => { - expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0); - Notifier.evaluateEvent(testEvent); - expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0); + expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(0); + notifier.evaluateEvent(testEvent); + expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(0); const eventFromOtherRoom = mkEvent({ event: true, @@ -804,8 +807,8 @@ describe("Notifier", () => { content: {}, }); - Notifier.evaluateEvent(eventFromOtherRoom); - expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); + notifier.evaluateEvent(eventFromOtherRoom); + expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(1); }); it("should a pop-up for thread event", async () => { @@ -816,34 +819,34 @@ describe("Notifier", () => { participantUserIds: ["@bob:example.org"], }); - expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0); + expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(0); - Notifier.evaluateEvent(rootEvent); - expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0); + notifier.evaluateEvent(rootEvent); + expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(0); - Notifier.evaluateEvent(events[1]); - expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); + notifier.evaluateEvent(events[1]); + expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(1); dis.dispatch({ action: Action.ViewThread, thread_id: rootEvent.getId()!, }); - await waitFor(() => expect(SDKContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId())); + await waitFor(() => expect(context.roomViewStore.getThreadId()).toBe(rootEvent.getId())); - Notifier.evaluateEvent(events[1]); - expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); + notifier.evaluateEvent(events[1]); + expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(1); }); it("should show a pop-up for an audio message", () => { - Notifier.evaluateEvent(mkAudioEvent()); - expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); + notifier.evaluateEvent(mkAudioEvent()); + expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(1); }); }); describe("setPromptHidden", () => { it("should persist by default", () => { - Notifier.setPromptHidden(true); + notifier.setPromptHidden(true); expect(localStorage.getItem("notifications_hidden")).toBeTruthy(); }); }); @@ -852,7 +855,7 @@ describe("Notifier", () => { it("should not evaluate events from the thread list fake timeline sets", async () => { mockClient.supportsThreads.mockReturnValue(true); - const fn = jest.spyOn(Notifier, "evaluateEvent"); + const fn = jest.spyOn(notifier, "evaluateEvent"); await testRoom.createThreadsTimelineSets(); testRoom.threadsTimelineSets[0]!.addEventToTimeline( @@ -870,4 +873,34 @@ describe("Notifier", () => { expect(fn).not.toHaveBeenCalled(); }); }); + + describe("setEnabled", () => { + it("should call fire notifier_enabled value=true when permission is granted", async () => { + const dispatchSpy = jest.spyOn(dis, "dispatch"); + const notifier = new Notifier(dis, context); + + jest.mocked(MockPlatform.requestNotificationPermission).mockResolvedValue("granted"); + + const resolvers = Promise.withResolvers(); + notifier.setEnabled(true, resolvers.resolve); + await resolvers.promise; + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: "notifier_enabled", + value: true, + }); + }); + + it("should call fire notifier_enabled value=false when disabling", async () => { + const dispatchSpy = jest.spyOn(dis, "dispatch"); + const notifier = new Notifier(dis, context); + + notifier.setEnabled(false); + + expect(dispatchSpy).toHaveBeenCalledWith({ + action: "notifier_enabled", + value: false, + }); + }); + }); }); diff --git a/apps/web/test/unit-tests/submit-rageshake-test.ts b/apps/web/test/unit-tests/submit-rageshake-test.ts index 8e47ac7c81..b33a4a9982 100644 --- a/apps/web/test/unit-tests/submit-rageshake-test.ts +++ b/apps/web/test/unit-tests/submit-rageshake-test.ts @@ -26,8 +26,8 @@ import { type FeatureSettingKey, type SettingKey } from "../../src/settings/Sett import { SettingLevel } from "../../src/settings/SettingLevel.ts"; import SdkConfig from "../../src/SdkConfig.ts"; import { BugReportEndpointURLLocal } from "../../src/IConfigOptions.ts"; -import { Notifier } from "../../src/Notifier.ts"; import { MatrixClientPeg } from "../../src/MatrixClientPeg.ts"; +import { SDKContextClass } from "../../src/contexts/SDKContextClass.ts"; describe("Rageshakes", () => { let mockClient: Mocked; @@ -360,7 +360,7 @@ describe("Rageshakes", () => { describe("Settings Store", () => { beforeEach(() => { - jest.spyOn(Notifier, "isPossible").mockReturnValue(true); + jest.spyOn(SDKContextClass.instance.notifier, "isPossible").mockReturnValue(true); }); afterEach(() => { @@ -406,7 +406,7 @@ describe("Rageshakes", () => { it("should handle settings throwing when logged out", async () => { jest.mocked(MatrixClientPeg.get).mockRestore(); jest.mocked(MatrixClientPeg.safeGet).mockRestore(); - jest.spyOn(Notifier, "isPossible").mockImplementation(() => { + jest.spyOn(SDKContextClass.instance.notifier, "isPossible").mockImplementation(() => { throw new Error("Test"); }); @@ -419,7 +419,7 @@ describe("Rageshakes", () => { it("should handle reading notification settings when logged out", async () => { jest.mocked(MatrixClientPeg.get).mockRestore(); jest.mocked(MatrixClientPeg.safeGet).mockRestore(); - jest.spyOn(Notifier, "isPossible").mockReturnValue(true); + jest.spyOn(SDKContextClass.instance.notifier, "isPossible").mockReturnValue(true); const formData = await collectBugReport(); expect(JSON.parse(formData.get("mx_local_settings") as string)["notificationsEnabled"]).toBe(false);