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:
Michael Telatynski
2026-07-06 19:20:51 +00:00
committed by GitHub
parent 08419cf0cf
commit a26eb9f578
12 changed files with 214 additions and 172 deletions
-2
View File
@@ -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;
+2 -3
View File
@@ -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();
+66 -66
View File
@@ -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, (event: MatrixEvent) => 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<keyof EmittedEvents, EmittedEvents> {
export default class Notifier extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> {
private notifsByRoom: Record<string, Notification[]> = {};
// A list of event IDs that we've received but need to wait until
@@ -196,19 +173,50 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
*/
private readonly lastAudioNotificationMs = new Map<string, number>();
private msgTypeHandlers: Record<string, (event: MatrixEvent) => 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<keyof EmittedEvents, EmittedEvents
title = room.name;
// notificationMessageForEvent includes sender, but we already have the sender here
const msgType = ev.getContent().msgtype;
if (ev.getContent().body && (!msgType || !msgTypeHandlers.hasOwnProperty(msgType))) {
if (ev.getContent().body && (!msgType || !this.msgTypeHandlers.hasOwnProperty(msgType))) {
msg = stripPlainReply(getNotificationBodyWithoutSpoilers(ev));
}
} else if (ev.getType() === "m.room.member") {
@@ -238,7 +246,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
title = ev.sender.name + " (" + room.name + ")";
// notificationMessageForEvent includes sender, but we've just out sender in the title
const msgType = ev.getContent().msgtype;
if (ev.getContent().body && (!msgType || !msgTypeHandlers.hasOwnProperty(msgType))) {
if (ev.getContent().body && (!msgType || !this.msgTypeHandlers.hasOwnProperty(msgType))) {
msg = stripPlainReply(getNotificationBodyWithoutSpoilers(ev));
}
}
@@ -300,8 +308,8 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
// XXX: Exported for tests
public async playAudioNotification(ev: MatrixEvent, room: Room): Promise<void> {
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<keyof EmittedEvents, EmittedEvents
}
public start(): void {
const cli = MatrixClientPeg.safeGet();
cli.on(RoomEvent.Timeline, this.onEvent);
cli.on(RoomEvent.Receipt, this.onRoomReceipt);
cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
cli.on(ClientEvent.Sync, this.onSyncStateChange);
this.sdkContext.client!.on(RoomEvent.Timeline, this.onEvent);
this.sdkContext.client!.on(RoomEvent.Receipt, this.onRoomReceipt);
this.sdkContext.client!.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.sdkContext.client!.on(ClientEvent.Sync, this.onSyncStateChange);
this.toolbarHidden = false;
this.isSyncing = false;
}
public stop(): void {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get()!.removeListener(RoomEvent.Timeline, this.onEvent);
MatrixClientPeg.get()!.removeListener(RoomEvent.Receipt, this.onRoomReceipt);
MatrixClientPeg.get()!.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
MatrixClientPeg.get()!.removeListener(ClientEvent.Sync, this.onSyncStateChange);
}
this.sdkContext.client?.removeListener(RoomEvent.Timeline, this.onEvent);
this.sdkContext.client?.removeListener(RoomEvent.Receipt, this.onRoomReceipt);
this.sdkContext.client?.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.sdkContext.client?.removeListener(ClientEvent.Sync, this.onSyncStateChange);
this.isSyncing = false;
}
@@ -390,23 +395,23 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
if (callback) callback();
PosthogAnalytics.instance.trackEvent<PermissionChangedEvent>({
this.sdkContext.posthogAnalytics.trackEvent<PermissionChangedEvent>({
eventName: "PermissionChanged",
permission: "Notification",
granted: true,
});
dis.dispatch({
this.dispatcher.dispatch({
action: "notifier_enabled",
value: true,
});
});
} else {
PosthogAnalytics.instance.trackEvent<PermissionChangedEvent>({
this.sdkContext.posthogAnalytics.trackEvent<PermissionChangedEvent>({
eventName: "PermissionChanged",
permission: "Notification",
granted: false,
});
dis.dispatch({
this.dispatcher.dispatch({
action: "notifier_enabled",
value: false,
});
@@ -450,7 +455,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
}
public shouldShowPrompt(): boolean {
const client = MatrixClientPeg.get();
const client = this.sdkContext.client;
if (!client) {
return false;
}
@@ -475,6 +480,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
// XXX: Exported for tests
public onSyncStateChange = (state: SyncState, prevState: SyncState | null, data?: SyncStateData): void => {
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<keyof EmittedEvents, EmittedEvents
// wait for first non-cached sync to complete
if (![SyncState.Stopped, SyncState.Error].includes(state) && !data?.fromCache) {
createLocalNotificationSettingsIfNeeded(MatrixClientPeg.safeGet());
createLocalNotificationSettingsIfNeeded(this.sdkContext.client);
}
};
@@ -494,13 +500,14 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
removed: boolean,
data: IRoomTimelineData,
): void => {
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<keyof EmittedEvents, EmittedEvents
// XXX: exported for tests
public evaluateEvent(ev: MatrixEvent): void {
if (!this.sdkContext.client) return;
const roomId = ev.getRoomId()!;
const room = MatrixClientPeg.safeGet().getRoom(roomId);
const room = this.sdkContext.client.getRoom(roomId);
if (!room) {
// e.g we are in the process of joining a room.
// Seen in the Playwright lazy-loading test.
return;
}
const actions = MatrixClientPeg.safeGet().getPushActionsForEvent(ev);
const actions = this.sdkContext.client.getPushActionsForEvent(ev);
if (actions?.notify) {
this.performCustomEventHandling(ev);
const store = SDKContextClass.instance.roomViewStore;
const store = this.sdkContext.roomViewStore;
const isViewingRoom = store.getRoomId() === room.roomId;
const threadId: string | undefined = ev.getId() !== ev.threadRootId ? ev.threadRootId : undefined;
const isViewingThread = store.getThreadId() === threadId;
@@ -670,8 +678,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
*/
private performCustomEventHandling(ev: MatrixEvent): void {
const toaster = ToastStore.sharedInstance();
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(ev.getRoomId());
const room = this.sdkContext.client?.getRoom(ev.getRoomId());
if (room && EventType.RTCNotification === ev.getType()) {
// We don't need to await this.
@@ -679,10 +686,3 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
}
}
}
if (!window.mxNotifier) {
window.mxNotifier = new NotifierClass();
}
export default window.mxNotifier;
export const Notifier: NotifierClass = window.mxNotifier;
@@ -37,7 +37,6 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig, { type ConfigOptions } from "../../SdkConfig";
import dis from "../../dispatcher/dispatcher";
import Notifier from "../../Notifier";
import Modal from "../../Modal";
import { showRoomInviteDialog, showStartChatInviteDialog } from "../../RoomInvite";
import * as Rooms from "../../Rooms";
@@ -1638,8 +1637,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
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);
@@ -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<IRoomProps, IRoomState> {
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);
}
}
@@ -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<IProps, IS
this.roomProps = EchoChamber.forRoom(context.getRoom(this.props.roomId)!);
let currentSound = "default";
const soundData = Notifier.getSoundForRoom(this.props.roomId);
const soundData = SDKContextClass.instance.notifier.getSoundForRoom(this.props.roomId);
if (soundData) {
currentSound = soundData.name || soundData.url;
}
+12
View File
@@ -29,6 +29,8 @@ import { MultiRoomViewStore } from "../stores/MultiRoomViewStore";
import { type ActionPayload, isAction } from "../dispatcher/payloads.ts";
import { Action } from "../dispatcher/actions.ts";
import { type OnLoggedInPayload } from "../dispatcher/payloads/OnLoggedInPayload.ts";
import Notifier from "../Notifier.ts";
import SettingController from "../settings/controllers/SettingController.ts";
/**
* A class which (mostly) lazily initialises stores as and when they are requested, ensuring they remain
@@ -71,8 +73,11 @@ export class SDKContextClass {
protected _OidcClientStore?: OidcClientStore;
protected _ResizeNotifier?: ResizeNotifier;
protected _MultiRoomViewStore?: MultiRoomViewStore;
protected _Notifier?: Notifier;
public constructor() {
SettingController.sdkContext = this;
defaultDispatcher.register(this.onDispatch);
}
@@ -205,6 +210,13 @@ export class SDKContextClass {
return this._MultiRoomViewStore;
}
public get notifier(): Notifier {
if (!this._Notifier) {
this._Notifier = new Notifier(defaultDispatcher, this);
}
return this._Notifier;
}
public onLoggedOut(): void {
this._UserProfilesStore = undefined;
this._OidcClientStore = undefined;
@@ -29,14 +29,6 @@ export function isPushNotifyDisabled(): boolean {
return masterRule.enabled && !masterRule.actions.includes(PushRuleActionName.Notify);
}
function getNotifier(): any {
// TODO: [TS] Formal type that doesn't cause a cyclical reference.
// eslint-disable-next-line @typescript-eslint/no-require-imports
let Notifier = require("../../Notifier"); // avoids cyclical references
if (Notifier.default) Notifier = Notifier.default; // correct for webpack require() weirdness
return Notifier;
}
export class NotificationsEnabledController extends SettingController {
public getValueOverride(
level: SettingLevel,
@@ -44,7 +36,7 @@ export class NotificationsEnabledController extends SettingController {
calculatedValue: any,
calculatedAtLevel: SettingLevel | null,
): any {
if (!getNotifier().isPossible()) return false;
if (!this.sdkContext.notifier.isPossible()) return false;
if (calculatedValue === null || calculatedAtLevel === "default") {
return !isPushNotifyDisabled();
@@ -54,15 +46,15 @@ export class NotificationsEnabledController extends SettingController {
}
public onChange(level: SettingLevel, roomId: string, newValue: any): void {
if (getNotifier().supportsDesktopNotifications()) {
getNotifier().setEnabled(newValue);
if (this.sdkContext.notifier.supportsDesktopNotifications()) {
this.sdkContext.notifier.setEnabled(newValue);
}
}
}
export class NotificationBodyEnabledController extends SettingController {
public getValueOverride(level: SettingLevel, roomId: string, calculatedValue: any): any {
if (!getNotifier().isPossible()) return false;
if (!this.sdkContext.notifier.isPossible()) return false;
if (calculatedValue === null) {
return !isPushNotifyDisabled();
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { type SettingLevel } from "../SettingLevel";
import type SettingsStore from "../SettingsStore";
import { type SDKContextClass } from "../../contexts/SDKContextClass.ts";
/**
* Represents a controller for individual settings to alter the reading behaviour
@@ -19,6 +20,7 @@ import type SettingsStore from "../SettingsStore";
* intended to handle environmental factors for specific settings.
*/
export default abstract class SettingController {
public static sdkContext: SDKContextClass;
public static settingsStore: typeof SettingsStore;
/**
@@ -77,4 +79,12 @@ export default abstract class SettingController {
protected get settingsStore(): typeof SettingsStore {
return SettingController.settingsStore;
}
/**
* Accessor to the SDKContext injected at runtime.
* Preferred to direct imports in order to avoid import cycles.
*/
protected get sdkContext(): SDKContextClass {
return SettingController.sdkContext;
}
}
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import { _t } from "../languageHandler";
import Notifier from "../Notifier";
import type Notifier from "../Notifier";
import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore";
import { MatrixClientPeg } from "../MatrixClientPeg";
@@ -15,22 +15,22 @@ import { getLocalNotificationAccountDataEventType } from "../utils/notifications
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
const onAccept = async (): Promise<void> => {
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<void> => {
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
+95 -62
View File
@@ -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,
});
});
});
});
@@ -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<MatrixClient>;
@@ -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);