From c5a2109ef5815518d7d98680aba718f7c543010d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0smail=20=C3=96zsoy?= <111320991+MatrimAl@users.noreply.github.com> Date: Fri, 15 May 2026 14:07:08 +0300 Subject: [PATCH] Incoming Element Calls now trigger a regular OS notification (#33499) * Fix missing OS-level desktop notification for Element Call (MSC4075) * Add Unit-test for textForCallInviteEvent and lint fixes * Refactor call invite notification * prettier fix * Added mock supportsVoip for notifier-test --- apps/web/src/TextForEvent.tsx | 57 +++++++++++++------ apps/web/test/unit-tests/Notifier-test.ts | 3 + .../web/test/unit-tests/TextForEvent-test.tsx | 42 ++++++++++++++ 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/apps/web/src/TextForEvent.tsx b/apps/web/src/TextForEvent.tsx index b9ea96dcd8..32dfb213b1 100644 --- a/apps/web/src/TextForEvent.tsx +++ b/apps/web/src/TextForEvent.tsx @@ -23,6 +23,7 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; import { removeDirectionOverrideChars } from "matrix-js-sdk/src/utils"; import { type PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; +import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; import { _t } from "./languageHandler"; import * as Roles from "./Roles"; @@ -60,26 +61,47 @@ function textForCallEvent(event: MatrixEvent, client: MatrixClient): () => strin // any text to display at all. For this reason they return deferred values // to avoid the expense of looking up translations when they're not needed. -function textForCallInviteEvent(event: MatrixEvent, client: MatrixClient): (() => string) | null { - const senderName = getSenderName(event); - // FIXME: Find a better way to determine this from the event? - const isVoice = !event.getContent().offer?.sdp?.includes("m=video"); - const isSupported = client.supportsVoip(); - - // This ladder could be reduced down to a couple string variables, however other languages +function getCallInviteText(isVoice: boolean, isSupported: boolean, senderName: string): () => string { + // This logic could be reduced down to dynamic string keys, however other languages // can have a hard time translating those strings. In an effort to make translations easier - // and more accurate, we break out the string-based variables to a couple booleans. - if (isVoice && isSupported) { - return () => _t("timeline|m.call.invite|voice_call", { senderName }); - } else if (isVoice && !isSupported) { - return () => _t("timeline|m.call.invite|voice_call_unsupported", { senderName }); - } else if (!isVoice && isSupported) { - return () => _t("timeline|m.call.invite|video_call", { senderName }); - } else if (!isVoice && !isSupported) { - return () => _t("timeline|m.call.invite|video_call_unsupported", { senderName }); + // and more accurate, we use explicit strings for each combination. + if (isVoice) { + return isSupported + ? () => _t("timeline|m.call.invite|voice_call", { senderName }) + : () => _t("timeline|m.call.invite|voice_call_unsupported", { senderName }); } - return null; + return isSupported + ? () => _t("timeline|m.call.invite|video_call", { senderName }) + : () => _t("timeline|m.call.invite|video_call_unsupported", { senderName }); +} + +/** + * Resolves the textual content for incoming legacy WebRTC call events. + */ +function textForCallInviteEvent(event: MatrixEvent, client: MatrixClient): (() => string) | null { + const senderName = getSenderName(event); + const content = event.getContent(); + + // Fallback for legacy WebRTC signaling + // FIXME: Find a better way to determine this from the event? + const isVoice = !content.offer?.sdp?.includes("m=video"); + const isSupported = client.supportsVoip(); + + return getCallInviteText(isVoice, isSupported, senderName); +} + +/** + * Resolves the textual content for incoming MatrixRTC notifications (MSC4075). + */ +function textForRTCNotificationEvent(event: MatrixEvent, client: MatrixClient): (() => string) | null { + const senderName = getSenderName(event); + const content = event.getContent(); + + const isVoice = content["m.call.intent"] === "audio"; + const isSupported = client.supportsVoip(); + + return getCallInviteText(isVoice, isSupported, senderName); } enum Modification { @@ -881,6 +903,7 @@ const handlers: IHandlers = { [EventType.RoomMessage]: textForMessageEvent, [EventType.Sticker]: textForMessageEvent, [EventType.CallInvite]: textForCallInviteEvent, + [EventType.RTCNotification]: textForRTCNotificationEvent, [M_POLL_START.name]: textForPollStartEvent, [M_POLL_END.name]: textForPollEndEvent, [M_POLL_START.altName]: textForPollStartEvent, diff --git a/apps/web/test/unit-tests/Notifier-test.ts b/apps/web/test/unit-tests/Notifier-test.ts index bd1b20c0be..c618631fca 100644 --- a/apps/web/test/unit-tests/Notifier-test.ts +++ b/apps/web/test/unit-tests/Notifier-test.ts @@ -131,6 +131,9 @@ describe("Notifier", () => { decryptEventIfNeeded: jest.fn(), getRoom: jest.fn(), getPushActionsForEvent: jest.fn(), + // Mock required because TextForEvent now evaluates supportsVoip for RTCNotification to trigger OS popups. + // The true/false value is arbitrary here, as this test only verifies the in-app toast creation, not the OS text output. + supportsVoip: jest.fn().mockReturnValue(true), supportsThreads: jest.fn().mockReturnValue(false), matrixRTC: { on: jest.fn(), diff --git a/apps/web/test/unit-tests/TextForEvent-test.tsx b/apps/web/test/unit-tests/TextForEvent-test.tsx index c6c23bffa2..6b5afac432 100644 --- a/apps/web/test/unit-tests/TextForEvent-test.tsx +++ b/apps/web/test/unit-tests/TextForEvent-test.tsx @@ -492,6 +492,48 @@ describe("TextForEvent", () => { }); }); + describe("textForCallInviteEvent()", () => { + let mockClient: MatrixClient; + let callInviteEvent: MatrixEvent; + + beforeEach(() => { + stubClient(); + mockClient = MatrixClientPeg.safeGet(); + + mocked(mockClient.supportsVoip).mockReturnValue(true); + + callInviteEvent = { + getSender: jest.fn().mockReturnValue("@alice:matrix.org"), + sender: { name: "Alice" }, + getContent: jest.fn().mockReturnValue({}), + getType: jest.fn().mockReturnValue(EventType.CallInvite), + isState: jest.fn().mockReturnValue(false), + } as unknown as MatrixEvent; + }); + + it("returns correct message for legacy voice call invite", () => { + mocked(callInviteEvent).getContent.mockReturnValue({ offer: { sdp: "m=audio" } }); + expect(textForEvent(callInviteEvent, mockClient)).toEqual("Alice placed a voice call."); + }); + + it("returns correct message for legacy video call invite", () => { + mocked(callInviteEvent).getContent.mockReturnValue({ offer: { sdp: "m=video" } }); + expect(textForEvent(callInviteEvent, mockClient)).toEqual("Alice placed a video call."); + }); + + it("returns correct message for MSC4075 voice call", () => { + mocked(callInviteEvent).getType.mockReturnValue(EventType.RTCNotification); + mocked(callInviteEvent).getContent.mockReturnValue({ "m.call.intent": "audio" }); + expect(textForEvent(callInviteEvent, mockClient)).toEqual("Alice placed a voice call."); + }); + + it("returns correct message for MSC4075 video call", () => { + mocked(callInviteEvent).getType.mockReturnValue(EventType.RTCNotification); + mocked(callInviteEvent).getContent.mockReturnValue({ "m.call.intent": "video" }); + expect(textForEvent(callInviteEvent, mockClient)).toEqual("Alice placed a video call."); + }); + }); + describe("textForMemberEvent()", () => { beforeEach(() => { stubClient();