Fix Notifier import cycle (#34122)
* Fix Notifier import cycle * Fix tests * Delint * Iterate * Handle SDKContextClass `client` initialisation internally Rather than via MatrixChat - this is predominantly for Lifecycle tests as they don't use a MatrixChat and it doesn't make much sense for this component to own this state. * Fix tests * Iterate * Improve coverage * Improve coverage * Fix bad merge * Iterate
This commit is contained in:
@@ -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<ThreadPayload>({
|
||||
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<void>();
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user