Files
ThreadNet-Web/apps/web/test/unit-tests/Notifier-test.ts
T
hayyaksiandGitHub 3153912cc1 Throttle notification sounds so a backlog doesn't play them all at once (#33989)
After waking from sleep (notably macOS Sequoia), the whole sync backlog is delivered in one
batch, so every backlogged notifying event calls playAudioNotification near-simultaneously
and the identical audio buffers superimpose into a single loud "stacked" sound.

Throttle audible plays to at most one per distinct resolved sound within
NOTIFICATION_SOUND_THROTTLE_MS, keyed on the sound so two genuinely different sounds arriving
within the window each still play. The throttle runs only after the existing silencing gate,
so it suppresses redundant audible plays without affecting the gating logic.
2026-07-06 15:11:58 +00:00

874 lines
35 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { mocked, type MockedObject } from "jest-mock-vitest-adapter";
import {
ClientEvent,
type MatrixClient,
Room,
RoomEvent,
EventType,
MsgType,
type IContent,
MatrixEvent,
SyncState,
type AccountDataEvents,
} from "matrix-js-sdk/src/matrix";
import { waitFor } from "jest-matrix-react";
import { CallMembership, type SessionMembershipData, type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { randomUUID } from "node:crypto";
import type BasePlatform from "../../src/BasePlatform";
import Notifier, { NOTIFICATION_SOUND_THROTTLE_MS } from "../../src/Notifier";
import SettingsStore from "../../src/settings/SettingsStore";
import ToastStore from "../../src/stores/ToastStore";
import {
createLocalNotificationSettingsIfNeeded,
getLocalNotificationAccountDataEventType,
} from "../../src/utils/notifications";
import {
getMockClientWithEventEmitter,
mkEvent,
mkMessage,
mockClientMethodsUser,
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";
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";
jest.mock("../../src/utils/notifications", () => ({
// @ts-ignore
...jest.requireActual("../../src/utils/notifications"),
createLocalNotificationSettingsIfNeeded: jest.fn(),
}));
jest.mock("../../src/audio/compat", () => ({
...jest.requireActual("../../src/audio/compat"),
createAudioContext: jest.fn(),
}));
const settingsStoreGetValue = SettingsStore.getValue;
describe("Notifier", () => {
const roomId = "!room1:server";
const testEvent = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: roomId,
content: {},
});
let MockPlatform: MockedObject<BasePlatform>;
let mockClient: MockedObject<MatrixClient>;
let testRoom: Room;
let accountDataEventKey: keyof AccountDataEvents;
let accountDataStore: Record<string, MatrixEvent | undefined> = {};
let mockSettings: Record<string, boolean> = {};
const userId = "@bob:example.org";
const emitLiveEvent = (event: MatrixEvent): void => {
mockClient!.emit(RoomEvent.Timeline, event, testRoom, false, false, {
liveEvent: true,
timeline: testRoom.getLiveTimeline(),
});
};
const mkAudioEvent = (): MatrixEvent => {
return mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@user:example.com",
room: "!room:example.com",
content: {
msgtype: MsgType.Audio,
body: "test audio message",
},
});
};
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn(),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => {
accountDataStore = {};
mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
isGuest: jest.fn().mockReturnValue(false),
getAccountData: jest.fn().mockImplementation((eventType) => accountDataStore[eventType]),
setAccountData: jest.fn().mockImplementation((eventType, content) => {
accountDataStore[eventType] = content
? new MatrixEvent({
type: eventType,
content,
})
: undefined;
}),
fetchRoomEvent: jest.fn(),
decryptEventIfNeeded: jest.fn(),
getRoom: jest.fn(),
getPushActionsForEvent: jest.fn(),
// Mock required because TextForEvent now evaluates supportsVoip for RTCNotification to trigger OS popups.
// The true/false value is arbitrary here, as this test only verifies the in-app toast creation, not the OS text output.
supportsVoip: jest.fn().mockReturnValue(true),
supportsThreads: jest.fn().mockReturnValue(false),
matrixRTC: {
on: jest.fn(),
off: jest.fn(),
getRoomSession: jest.fn(),
},
});
mockClient.pushRules = {
global: {},
};
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId!);
testRoom = new Room(roomId, mockClient, mockClient.getSafeUserId());
MockPlatform = mockPlatformPeg({
supportsNotifications: jest.fn().mockReturnValue(true),
maySendNotifications: jest.fn().mockReturnValue(true),
displayNotification: jest.fn().mockReturnValue({ close: jest.fn() }),
loudNotification: jest.fn(),
});
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
mockClient.getRoom.mockImplementation((id: string | undefined): Room | null => {
if (id === roomId) return testRoom;
if (id) return new Room(id, mockClient, mockClient.getSafeUserId());
return null;
});
// @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();
});
describe("triggering notification from events", () => {
let hasStartedNotiferBefore = false;
const event = new MatrixEvent({
sender: "@alice:server.org",
type: "m.room.message",
room_id: roomId,
content: {
body: "hey",
},
});
beforeEach(() => {
// notifier defines some listener functions in start
// and references them in stop
// so blows up if stopped before it was started
if (hasStartedNotiferBefore) {
Notifier.stop();
}
Notifier.start();
hasStartedNotiferBefore = true;
mockClient.getRoom.mockReturnValue(testRoom);
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: true,
},
});
mockSettings = {
notificationsEnabled: true,
audioNotificationsEnabled: true,
};
// enable notifications by default
jest.spyOn(SettingsStore, "getValue")
.mockReset()
.mockImplementation((settingName) => mockSettings[settingName] ?? false);
});
afterAll(() => {
Notifier.stop();
});
it("does not create notifications before syncing has started", () => {
emitLiveEvent(event);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("does not create notifications for own event", () => {
const ownEvent = new MatrixEvent({ sender: userId });
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(ownEvent);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("does not create notifications for non-live events (scrollback)", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
mockClient!.emit(RoomEvent.Timeline, event, testRoom, false, false, {
liveEvent: false,
timeline: testRoom.getLiveTimeline(),
});
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("does not create notifications for rooms which cannot be obtained via client.getRoom", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
mockClient.getRoom.mockReturnValue(null);
mockClient!.emit(RoomEvent.Timeline, event, testRoom, false, false, {
liveEvent: true,
timeline: testRoom.getLiveTimeline(),
});
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("does not create notifications when event does not have notify push action", () => {
mockClient.getPushActionsForEvent.mockReturnValue({
notify: false,
tweaks: {
sound: true,
},
});
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it("creates desktop notification when enabled", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(testRoom.name, "hey", null, testRoom, event);
});
it("closes a desktop notification when room is marked read", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(testRoom.name, "hey", null, testRoom, event);
mockClient!.emit(RoomEvent.Receipt, event, testRoom);
expect(
(
MockPlatform.displayNotification.mock.results[0].value as ReturnType<
typeof MockPlatform.displayNotification
>
).close,
).toHaveBeenCalled();
});
it("creates a loud notification when enabled", () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
expect(MockPlatform.loudNotification).toHaveBeenCalledWith(event, testRoom);
});
it("does not create loud notification when event does not have sound tweak in push actions", () => {
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: false,
},
});
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing, null);
emitLiveEvent(event);
// desktop notification created
expect(MockPlatform.displayNotification).toHaveBeenCalled();
// without noisy
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
});
describe("displayPopupNotification", () => {
const testCases: { event: IContent | undefined; count: number }[] = [
{ event: { is_silenced: true }, count: 0 },
{ event: { is_silenced: false }, count: 1 },
{ event: undefined, count: 1 },
];
it.each(testCases)("does not dispatch when notifications are silenced", ({ event, count }) => {
mockClient.setAccountData(accountDataEventKey, event!);
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);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@user:example.com (!room1:server)",
"@user:example.com: test audio message",
"data:image/png;base64,00",
testRoom,
audioEvent,
);
});
it("should strip reply fallback", () => {
const event = mkMessage({
msg: "Test",
event: true,
user: mockClient.getSafeUserId(),
room: testRoom.roomId,
});
const reply = mkMessage({
msg: "This was a triumph",
event: true,
user: mockClient.getSafeUserId(),
room: testRoom.roomId,
});
addReplyToMessageContent(reply.getContent(), event);
Notifier.displayPopupNotification(reply, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@bob:example.org (!room1:server)",
"This was a triumph",
expect.any(String),
testRoom,
reply,
);
});
it.each([
["This was a triumph", "This was a triumph", "This was a triumph"],
["This was a triumph", "<span data-mx-spoiler>This was a triumph</span>", "[Spoiler]"],
["This was a triumph", '<span data-mx-spoiler="triumph">This was a triumph</span>', "[Spoiler]"],
["foo bar baz", "foo <span data-mx-spoiler>bar</span> baz", "foo [Spoiler] baz"],
["foo foo foo", "foo <span data-mx-spoiler>foo</span> foo", "foo [Spoiler] foo"],
[
"a b c d e",
"a <span data-mx-spoiler>b</span> c <span data-mx-spoiler>d</span> e",
"a [Spoiler] c [Spoiler] e",
],
["foo foo", "foo <span data-mx-spoiler></span> foo", "foo [Spoiler] foo"],
["foo bar baz", "foo <span data-mx-spoiler>b<em>a</em>r</span> baz", "foo [Spoiler] baz"],
["foobar", "<span data-mx-spoiler>foo</span><span data-mx-spoiler>bar</span>", "[Spoiler][Spoiler]"],
["foo bar baz", "<strong>foo <span data-mx-spoiler>bar</span> baz</strong>", "foo [Spoiler] baz"],
["foo <bar> baz", "foo <span data-mx-spoiler>&lt;bar&gt;</span> baz", "foo [Spoiler] baz"],
["foo\nbar\nbaz", "foo<span data-mx-spoiler><br>bar<br></span>baz", "foo[Spoiler]baz"],
])("should hide spoilers in notification", (body, formattedBody, expected) => {
const spoilerEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
user: mockClient.getSafeUserId(),
room: testRoom.roomId,
content: {
msgtype: MsgType.Text,
body: body,
format: "org.matrix.custom.html",
formatted_body: formattedBody,
},
});
Notifier.displayPopupNotification(spoilerEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@bob:example.org (!room1:server)",
expected,
expect.any(String),
testRoom,
spoilerEvent,
);
});
});
describe("getSoundForRoom", () => {
it("should not explode if given invalid url", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => {
return { url: { content_uri: "foobar" } };
});
expect(Notifier.getSoundForRoom("!roomId:server")).toBeNull();
});
});
describe("_playAudioNotification", () => {
const testCases: { event: IContent | undefined; count: number }[] = [
{ event: { is_silenced: true }, count: 0 },
{ event: { is_silenced: false }, count: 1 },
{ event: undefined, count: 1 },
];
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();
mockClient.setAccountData(accountDataEventKey, event!);
Notifier.playAudioNotification(testEvent, testRoom);
expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count);
});
});
// Regression test for https://github.com/element-hq/element-web/issues/31996
// On macOS Sequoia, waking from sleep delivers the whole sync backlog in one
// batch, firing playAudioNotification for every backlogged notifying event
// near-simultaneously. Without throttling, the identical sound buffers
// superimpose into one loud "stacked" sound. We coalesce a burst into at
// most one audible play within NOTIFICATION_SOUND_THROTTLE_MS.
describe("playAudioNotification throttle (macOS wake-from-sleep stacking)", () => {
let playSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
// Ensure notifications are not silenced so we exercise the throttle,
// not the silencing gate.
accountDataStore = {};
mockClient.setAccountData(accountDataEventKey, { is_silenced: false });
// Default sound path (no custom room sound).
Notifier.getSoundForRoom = jest.fn().mockReturnValue(null);
// @ts-ignore - backgroundAudio is private
playSpy = jest.spyOn(Notifier.backgroundAudio, "pickFormatAndPlay").mockResolvedValue({} as any);
});
afterEach(() => {
playSpy.mockRestore();
jest.useRealTimers();
});
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);
expect(playSpy).toHaveBeenCalledTimes(1);
});
it("plays again once the throttle window has elapsed", async () => {
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);
expect(playSpy).toHaveBeenCalledTimes(2);
});
it("throttles right up to the window boundary, then plays again (strict `<`)", async () => {
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);
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);
expect(playSpy).toHaveBeenCalledTimes(2);
});
it("does not coalesce two genuinely different sounds within the window (#31996 per-sound keying)", async () => {
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) =>
roomId === testRoom.roomId ? soundA : soundB,
);
// @ts-ignore - backgroundAudio is private
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);
expect(customPlaySpy).toHaveBeenCalledTimes(2);
expect(customPlaySpy).toHaveBeenNthCalledWith(1, soundA.url);
expect(customPlaySpy).toHaveBeenNthCalledWith(2, soundB.url);
customPlaySpy.mockRestore();
});
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);
// Silencing gate short-circuits before the sound is played.
expect(playSpy).not.toHaveBeenCalled();
// ...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);
expect(playSpy).toHaveBeenCalledTimes(1);
});
});
describe("group call notifications", () => {
let callId: string;
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((key, ...params) => {
if (key === "notificationsEnabled") {
return true;
}
return settingsStoreGetValue(key, ...params);
});
jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast");
jest.spyOn(ToastStore.sharedInstance(), "dismissToast");
ToastStore.sharedInstance().reset();
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {},
});
callId = randomUUID();
jest.spyOn(testRoom, "findEventById").mockImplementation((eventId) => {
if (eventId === "$memberEventId") {
return mkEvent({
event: true,
user: "@alice:foo",
type: "org.matrix.msc4143.rtc.member",
content: { call_id: callId } satisfies Partial<SessionMembershipData>,
});
}
return undefined;
});
Notifier.start();
Notifier.onSyncStateChange(SyncState.Syncing, null);
});
afterEach(() => {
jest.restoreAllMocks();
});
const emitCallNotificationEvent = (
params: {
type?: string;
roomMention?: boolean;
lifetime?: number;
ts?: number;
content?: Partial<IContent>;
} = {},
) => {
const { type, roomMention, lifetime, ts, content } = {
type: EventType.RTCNotification,
roomMention: true,
lifetime: 30000,
ts: Date.now(),
...params,
};
const notificationEvent = mkEvent({
type: type,
user: "@alice:foo",
room: roomId,
ts,
content: {
"notification_type": "ring",
"m.relates_to": { rel_type: "m.reference", event_id: "$memberEventId" },
"m.mentions": { user_ids: [], room: roomMention },
lifetime,
"sender_ts": ts,
...content,
},
event: true,
});
emitLiveEvent(notificationEvent);
return notificationEvent;
};
it("shows group call toast", () => {
const notificationEvent = emitCallNotificationEvent();
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
expect.objectContaining({
key: getIncomingCallToastKey(callId, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notificationEvent },
}),
);
});
it("shows group call toast once for multiple notifications to the same call", () => {
// Call the same function twice.
emitCallNotificationEvent();
emitCallNotificationEvent();
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledTimes(1);
});
it("shows group call toast even if the call membership is not stored locally", () => {
jest.spyOn(testRoom, "findEventById").mockReturnValue(undefined);
jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
if (eventId === "$memberEventId" && roomId === testRoom.roomId) {
return {
user: "@alice:foo",
type: "org.matrix.msc4143.rtc.member",
content: { call_id: callId } satisfies Partial<SessionMembershipData>,
};
}
throw new Error("Test mockClient.fetchRoomEvent failed to find event");
});
const notificationEvent = emitCallNotificationEvent();
waitFor(() => {
expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(
expect.objectContaining({
key: getIncomingCallToastKey(callId, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notificationEvent },
}),
);
});
});
it.each<IContent>([
{ "m.relates_to": undefined },
{ "m.relates_to": { rel_type: "m.reference" } },
{ "m.relates_to": { event_id: "$memberEventId", rel_type: "something.else" } },
])("ignores invalid relations for call notification", (content) => {
emitCallNotificationEvent({ content });
waitFor(() => {
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});
});
it("ignores a call if the membership is missing", () => {
jest.spyOn(testRoom, "findEventById").mockReturnValue(undefined);
jest.spyOn(mockClient, "fetchRoomEvent").mockImplementation(async () => {
throw new Error("Test mockClient.fetchRoomEvent expected not to find event");
});
emitCallNotificationEvent();
waitFor(() => {
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});
});
it("should not show toast when group call is already connected", () => {
const members = [
new CallMembership(
mkEvent({
event: true,
room: testRoom.roomId,
user: userId,
type: EventType.GroupCallMemberPrefix,
content: {},
}),
{
// TODO: Once https://github.com/matrix-org/matrix-js-sdk/pull/5134 is merged this can be MembershipKind.Session
kind: "session" as any,
data: {
call_id: "123",
application: "m.call",
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: [],
device_id: "DEVICE",
},
},
"hashed_id_XXXAAAAA",
),
];
const mockRtcSession = {
memberships: members,
slotDescription: { application: "m.call", id: "" },
} as unknown as MatrixRTCSession;
mocked(mockClient.matrixRTC.getRoomSession).mockReturnValue(mockRtcSession);
emitCallNotificationEvent();
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});
it("should not show toast when calling with a different event type to org.matrix.msc4075.rtc.notification", () => {
emitCallNotificationEvent({ type: "event_type" });
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});
it("should not show notification event is expired", () => {
emitCallNotificationEvent({ ts: Date.now() - 40000 });
expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});
});
describe("local notification settings", () => {
const createLocalNotificationSettingsIfNeededMock = mocked(createLocalNotificationSettingsIfNeeded);
let hasStartedNotiferBefore = false;
beforeEach(() => {
// notifier defines some listener functions in start
// and references them in stop
// so blows up if stopped before it was started
if (hasStartedNotiferBefore) {
Notifier.stop();
}
Notifier.start();
hasStartedNotiferBefore = true;
createLocalNotificationSettingsIfNeededMock.mockClear();
});
afterAll(() => {
Notifier.stop();
});
it("does not create local notifications event after a sync error", () => {
mockClient.emit(ClientEvent.Sync, SyncState.Error, SyncState.Syncing);
expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled();
});
it("does not create local notifications event after sync stops", () => {
mockClient.emit(ClientEvent.Sync, SyncState.Stopped, SyncState.Syncing);
expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled();
});
it("does not create local notifications event after a cached sync", () => {
mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing, {
fromCache: true,
});
expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled();
});
it("creates local notifications event after a non-cached sync", () => {
mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing, {});
expect(createLocalNotificationSettingsIfNeededMock).toHaveBeenCalled();
});
});
describe("evaluateEvent", () => {
beforeEach(() => {
jest.spyOn(SDKContextClass.instance.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);
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: true,
},
});
});
it("should show a pop-up", () => {
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier.evaluateEvent(testEvent);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
const eventFromOtherRoom = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: "!otherroom:example.org",
content: {},
});
Notifier.evaluateEvent(eventFromOtherRoom);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
});
it("should a pop-up for thread event", async () => {
const { events, rootEvent } = mkThread({
room: testRoom,
client: mockClient,
authorId: "@bob:example.org",
participantUserIds: ["@bob:example.org"],
});
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier.evaluateEvent(rootEvent);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
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()));
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);
});
});
describe("setPromptHidden", () => {
it("should persist by default", () => {
Notifier.setPromptHidden(true);
expect(localStorage.getItem("notifications_hidden")).toBeTruthy();
});
});
describe("onEvent", () => {
it("should not evaluate events from the thread list fake timeline sets", async () => {
mockClient.supportsThreads.mockReturnValue(true);
const fn = jest.spyOn(Notifier, "evaluateEvent");
await testRoom.createThreadsTimelineSets();
testRoom.threadsTimelineSets[0]!.addEventToTimeline(
mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: roomId,
content: { body: "this is a thread root" },
}),
testRoom.threadsTimelineSets[0]!.getLiveTimeline(),
{ toStartOfTimeline: false, fromCache: false, addToState: true },
);
expect(fn).not.toHaveBeenCalled();
});
});
});