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.
874 lines
35 KiB
TypeScript
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><bar></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();
|
|
});
|
|
});
|
|
});
|