diff --git a/apps/web/src/Notifier.ts b/apps/web/src/Notifier.ts index a274ae883f..a38913de81 100644 --- a/apps/web/src/Notifier.ts +++ b/apps/web/src/Notifier.ts @@ -60,6 +60,29 @@ import { BackgroundAudio } from "./audio/BackgroundAudio"; const MAX_PENDING_ENCRYPTED = 20; +/** + * Minimum interval (in milliseconds) between two *audible* notification plays. + * + * Coalesces bursts of backlogged notifications into a single sound. This is the + * in-repo remedy for https://github.com/element-hq/element-web/issues/31996: on + * macOS Sequoia, waking from sleep delivers the entire sync backlog in one + * batch, so every backlogged notifying event fires {@link + * NotifierClass.playAudioNotification} near-simultaneously and the identical + * audio buffers superimpose into one loud "stacked" sound. + * + * The throttle is keyed on the resolved sound, so a backlog of identical sounds + * coalesces to one play while two genuinely *different* sounds (e.g. a custom + * per-room sound) arriving within the window each still play. A conservative + * window is intentional: merging a burst of the same sound is preferable to a + * wall of overlapping audio. + * + * NOTE: This only cures the sounds-enabled renderer Web-Audio path (the default + * config). It does NOT fix the variant where macOS Sequoia ignores the OS + * banner's `silent: true` and plays its own coalesced banner sound on wake; + * that is purely OS/Electron behaviour with no in-repo lever. + */ +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 @@ -165,6 +188,14 @@ class NotifierClass extends TypedEventEmitter(); + public notificationMessageForEvent(ev: MatrixEvent): string | null { const msgType = ev.getContent().msgtype; if (msgType && msgTypeHandlers.hasOwnProperty(msgType)) { @@ -278,6 +309,20 @@ class NotifierClass extends TypedEventEmitter { // @ts-ignore Notifier.backgroundAudio.audioContext = mockAudioContext; + + // Notifier is a singleton, so its audio-notification throttle state + // (see NOTIFICATION_SOUND_THROTTLE_MS) leaks between tests. Reset it so + // each test exercises a clean instance. + // @ts-ignore - lastAudioNotificationMs is private + Notifier.lastAudioNotificationMs.clear(); }); describe("triggering notification from events", () => { @@ -429,6 +435,109 @@ describe("Notifier", () => { }); }); + // Regression test for https://github.com/element-hq/element-web/issues/31996 + // On macOS Sequoia, waking from sleep delivers the whole sync backlog in one + // batch, firing playAudioNotification for every backlogged notifying event + // near-simultaneously. Without throttling, the identical sound buffers + // superimpose into one loud "stacked" sound. We coalesce a burst into at + // most one audible play within NOTIFICATION_SOUND_THROTTLE_MS. + describe("playAudioNotification throttle (macOS wake-from-sleep stacking)", () => { + let playSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(0); + + // Ensure notifications are not silenced so we exercise the throttle, + // not the silencing gate. + accountDataStore = {}; + mockClient.setAccountData(accountDataEventKey, { is_silenced: false }); + + // Default sound path (no custom room sound). + Notifier.getSoundForRoom = jest.fn().mockReturnValue(null); + + // @ts-ignore - backgroundAudio is private + playSpy = jest.spyOn(Notifier.backgroundAudio, "pickFormatAndPlay").mockResolvedValue({} as any); + }); + + afterEach(() => { + playSpy.mockRestore(); + jest.useRealTimers(); + }); + + it("plays at most one sound for a burst of notifications within the throttle window", async () => { + // Simulate a backlog of notifications arriving back-to-back on wake. + await Notifier.playAudioNotification(testEvent, testRoom); + await Notifier.playAudioNotification(testEvent, testRoom); + await Notifier.playAudioNotification(testEvent, testRoom); + + expect(playSpy).toHaveBeenCalledTimes(1); + }); + + it("plays again once the throttle window has elapsed", async () => { + await Notifier.playAudioNotification(testEvent, testRoom); + expect(playSpy).toHaveBeenCalledTimes(1); + + // Advance the clock just past the throttle window. + jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS + 1); + + await Notifier.playAudioNotification(testEvent, testRoom); + expect(playSpy).toHaveBeenCalledTimes(2); + }); + + it("throttles right up to the window boundary, then plays again (strict `<`)", async () => { + await Notifier.playAudioNotification(testEvent, testRoom); + expect(playSpy).toHaveBeenCalledTimes(1); + + // One ms before the window elapses: still throttled. + jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS - 1); + await Notifier.playAudioNotification(testEvent, testRoom); + expect(playSpy).toHaveBeenCalledTimes(1); + + // Exactly at the window boundary: plays again (the comparison is a strict `<`). + jest.setSystemTime(NOTIFICATION_SOUND_THROTTLE_MS); + await Notifier.playAudioNotification(testEvent, testRoom); + expect(playSpy).toHaveBeenCalledTimes(2); + }); + + it("does not coalesce two genuinely different sounds within the window (#31996 per-sound keying)", async () => { + const soundA = { url: "sound-a.mp3", name: "A", type: "audio/mpeg", size: 1 }; + const soundB = { url: "sound-b.mp3", name: "B", type: "audio/mpeg", size: 1 }; + const otherRoom = new Room("!other:server", mockClient, mockClient.getSafeUserId()); + (Notifier.getSoundForRoom as jest.Mock).mockImplementation((roomId: string) => + roomId === testRoom.roomId ? soundA : soundB, + ); + // @ts-ignore - backgroundAudio is private + const customPlaySpy = jest.spyOn(Notifier.backgroundAudio, "play").mockResolvedValue({} as any); + + // Two different sounds back-to-back within the window: BOTH must play (only identical + // backlogged sounds are coalesced). + await Notifier.playAudioNotification(testEvent, testRoom); + await Notifier.playAudioNotification(testEvent, otherRoom); + + expect(customPlaySpy).toHaveBeenCalledTimes(2); + expect(customPlaySpy).toHaveBeenNthCalledWith(1, soundA.url); + expect(customPlaySpy).toHaveBeenNthCalledWith(2, soundB.url); + customPlaySpy.mockRestore(); + }); + + it("does not play, and does not arm the throttle, when notifications are silenced", async () => { + mockClient.setAccountData(accountDataEventKey, { is_silenced: true }); + + await Notifier.playAudioNotification(testEvent, testRoom); + await Notifier.playAudioNotification(testEvent, testRoom); + + // Silencing gate short-circuits before the sound is played. + expect(playSpy).not.toHaveBeenCalled(); + + // ...and the silenced calls did NOT arm the throttle: once un-silenced, the next event plays + // immediately (a regression arming the throttle on silenced events would suppress this). + mockClient.setAccountData(accountDataEventKey, { is_silenced: false }); + await Notifier.playAudioNotification(testEvent, testRoom); + expect(playSpy).toHaveBeenCalledTimes(1); + }); + }); + describe("group call notifications", () => { let callId: string; beforeEach(() => {