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 IntegrationManagers } from "../integrations/IntegrationManagers";
import { type ModalManager } from "../Modal"; import { type ModalManager } from "../Modal";
import type SettingsStore from "../settings/SettingsStore"; import type SettingsStore from "../settings/SettingsStore";
import { type Notifier } from "../Notifier";
import type RightPanelStore from "../stores/right-panel/RightPanelStore"; import type RightPanelStore from "../stores/right-panel/RightPanelStore";
import type WidgetStore from "../stores/WidgetStore"; import type WidgetStore from "../stores/WidgetStore";
import type LegacyCallHandler from "../LegacyCallHandler"; import type LegacyCallHandler from "../LegacyCallHandler";
@@ -97,7 +96,6 @@ declare global {
mxIntegrationManagers: typeof IntegrationManagers; mxIntegrationManagers: typeof IntegrationManagers;
singletonModalManager: ModalManager; singletonModalManager: ModalManager;
mxSettingsStore: SettingsStore; mxSettingsStore: SettingsStore;
mxNotifier: typeof Notifier;
mxRightPanelStore: RightPanelStore; mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore; mxWidgetStore: WidgetStore;
mxWidgetLayoutStore: WidgetLayoutStore; mxWidgetLayoutStore: WidgetLayoutStore;
+2 -3
View File
@@ -24,7 +24,6 @@ import { MatrixClientPeg, type MatrixClientPegAssignOpts } from "./MatrixClientP
import { ModuleRunner } from "./modules/ModuleRunner"; import { ModuleRunner } from "./modules/ModuleRunner";
import EventIndexPeg from "./indexing/EventIndexPeg"; import EventIndexPeg from "./indexing/EventIndexPeg";
import { createMatrixClient, createClientWithCreds, type IMatrixClientCreds } from "./utils/createMatrixClient"; import { createMatrixClient, createClientWithCreds, type IMatrixClientCreds } from "./utils/createMatrixClient";
import Notifier from "./Notifier";
import UserActivity from "./UserActivity"; import UserActivity from "./UserActivity";
import Presence from "./Presence"; import Presence from "./Presence";
import dis from "./dispatcher/dispatcher"; import dis from "./dispatcher/dispatcher";
@@ -1098,7 +1097,7 @@ async function startMatrixClient(
ToastStore.sharedInstance().reset(); ToastStore.sharedInstance().reset();
DialogOpener.instance.prepare(client); DialogOpener.instance.prepare(client);
Notifier.start(); SDKContextClass.instance.notifier.start();
UserActivity.sharedInstance().start(); UserActivity.sharedInstance().start();
DMRoomMap.makeShared(client).start(); DMRoomMap.makeShared(client).start();
IntegrationManagers.sharedInstance().startWatching(); IntegrationManagers.sharedInstance().startWatching();
@@ -1229,7 +1228,7 @@ export async function clearStorage(opts?: { deleteEverything?: boolean }): Promi
* on MatrixClientPeg after stopping. * on MatrixClientPeg after stopping.
*/ */
export function stopMatrixClient(unsetClient = true): void { export function stopMatrixClient(unsetClient = true): void {
Notifier.stop(); SDKContextClass.instance.notifier.stop();
LegacyCallHandler.instance.stop(); LegacyCallHandler.instance.stop();
UserActivity.sharedInstance().stop(); UserActivity.sharedInstance().stop();
SDKContextClass.instance.typingStore.reset(); 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 PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
import { type SessionMembershipData, type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; import { type SessionMembershipData, type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { PosthogAnalytics } from "./PosthogAnalytics";
import SdkConfig from "./SdkConfig"; import SdkConfig from "./SdkConfig";
import PlatformPeg from "./PlatformPeg"; import PlatformPeg from "./PlatformPeg";
import * as TextForEvent from "./TextForEvent"; import * as TextForEvent from "./TextForEvent";
import * as Avatar from "./Avatar"; import * as Avatar from "./Avatar";
import dis from "./dispatcher/dispatcher";
import { _t } from "./languageHandler"; import { _t } from "./languageHandler";
import Modal from "./Modal"; import Modal from "./Modal";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
@@ -43,12 +40,13 @@ import { isPushNotifyDisabled } from "./settings/controllers/NotificationControl
import UserActivity from "./UserActivity"; import UserActivity from "./UserActivity";
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog"; 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 { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications";
import { getIncomingCallToastKey, getNotificationEventSendTs, IncomingCallToast } from "./toasts/IncomingCallToast"; import { getIncomingCallToastKey, getNotificationEventSendTs, IncomingCallToast } from "./toasts/IncomingCallToast";
import ToastStore from "./stores/ToastStore"; import ToastStore from "./stores/ToastStore";
import { stripPlainReply } from "./utils/Reply"; import { stripPlainReply } from "./utils/Reply";
import { BackgroundAudio } from "./audio/BackgroundAudio"; import { BackgroundAudio } from "./audio/BackgroundAudio";
import { type MatrixDispatcher } from "./dispatcher/dispatcher.ts";
/* /*
* Dispatches: * Dispatches:
@@ -83,27 +81,6 @@ const MAX_PENDING_ENCRYPTED = 20;
*/ */
export const NOTIFICATION_SOUND_THROTTLE_MS = 1000; 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 * Extracts plain text from a message body, replacing any spoilered content
* with '[Spoiler]' to prevent spoilers in desktop notifications. * with '[Spoiler]' to prevent spoilers in desktop notifications.
@@ -175,7 +152,7 @@ export type NotificationSound = {
size?: number; size?: number;
}; };
class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> { export default class Notifier extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> {
private notifsByRoom: Record<string, Notification[]> = {}; private notifsByRoom: Record<string, Notification[]> = {};
// A list of event IDs that we've received but need to wait until // 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 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 { public notificationMessageForEvent(ev: MatrixEvent): string | null {
if (!this.sdkContext.client) return null;
const msgType = ev.getContent().msgtype; const msgType = ev.getContent().msgtype;
if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) { if (msgType && this.msgTypeHandlers.hasOwnProperty(msgType)) {
return msgTypeHandlers[msgType](ev); return this.msgTypeHandlers[msgType](ev);
} }
return TextForEvent.textForEvent(ev, MatrixClientPeg.safeGet()); return TextForEvent.textForEvent(ev, this.sdkContext.client);
} }
// XXX: exported for tests // XXX: exported for tests
public displayPopupNotification(ev: MatrixEvent, room: Room): void { public displayPopupNotification(ev: MatrixEvent, room: Room): void {
const plaf = PlatformPeg.get(); const plaf = PlatformPeg.get();
const cli = MatrixClientPeg.safeGet(); const cli = this.sdkContext.client;
if (!plaf) { if (!plaf || !cli) {
return; return;
} }
if (!plaf.supportsNotifications() || !plaf.maySendNotifications()) { if (!plaf.supportsNotifications() || !plaf.maySendNotifications()) {
@@ -227,7 +235,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
title = room.name; title = room.name;
// notificationMessageForEvent includes sender, but we already have the sender here // notificationMessageForEvent includes sender, but we already have the sender here
const msgType = ev.getContent().msgtype; 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)); msg = stripPlainReply(getNotificationBodyWithoutSpoilers(ev));
} }
} else if (ev.getType() === "m.room.member") { } else if (ev.getType() === "m.room.member") {
@@ -238,7 +246,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
title = ev.sender.name + " (" + room.name + ")"; title = ev.sender.name + " (" + room.name + ")";
// notificationMessageForEvent includes sender, but we've just out sender in the title // notificationMessageForEvent includes sender, but we've just out sender in the title
const msgType = ev.getContent().msgtype; 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)); msg = stripPlainReply(getNotificationBodyWithoutSpoilers(ev));
} }
} }
@@ -300,8 +308,8 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
// XXX: Exported for tests // XXX: Exported for tests
public async playAudioNotification(ev: MatrixEvent, room: Room): Promise<void> { public async playAudioNotification(ev: MatrixEvent, room: Room): Promise<void> {
const cli = MatrixClientPeg.safeGet(); const cli = this.sdkContext.client;
if (localNotificationsAreSilenced(cli)) { if (!cli || localNotificationsAreSilenced(cli)) {
return; return;
} }
@@ -331,22 +339,19 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
} }
public start(): void { public start(): void {
const cli = MatrixClientPeg.safeGet(); this.sdkContext.client!.on(RoomEvent.Timeline, this.onEvent);
cli.on(RoomEvent.Timeline, this.onEvent); this.sdkContext.client!.on(RoomEvent.Receipt, this.onRoomReceipt);
cli.on(RoomEvent.Receipt, this.onRoomReceipt); this.sdkContext.client!.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); this.sdkContext.client!.on(ClientEvent.Sync, this.onSyncStateChange);
cli.on(ClientEvent.Sync, this.onSyncStateChange);
this.toolbarHidden = false; this.toolbarHidden = false;
this.isSyncing = false; this.isSyncing = false;
} }
public stop(): void { public stop(): void {
if (MatrixClientPeg.get()) { this.sdkContext.client?.removeListener(RoomEvent.Timeline, this.onEvent);
MatrixClientPeg.get()!.removeListener(RoomEvent.Timeline, this.onEvent); this.sdkContext.client?.removeListener(RoomEvent.Receipt, this.onRoomReceipt);
MatrixClientPeg.get()!.removeListener(RoomEvent.Receipt, this.onRoomReceipt); this.sdkContext.client?.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
MatrixClientPeg.get()!.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted); this.sdkContext.client?.removeListener(ClientEvent.Sync, this.onSyncStateChange);
MatrixClientPeg.get()!.removeListener(ClientEvent.Sync, this.onSyncStateChange);
}
this.isSyncing = false; this.isSyncing = false;
} }
@@ -390,23 +395,23 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
if (callback) callback(); if (callback) callback();
PosthogAnalytics.instance.trackEvent<PermissionChangedEvent>({ this.sdkContext.posthogAnalytics.trackEvent<PermissionChangedEvent>({
eventName: "PermissionChanged", eventName: "PermissionChanged",
permission: "Notification", permission: "Notification",
granted: true, granted: true,
}); });
dis.dispatch({ this.dispatcher.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
value: true, value: true,
}); });
}); });
} else { } else {
PosthogAnalytics.instance.trackEvent<PermissionChangedEvent>({ this.sdkContext.posthogAnalytics.trackEvent<PermissionChangedEvent>({
eventName: "PermissionChanged", eventName: "PermissionChanged",
permission: "Notification", permission: "Notification",
granted: false, granted: false,
}); });
dis.dispatch({ this.dispatcher.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
value: false, value: false,
}); });
@@ -450,7 +455,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
} }
public shouldShowPrompt(): boolean { public shouldShowPrompt(): boolean {
const client = MatrixClientPeg.get(); const client = this.sdkContext.client;
if (!client) { if (!client) {
return false; return false;
} }
@@ -475,6 +480,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
// XXX: Exported for tests // XXX: Exported for tests
public onSyncStateChange = (state: SyncState, prevState: SyncState | null, data?: SyncStateData): void => { public onSyncStateChange = (state: SyncState, prevState: SyncState | null, data?: SyncStateData): void => {
if (!this.sdkContext.client) return;
if (state === SyncState.Syncing) { if (state === SyncState.Syncing) {
this.isSyncing = true; this.isSyncing = true;
} else if (state === SyncState.Stopped || state === SyncState.Error) { } 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 // wait for first non-cached sync to complete
if (![SyncState.Stopped, SyncState.Error].includes(state) && !data?.fromCache) { 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, removed: boolean,
data: IRoomTimelineData, data: IRoomTimelineData,
): void => { ): void => {
if (!this.sdkContext.client) return;
if (removed) return; // only notify for new events, not removed ones if (removed) return; // only notify for new events, not removed ones
if (!data.liveEvent || !!toStartOfTimeline) return; // only notify for new things, not old. if (!data.liveEvent || !!toStartOfTimeline) return; // only notify for new things, not old.
if (!this.isSyncing) return; // don't alert for any messages initially 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 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', // 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. // 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 // XXX: exported for tests
public evaluateEvent(ev: MatrixEvent): void { public evaluateEvent(ev: MatrixEvent): void {
if (!this.sdkContext.client) return;
const roomId = ev.getRoomId()!; const roomId = ev.getRoomId()!;
const room = MatrixClientPeg.safeGet().getRoom(roomId); const room = this.sdkContext.client.getRoom(roomId);
if (!room) { if (!room) {
// e.g we are in the process of joining a room. // e.g we are in the process of joining a room.
// Seen in the Playwright lazy-loading test. // Seen in the Playwright lazy-loading test.
return; return;
} }
const actions = MatrixClientPeg.safeGet().getPushActionsForEvent(ev); const actions = this.sdkContext.client.getPushActionsForEvent(ev);
if (actions?.notify) { if (actions?.notify) {
this.performCustomEventHandling(ev); this.performCustomEventHandling(ev);
const store = SDKContextClass.instance.roomViewStore; const store = this.sdkContext.roomViewStore;
const isViewingRoom = store.getRoomId() === room.roomId; const isViewingRoom = store.getRoomId() === room.roomId;
const threadId: string | undefined = ev.getId() !== ev.threadRootId ? ev.threadRootId : undefined; const threadId: string | undefined = ev.getId() !== ev.threadRootId ? ev.threadRootId : undefined;
const isViewingThread = store.getThreadId() === threadId; const isViewingThread = store.getThreadId() === threadId;
@@ -670,8 +678,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
*/ */
private performCustomEventHandling(ev: MatrixEvent): void { private performCustomEventHandling(ev: MatrixEvent): void {
const toaster = ToastStore.sharedInstance(); const toaster = ToastStore.sharedInstance();
const cli = MatrixClientPeg.safeGet(); const room = this.sdkContext.client?.getRoom(ev.getRoomId());
const room = cli.getRoom(ev.getRoomId());
if (room && EventType.RTCNotification === ev.getType()) { if (room && EventType.RTCNotification === ev.getType()) {
// We don't need to await this. // 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 PlatformPeg from "../../PlatformPeg";
import SdkConfig, { type ConfigOptions } from "../../SdkConfig"; import SdkConfig, { type ConfigOptions } from "../../SdkConfig";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import Notifier from "../../Notifier";
import Modal from "../../Modal"; import Modal from "../../Modal";
import { showRoomInviteDialog, showStartChatInviteDialog } from "../../RoomInvite"; import { showRoomInviteDialog, showStartChatInviteDialog } from "../../RoomInvite";
import * as Rooms from "../../Rooms"; import * as Rooms from "../../Rooms";
@@ -1638,8 +1637,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.firstSyncComplete = true; this.firstSyncComplete = true;
this.firstSyncPromise.resolve(); this.firstSyncPromise.resolve();
if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) { if (this.stores.notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) {
showNotificationsToast(false); showNotificationsToast(this.stores.notifier, false);
} }
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
@@ -88,7 +88,6 @@ import { containsEmoji } from "../../effects/utils";
import { CHAT_EFFECTS } from "../../effects"; import { CHAT_EFFECTS } from "../../effects";
import { CallView } from "../views/voip/CallView"; import { CallView } from "../views/voip/CallView";
import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../KeyBindingsManager";
@@ -1689,8 +1688,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (!this.state.room) return; if (!this.state.room) return;
const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); 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 they are not alone prompt the user about notifications so they don't miss replies
if (memberCount > 1 && Notifier.shouldShowPrompt()) { if (memberCount > 1 && this.context.notifier.shouldShowPrompt()) {
showNotificationsToast(true); showNotificationsToast(this.context.notifier, true);
} }
} }
@@ -11,7 +11,6 @@ import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import AccessibleButton, { type ButtonEvent } from "../../../elements/AccessibleButton"; import AccessibleButton, { type ButtonEvent } from "../../../elements/AccessibleButton";
import Notifier from "../../../../../Notifier";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../../settings/SettingLevel"; import { SettingLevel } from "../../../../../settings/SettingLevel";
import { type RoomEchoChamber } from "../../../../../stores/local-echo/RoomEchoChamber"; import { type RoomEchoChamber } from "../../../../../stores/local-echo/RoomEchoChamber";
@@ -26,6 +25,7 @@ import { chromeFileInputFix } from "../../../../../utils/BrowserWorkarounds";
import SettingsTab from "../SettingsTab"; import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubsection } from "../../shared/SettingsSubsection"; import { SettingsSubsection } from "../../shared/SettingsSubsection";
import { SDKContextClass } from "../../../../../contexts/SDKContextClass.ts";
interface IProps { interface IProps {
roomId: string; roomId: string;
@@ -50,7 +50,7 @@ export default class NotificationsSettingsTab extends React.Component<IProps, IS
this.roomProps = EchoChamber.forRoom(context.getRoom(this.props.roomId)!); this.roomProps = EchoChamber.forRoom(context.getRoom(this.props.roomId)!);
let currentSound = "default"; let currentSound = "default";
const soundData = Notifier.getSoundForRoom(this.props.roomId); const soundData = SDKContextClass.instance.notifier.getSoundForRoom(this.props.roomId);
if (soundData) { if (soundData) {
currentSound = soundData.name || soundData.url; 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 { type ActionPayload, isAction } from "../dispatcher/payloads.ts";
import { Action } from "../dispatcher/actions.ts"; import { Action } from "../dispatcher/actions.ts";
import { type OnLoggedInPayload } from "../dispatcher/payloads/OnLoggedInPayload.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 * 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 _OidcClientStore?: OidcClientStore;
protected _ResizeNotifier?: ResizeNotifier; protected _ResizeNotifier?: ResizeNotifier;
protected _MultiRoomViewStore?: MultiRoomViewStore; protected _MultiRoomViewStore?: MultiRoomViewStore;
protected _Notifier?: Notifier;
public constructor() { public constructor() {
SettingController.sdkContext = this;
defaultDispatcher.register(this.onDispatch); defaultDispatcher.register(this.onDispatch);
} }
@@ -205,6 +210,13 @@ export class SDKContextClass {
return this._MultiRoomViewStore; return this._MultiRoomViewStore;
} }
public get notifier(): Notifier {
if (!this._Notifier) {
this._Notifier = new Notifier(defaultDispatcher, this);
}
return this._Notifier;
}
public onLoggedOut(): void { public onLoggedOut(): void {
this._UserProfilesStore = undefined; this._UserProfilesStore = undefined;
this._OidcClientStore = undefined; this._OidcClientStore = undefined;
@@ -29,14 +29,6 @@ export function isPushNotifyDisabled(): boolean {
return masterRule.enabled && !masterRule.actions.includes(PushRuleActionName.Notify); 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 { export class NotificationsEnabledController extends SettingController {
public getValueOverride( public getValueOverride(
level: SettingLevel, level: SettingLevel,
@@ -44,7 +36,7 @@ export class NotificationsEnabledController extends SettingController {
calculatedValue: any, calculatedValue: any,
calculatedAtLevel: SettingLevel | null, calculatedAtLevel: SettingLevel | null,
): any { ): any {
if (!getNotifier().isPossible()) return false; if (!this.sdkContext.notifier.isPossible()) return false;
if (calculatedValue === null || calculatedAtLevel === "default") { if (calculatedValue === null || calculatedAtLevel === "default") {
return !isPushNotifyDisabled(); return !isPushNotifyDisabled();
@@ -54,15 +46,15 @@ export class NotificationsEnabledController extends SettingController {
} }
public onChange(level: SettingLevel, roomId: string, newValue: any): void { public onChange(level: SettingLevel, roomId: string, newValue: any): void {
if (getNotifier().supportsDesktopNotifications()) { if (this.sdkContext.notifier.supportsDesktopNotifications()) {
getNotifier().setEnabled(newValue); this.sdkContext.notifier.setEnabled(newValue);
} }
} }
} }
export class NotificationBodyEnabledController extends SettingController { export class NotificationBodyEnabledController extends SettingController {
public getValueOverride(level: SettingLevel, roomId: string, calculatedValue: any): any { public getValueOverride(level: SettingLevel, roomId: string, calculatedValue: any): any {
if (!getNotifier().isPossible()) return false; if (!this.sdkContext.notifier.isPossible()) return false;
if (calculatedValue === null) { if (calculatedValue === null) {
return !isPushNotifyDisabled(); return !isPushNotifyDisabled();
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { type SettingLevel } from "../SettingLevel"; import { type SettingLevel } from "../SettingLevel";
import type SettingsStore from "../SettingsStore"; import type SettingsStore from "../SettingsStore";
import { type SDKContextClass } from "../../contexts/SDKContextClass.ts";
/** /**
* Represents a controller for individual settings to alter the reading behaviour * 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. * intended to handle environmental factors for specific settings.
*/ */
export default abstract class SettingController { export default abstract class SettingController {
public static sdkContext: SDKContextClass;
public static settingsStore: typeof SettingsStore; public static settingsStore: typeof SettingsStore;
/** /**
@@ -77,4 +79,12 @@ export default abstract class SettingController {
protected get settingsStore(): typeof SettingsStore { protected get settingsStore(): typeof SettingsStore {
return SettingController.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 { _t } from "../languageHandler";
import Notifier from "../Notifier"; import type Notifier from "../Notifier";
import GenericToast from "../components/views/toasts/GenericToast"; import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import { MatrixClientPeg } from "../MatrixClientPeg"; import { MatrixClientPeg } from "../MatrixClientPeg";
@@ -15,22 +15,22 @@ import { getLocalNotificationAccountDataEventType } from "../utils/notifications
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel"; 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"; 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({ ToastStore.sharedInstance().addOrReplaceToast({
key: TOAST_KEY, key: TOAST_KEY,
title: fromMessageSend title: fromMessageSend
+95 -62
View File
@@ -21,6 +21,7 @@ import {
import { waitFor } from "jest-matrix-react"; import { waitFor } from "jest-matrix-react";
import { CallMembership, type SessionMembershipData, type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; import { CallMembership, type SessionMembershipData, type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import type BasePlatform from "../../src/BasePlatform"; import type BasePlatform from "../../src/BasePlatform";
import Notifier, { NOTIFICATION_SOUND_THROTTLE_MS } from "../../src/Notifier"; import Notifier, { NOTIFICATION_SOUND_THROTTLE_MS } from "../../src/Notifier";
@@ -38,7 +39,6 @@ import {
mockPlatformPeg, mockPlatformPeg,
} from "../test-utils"; } from "../test-utils";
import { getIncomingCallToastKey, IncomingCallToast } from "../../src/toasts/IncomingCallToast"; import { getIncomingCallToastKey, IncomingCallToast } from "../../src/toasts/IncomingCallToast";
import { SDKContextClass } from "../../src/contexts/SDKContextClass";
import UserActivity from "../../src/UserActivity"; import UserActivity from "../../src/UserActivity";
import Modal from "../../src/Modal"; import Modal from "../../src/Modal";
import { mkThread } from "../test-utils/threads"; 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 { type ThreadPayload } from "../../src/dispatcher/payloads/ThreadPayload";
import { Action } from "../../src/dispatcher/actions"; import { Action } from "../../src/dispatcher/actions";
import { addReplyToMessageContent } from "../../src/utils/Reply"; import { addReplyToMessageContent } from "../../src/utils/Reply";
import { TestSDKContext } from "./TestSDKContext.ts";
jest.mock("../../src/utils/notifications", () => ({ jest.mock("../../src/utils/notifications", () => ({
// @ts-ignore // @ts-ignore
@@ -61,6 +62,9 @@ jest.mock("../../src/audio/compat", () => ({
const settingsStoreGetValue = SettingsStore.getValue; const settingsStoreGetValue = SettingsStore.getValue;
describe("Notifier", () => { describe("Notifier", () => {
const context = new TestSDKContext();
let notifier: Notifier;
const roomId = "!room1:server"; const roomId = "!room1:server";
const testEvent = mkEvent({ const testEvent = mkEvent({
event: true, event: true,
@@ -145,6 +149,8 @@ describe("Notifier", () => {
mockClient.pushRules = { mockClient.pushRules = {
global: {}, global: {},
}; };
// @ts-ignore
mockClient.pushProcessor = new PushProcessor(mockClient);
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId!); accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId!);
testRoom = new Room(roomId, mockClient, mockClient.getSafeUserId()); testRoom = new Room(roomId, mockClient, mockClient.getSafeUserId());
@@ -154,9 +160,11 @@ describe("Notifier", () => {
maySendNotifications: jest.fn().mockReturnValue(true), maySendNotifications: jest.fn().mockReturnValue(true),
displayNotification: jest.fn().mockReturnValue({ close: jest.fn() }), displayNotification: jest.fn().mockReturnValue({ close: jest.fn() }),
loudNotification: 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 => { mockClient.getRoom.mockImplementation((id: string | undefined): Room | null => {
if (id === roomId) return testRoom; if (id === roomId) return testRoom;
@@ -165,13 +173,8 @@ describe("Notifier", () => {
}); });
// @ts-ignore // @ts-ignore
Notifier.backgroundAudio.audioContext = mockAudioContext; notifier.backgroundAudio.audioContext = mockAudioContext;
context._client = mockClient;
// 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", () => { describe("triggering notification from events", () => {
@@ -191,9 +194,9 @@ describe("Notifier", () => {
// and references them in stop // and references them in stop
// so blows up if stopped before it was started // so blows up if stopped before it was started
if (hasStartedNotiferBefore) { if (hasStartedNotiferBefore) {
Notifier.stop(); notifier.stop();
} }
Notifier.start(); notifier.start();
hasStartedNotiferBefore = true; hasStartedNotiferBefore = true;
mockClient.getRoom.mockReturnValue(testRoom); mockClient.getRoom.mockReturnValue(testRoom);
mockClient.getPushActionsForEvent.mockReturnValue({ mockClient.getPushActionsForEvent.mockReturnValue({
@@ -215,7 +218,7 @@ describe("Notifier", () => {
}); });
afterAll(() => { afterAll(() => {
Notifier.stop(); notifier.stop();
}); });
it("does not create notifications before syncing has started", () => { 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 }) => { it.each(testCases)("does not dispatch when notifications are silenced", ({ event, count }) => {
mockClient.setAccountData(accountDataEventKey, event!); mockClient.setAccountData(accountDataEventKey, event!);
Notifier.displayPopupNotification(testEvent, testRoom); notifier.displayPopupNotification(testEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count); expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count);
}); });
it("should display a notification for a voice message", () => { it("should display a notification for a voice message", () => {
const audioEvent = mkAudioEvent(); const audioEvent = mkAudioEvent();
Notifier.displayPopupNotification(audioEvent, testRoom); notifier.displayPopupNotification(audioEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith( expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@user:example.com (!room1:server)", "@user:example.com (!room1:server)",
"@user:example.com: test audio message", "@user:example.com: test audio message",
@@ -358,7 +361,7 @@ describe("Notifier", () => {
room: testRoom.roomId, room: testRoom.roomId,
}); });
addReplyToMessageContent(reply.getContent(), event); addReplyToMessageContent(reply.getContent(), event);
Notifier.displayPopupNotification(reply, testRoom); notifier.displayPopupNotification(reply, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith( expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@bob:example.org (!room1:server)", "@bob:example.org (!room1:server)",
"This was a triumph", "This was a triumph",
@@ -398,7 +401,7 @@ describe("Notifier", () => {
formatted_body: formattedBody, formatted_body: formattedBody,
}, },
}); });
Notifier.displayPopupNotification(spoilerEvent, testRoom); notifier.displayPopupNotification(spoilerEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith( expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
"@bob:example.org (!room1:server)", "@bob:example.org (!room1:server)",
expected, expected,
@@ -414,7 +417,7 @@ describe("Notifier", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => { jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => {
return { url: { content_uri: "foobar" } }; 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.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 // It's not ideal to only look at whether this function has been called
// but avoids starting to look into DOM stuff // but avoids starting to look into DOM stuff
Notifier.getSoundForRoom = jest.fn(); notifier.getSoundForRoom = jest.fn();
mockClient.setAccountData(accountDataEventKey, event!); mockClient.setAccountData(accountDataEventKey, event!);
Notifier.playAudioNotification(testEvent, testRoom); notifier.playAudioNotification(testEvent, testRoom);
expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); expect(notifier.getSoundForRoom).toHaveBeenCalledTimes(count);
}); });
}); });
@@ -454,10 +457,10 @@ describe("Notifier", () => {
mockClient.setAccountData(accountDataEventKey, { is_silenced: false }); mockClient.setAccountData(accountDataEventKey, { is_silenced: false });
// Default sound path (no custom room sound). // Default sound path (no custom room sound).
Notifier.getSoundForRoom = jest.fn().mockReturnValue(null); jest.spyOn(notifier, "getSoundForRoom").mockReturnValue(null);
// @ts-ignore - backgroundAudio is private // @ts-ignore - backgroundAudio is private
playSpy = jest.spyOn(Notifier.backgroundAudio, "pickFormatAndPlay").mockResolvedValue({} as any); playSpy = jest.spyOn(notifier.backgroundAudio, "pickFormatAndPlay").mockResolvedValue({} as any);
}); });
afterEach(() => { afterEach(() => {
@@ -467,36 +470,36 @@ describe("Notifier", () => {
it("plays at most one sound for a burst of notifications within the throttle window", async () => { 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. // 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); expect(playSpy).toHaveBeenCalledTimes(1);
}); });
it("plays again once the throttle window has elapsed", async () => { it("plays again once the throttle window has elapsed", async () => {
await Notifier.playAudioNotification(testEvent, testRoom); await notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(1); expect(playSpy).toHaveBeenCalledTimes(1);
// Advance the clock just past the throttle window. // Advance the clock just past the throttle window.
jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS + 1); jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS + 1);
await Notifier.playAudioNotification(testEvent, testRoom); await notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(2); expect(playSpy).toHaveBeenCalledTimes(2);
}); });
it("throttles right up to the window boundary, then plays again (strict `<`)", async () => { 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); expect(playSpy).toHaveBeenCalledTimes(1);
// One ms before the window elapses: still throttled. // One ms before the window elapses: still throttled.
jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS - 1); jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS - 1);
await Notifier.playAudioNotification(testEvent, testRoom); await notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(1); expect(playSpy).toHaveBeenCalledTimes(1);
// Exactly at the window boundary: plays again (the comparison is a strict `<`). // Exactly at the window boundary: plays again (the comparison is a strict `<`).
jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS); jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS);
await Notifier.playAudioNotification(testEvent, testRoom); await notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(2); expect(playSpy).toHaveBeenCalledTimes(2);
}); });
@@ -504,16 +507,16 @@ describe("Notifier", () => {
const soundA = { url: "sound-a.mp3", name: "A", type: "audio/mpeg", size: 1 }; 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 soundB = { url: "sound-b.mp3", name: "B", type: "audio/mpeg", size: 1 };
const otherRoom = new Room("!other:server", mockClient, mockClient.getSafeUserId()); 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, roomId === testRoom.roomId ? soundA : soundB,
); );
// @ts-ignore - backgroundAudio is private // @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 // Two different sounds back-to-back within the window: BOTH must play (only identical
// backlogged sounds are coalesced). // backlogged sounds are coalesced).
await Notifier.playAudioNotification(testEvent, testRoom); await notifier.playAudioNotification(testEvent, testRoom);
await Notifier.playAudioNotification(testEvent, otherRoom); await notifier.playAudioNotification(testEvent, otherRoom);
expect(customPlaySpy).toHaveBeenCalledTimes(2); expect(customPlaySpy).toHaveBeenCalledTimes(2);
expect(customPlaySpy).toHaveBeenNthCalledWith(1, soundA.url); 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 () => { it("does not play, and does not arm the throttle, when notifications are silenced", async () => {
mockClient.setAccountData(accountDataEventKey, { is_silenced: true }); 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. // Silencing gate short-circuits before the sound is played.
expect(playSpy).not.toHaveBeenCalled(); 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 // ...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). // immediately (a regression arming the throttle on silenced events would suppress this).
mockClient.setAccountData(accountDataEventKey, { is_silenced: false }); mockClient.setAccountData(accountDataEventKey, { is_silenced: false });
await Notifier.playAudioNotification(testEvent, testRoom); await notifier.playAudioNotification(testEvent, testRoom);
expect(playSpy).toHaveBeenCalledTimes(1); expect(playSpy).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -567,8 +570,8 @@ describe("Notifier", () => {
} }
return undefined; return undefined;
}); });
Notifier.start(); notifier.start();
Notifier.onSyncStateChange(SyncState.Syncing, null); notifier.onSyncStateChange(SyncState.Syncing, null);
}); });
afterEach(() => { afterEach(() => {
@@ -738,15 +741,15 @@ describe("Notifier", () => {
// and references them in stop // and references them in stop
// so blows up if stopped before it was started // so blows up if stopped before it was started
if (hasStartedNotiferBefore) { if (hasStartedNotiferBefore) {
Notifier.stop(); notifier.stop();
} }
Notifier.start(); notifier.start();
hasStartedNotiferBefore = true; hasStartedNotiferBefore = true;
createLocalNotificationSettingsIfNeededMock.mockClear(); createLocalNotificationSettingsIfNeededMock.mockClear();
}); });
afterAll(() => { afterAll(() => {
Notifier.stop(); notifier.stop();
}); });
it("does not create local notifications event after a sync error", () => { it("does not create local notifications event after a sync error", () => {
@@ -774,14 +777,14 @@ describe("Notifier", () => {
describe("evaluateEvent", () => { describe("evaluateEvent", () => {
beforeEach(() => { 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(UserActivity.sharedInstance(), "userActiveRecently").mockReturnValue(true);
jest.spyOn(Modal, "hasDialogs").mockReturnValue(false); jest.spyOn(Modal, "hasDialogs").mockReturnValue(false);
jest.spyOn(Notifier, "displayPopupNotification").mockReset(); jest.spyOn(notifier, "displayPopupNotification").mockReset();
jest.spyOn(Notifier, "isEnabled").mockReturnValue(true); jest.spyOn(notifier, "isEnabled").mockReturnValue(true);
mockClient.getPushActionsForEvent.mockReturnValue({ mockClient.getPushActionsForEvent.mockReturnValue({
notify: true, notify: true,
@@ -792,9 +795,9 @@ describe("Notifier", () => {
}); });
it("should show a pop-up", () => { it("should show a pop-up", () => {
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0); expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier.evaluateEvent(testEvent); notifier.evaluateEvent(testEvent);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0); expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
const eventFromOtherRoom = mkEvent({ const eventFromOtherRoom = mkEvent({
event: true, event: true,
@@ -804,8 +807,8 @@ describe("Notifier", () => {
content: {}, content: {},
}); });
Notifier.evaluateEvent(eventFromOtherRoom); notifier.evaluateEvent(eventFromOtherRoom);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
}); });
it("should a pop-up for thread event", async () => { it("should a pop-up for thread event", async () => {
@@ -816,34 +819,34 @@ describe("Notifier", () => {
participantUserIds: ["@bob:example.org"], participantUserIds: ["@bob:example.org"],
}); });
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0); expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier.evaluateEvent(rootEvent); notifier.evaluateEvent(rootEvent);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(0); expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(0);
Notifier.evaluateEvent(events[1]); notifier.evaluateEvent(events[1]);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
dis.dispatch<ThreadPayload>({ dis.dispatch<ThreadPayload>({
action: Action.ViewThread, action: Action.ViewThread,
thread_id: rootEvent.getId()!, 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]); notifier.evaluateEvent(events[1]);
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
}); });
it("should show a pop-up for an audio message", () => { it("should show a pop-up for an audio message", () => {
Notifier.evaluateEvent(mkAudioEvent()); notifier.evaluateEvent(mkAudioEvent());
expect(Notifier.displayPopupNotification).toHaveBeenCalledTimes(1); expect(notifier.displayPopupNotification).toHaveBeenCalledTimes(1);
}); });
}); });
describe("setPromptHidden", () => { describe("setPromptHidden", () => {
it("should persist by default", () => { it("should persist by default", () => {
Notifier.setPromptHidden(true); notifier.setPromptHidden(true);
expect(localStorage.getItem("notifications_hidden")).toBeTruthy(); 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 () => { it("should not evaluate events from the thread list fake timeline sets", async () => {
mockClient.supportsThreads.mockReturnValue(true); mockClient.supportsThreads.mockReturnValue(true);
const fn = jest.spyOn(Notifier, "evaluateEvent"); const fn = jest.spyOn(notifier, "evaluateEvent");
await testRoom.createThreadsTimelineSets(); await testRoom.createThreadsTimelineSets();
testRoom.threadsTimelineSets[0]!.addEventToTimeline( testRoom.threadsTimelineSets[0]!.addEventToTimeline(
@@ -870,4 +873,34 @@ describe("Notifier", () => {
expect(fn).not.toHaveBeenCalled(); 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 { SettingLevel } from "../../src/settings/SettingLevel.ts";
import SdkConfig from "../../src/SdkConfig.ts"; import SdkConfig from "../../src/SdkConfig.ts";
import { BugReportEndpointURLLocal } from "../../src/IConfigOptions.ts"; import { BugReportEndpointURLLocal } from "../../src/IConfigOptions.ts";
import { Notifier } from "../../src/Notifier.ts";
import { MatrixClientPeg } from "../../src/MatrixClientPeg.ts"; import { MatrixClientPeg } from "../../src/MatrixClientPeg.ts";
import { SDKContextClass } from "../../src/contexts/SDKContextClass.ts";
describe("Rageshakes", () => { describe("Rageshakes", () => {
let mockClient: Mocked<MatrixClient>; let mockClient: Mocked<MatrixClient>;
@@ -360,7 +360,7 @@ describe("Rageshakes", () => {
describe("Settings Store", () => { describe("Settings Store", () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(Notifier, "isPossible").mockReturnValue(true); jest.spyOn(SDKContextClass.instance.notifier, "isPossible").mockReturnValue(true);
}); });
afterEach(() => { afterEach(() => {
@@ -406,7 +406,7 @@ describe("Rageshakes", () => {
it("should handle settings throwing when logged out", async () => { it("should handle settings throwing when logged out", async () => {
jest.mocked(MatrixClientPeg.get).mockRestore(); jest.mocked(MatrixClientPeg.get).mockRestore();
jest.mocked(MatrixClientPeg.safeGet).mockRestore(); jest.mocked(MatrixClientPeg.safeGet).mockRestore();
jest.spyOn(Notifier, "isPossible").mockImplementation(() => { jest.spyOn(SDKContextClass.instance.notifier, "isPossible").mockImplementation(() => {
throw new Error("Test"); throw new Error("Test");
}); });
@@ -419,7 +419,7 @@ describe("Rageshakes", () => {
it("should handle reading notification settings when logged out", async () => { it("should handle reading notification settings when logged out", async () => {
jest.mocked(MatrixClientPeg.get).mockRestore(); jest.mocked(MatrixClientPeg.get).mockRestore();
jest.mocked(MatrixClientPeg.safeGet).mockRestore(); jest.mocked(MatrixClientPeg.safeGet).mockRestore();
jest.spyOn(Notifier, "isPossible").mockReturnValue(true); jest.spyOn(SDKContextClass.instance.notifier, "isPossible").mockReturnValue(true);
const formData = await collectBugReport(); const formData = await collectBugReport();
expect(JSON.parse(formData.get("mx_local_settings") as string)["notificationsEnabled"]).toBe(false); expect(JSON.parse(formData.get("mx_local_settings") as string)["notificationsEnabled"]).toBe(false);