From be80e2161b781f3f340907da75643e523f155124 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 14 Nov 2023 15:41:56 +0100 Subject: [PATCH 01/13] Add ringing for matrixRTC - since we are using m.mentions we start with the Notifier - an event in the Notifier will result in a IncomingCall toast - incomingCallToast is responsible for ringing (as long as one can see the toast it rings) This should make sure visual and audio signal are in sync. Signed-off-by: Timo K --- src/Notifier.ts | 12 ++-- src/models/Call.ts | 22 ++++++- src/toasts/IncomingCallToast.tsx | 79 +++++++++++++++++--------- test/toasts/IncomingCallToast-test.tsx | 2 +- 4 files changed, 82 insertions(+), 33 deletions(-) diff --git a/src/Notifier.ts b/src/Notifier.ts index 37a424c9ed8..3136c1227ae 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -28,6 +28,7 @@ import { SyncStateData, IRoomTimelineData, M_LOCATION, + EventType, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged"; @@ -54,7 +55,6 @@ import { SdkContextClass } from "./contexts/SDKContext"; import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications"; import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; import ToastStore from "./stores/ToastStore"; -import { ElementCall } from "./models/Call"; import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoEventType } from "./voice-broadcast"; import { getSenderName } from "./utils/event/getSenderName"; import { stripPlainReply } from "./utils/Reply"; @@ -516,13 +516,17 @@ class NotifierClass { * Some events require special handling such as showing in-app toasts */ private performCustomEventHandling(ev: MatrixEvent): void { - if (ElementCall.CALL_EVENT_TYPE.names.includes(ev.getType()) && SettingsStore.getValue("feature_group_calls")) { + if ( + EventType.CallNotify === ev.getType() && + SettingsStore.getValue("feature_group_calls") && + (ev?.getAge() ?? 0) < 10000 + ) { ToastStore.sharedInstance().addOrReplaceToast({ - key: getIncomingCallToastKey(ev.getStateKey()!), + key: getIncomingCallToastKey(ev.getContent()?.call_id, ev.getRoomId() ?? ""), priority: 100, component: IncomingCallToast, bodyClassName: "mx_IncomingCallToast", - props: { callEvent: ev }, + props: { notifyEvent: ev }, }); } } diff --git a/src/models/Call.ts b/src/models/Call.ts index 9a841eb9d75..b08f592801a 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -738,10 +738,28 @@ export class ElementCall extends Call { SettingsStore.getValue("feature_video_rooms") && SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom(); - - console.log("Intend is ", isVideoRoom ? "VideoRoom" : "Prompt", " TODO, handle intent appropriately"); ElementCall.createOrGetCallWidget(room.roomId, room.client); WidgetStore.instance.emit(UPDATE_EVENT, null); + + // Send Call notify + + const existingRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter( + // filter all memberships where the application is m.call and the call_id is "" + (m) => m.application === "m.call" && m.callId === "", + ); + + // We only want to ring in rooms that have less then RING_MEMBER_LIMIT participants. For really large rooms we don't want to ring. + const RING_MEMBER_LIMIT = 10; + if (!isVideoRoom && existingRoomCallMembers.length == 0 && room.getJoinedMemberCount() < RING_MEMBER_LIMIT) { + // send ringing event + const content = { + "application": "m.call", + "m.mentions": { user_ids: [], room: true }, + "notify_type": "ring", // or notify + "call_id": "", + }; + await room.client.sendEvent(room.roomId, EventType.CallNotify, content); + } } protected async performConnection( diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index dc754695fba..419e4d365c6 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -14,8 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useEffect } from "react"; -import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { _t } from "../languageHandler"; import RoomAvatar from "../components/views/avatars/RoomAvatar"; @@ -31,14 +35,14 @@ import { LiveContentType, } from "../components/views/rooms/LiveContentSummary"; import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall"; -import { useRoomState } from "../hooks/useRoomState"; import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import { useDispatcher } from "../hooks/useDispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import { Call } from "../models/Call"; -import { useTypedEventEmitter } from "../hooks/useEventEmitter"; +import { AudioID } from "../LegacyCallHandler"; -export const getIncomingCallToastKey = (stateKey: string): string => `call_${stateKey}`; +export const getIncomingCallToastKey = (sessionId: string, roomId: string): string => `call_${sessionId}_${roomId}`; +const MAX_RING_TIME_MS = 10 * 1000; interface JoinCallButtonWithCallProps { onClick: (e: ButtonEvent) => void; @@ -62,36 +66,46 @@ function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps): } interface Props { - callEvent: MatrixEvent; + notifyEvent: MatrixEvent; } -export function IncomingCallToast({ callEvent }: Props): JSX.Element { - const roomId = callEvent.getRoomId()!; +export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { + const roomId = notifyEvent.getRoomId()!; const room = MatrixClientPeg.safeGet().getRoom(roomId) ?? undefined; const call = useCall(roomId); + const audio = useMemo(() => document.getElementById(AudioID.Ring) as HTMLMediaElement, []); - const dismissToast = useCallback((): void => { - ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(callEvent.getStateKey()!)); - }, [callEvent]); + // Start ringing if not already. + useEffect(() => { + const isRingToast = (notifyEvent.getContent() as unknown as { notify_type: String })["notify_type"] == "ring"; + if (isRingToast && audio.paused) { + audio.play(); + } + }, [audio, notifyEvent]); - const latestEvent = useRoomState( - room, - useCallback( - (state) => { - return state.getStateEvents(callEvent.getType(), callEvent.getStateKey()!); - }, - [callEvent], - ), + // Stop ringing on dismiss. + const dismissToast = useCallback((): void => { + ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(notifyEvent.getContent().call_id, roomId)); + audio.pause(); + }, [audio, notifyEvent, roomId]); + + // Dismiss if session got ended remotely. + const onSessionEnded = useCallback( + (endedSessionRoomId: String, session: MatrixRTCSession): void => { + if (roomId == endedSessionRoomId && session.sessionId == notifyEvent.getContent().call_id) { + dismissToast(); + } + }, + [dismissToast, notifyEvent, roomId], ); + // Dismiss on timeout. useEffect(() => { - if ("m.terminated" in latestEvent.getContent()) { - dismissToast(); - } - }, [latestEvent, dismissToast]); - - useTypedEventEmitter(latestEvent, MatrixEventEvent.BeforeRedaction, dismissToast); + const timeout = setTimeout(dismissToast, MAX_RING_TIME_MS); + return () => clearTimeout(timeout); + }); + // Dismiss on viewing call. useDispatcher( defaultDispatcher, useCallback( @@ -104,6 +118,7 @@ export function IncomingCallToast({ callEvent }: Props): JSX.Element { ), ); + // Dismiss on clicking join. const onJoinClick = useCallback( (e: ButtonEvent): void => { e.stopPropagation(); @@ -119,6 +134,7 @@ export function IncomingCallToast({ callEvent }: Props): JSX.Element { [room, dismissToast], ); + // Dismiss on closing toast. const onCloseClick = useCallback( (e: ButtonEvent): void => { e.stopPropagation(); @@ -128,9 +144,20 @@ export function IncomingCallToast({ callEvent }: Props): JSX.Element { [dismissToast], ); + useEffect(() => { + const matrixRTC = MatrixClientPeg.safeGet().matrixRTC; + matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onSessionEnded); + function disconnect(): void { + matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onSessionEnded); + } + return disconnect; + }, [audio, notifyEvent, onSessionEnded]); + return ( - +
+ +
diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index 739775fe9c2..3023f3f0c31 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -97,7 +97,7 @@ describe("IncomingCallEvent", () => { }); const renderToast = () => { - render(); + render(); }; it("correctly shows all the information", () => { From ad26d1d97f32087b2d8191c8ae0060ad7f777e27 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 14 Nov 2023 16:36:36 +0100 Subject: [PATCH 02/13] use typed CallNotifyContent Signed-off-by: Timo K --- src/models/Call.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index b08f592801a..8fda1404123 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -33,6 +33,8 @@ import { IWidgetApiRequest } from "matrix-widget-api"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; // eslint-disable-next-line no-restricted-imports import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; +// eslint-disable-next-line no-restricted-imports +import { ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc/types"; import type EventEmitter from "events"; import type { ClientWidgetApi } from "matrix-widget-api"; @@ -752,12 +754,13 @@ export class ElementCall extends Call { const RING_MEMBER_LIMIT = 10; if (!isVideoRoom && existingRoomCallMembers.length == 0 && room.getJoinedMemberCount() < RING_MEMBER_LIMIT) { // send ringing event - const content = { + const content: ICallNotifyContent = { "application": "m.call", "m.mentions": { user_ids: [], room: true }, - "notify_type": "ring", // or notify + "notify_type": "ring", "call_id": "", }; + await room.client.sendEvent(room.roomId, EventType.CallNotify, content); } } From 71263ea75e900421ee8aa8f16777c2ba24e24ce0 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 14 Nov 2023 20:00:17 +0100 Subject: [PATCH 03/13] update tests Signed-off-by: Timo K --- src/toasts/IncomingCallToast.tsx | 4 ++-- test/toasts/IncomingCallToast-test.tsx | 26 ++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 419e4d365c6..5ac81cc4ed7 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -123,15 +123,15 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { (e: ButtonEvent): void => { e.stopPropagation(); + // The toast will be automatically dismissed by the dispatcher callback above defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room?.roomId, view_call: true, metricsTrigger: undefined, }); - dismissToast(); }, - [room, dismissToast], + [room], ); // Dismiss on closing toast. diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index 3023f3f0c31..c02eb29d10b 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -37,6 +37,7 @@ import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingSt import DMRoomMap from "../../src/utils/DMRoomMap"; import ToastStore from "../../src/stores/ToastStore"; import { getIncomingCallToastKey, IncomingCallToast } from "../../src/toasts/IncomingCallToast"; +import { AudioID } from "../../src/LegacyCallHandler"; describe("IncomingCallEvent", () => { useMockedCalls(); @@ -59,6 +60,10 @@ describe("IncomingCallEvent", () => { stubClient(); client = mocked(MatrixClientPeg.safeGet()); + const audio = document.createElement("audio"); + audio.id = AudioID.Ring; + document.body.appendChild(audio); + room = new Room("!1:example.org", client, "@alice:example.org"); alice = mkRoomMember(room.roomId, "@alice:example.org"); @@ -96,7 +101,12 @@ describe("IncomingCallEvent", () => { jest.restoreAllMocks(); }); + const notifyContent = { + call_id: "", + }; const renderToast = () => { + call.event.getContent = () => notifyContent as any; + render(); }; @@ -141,7 +151,9 @@ describe("IncomingCallEvent", () => { }), ); await waitFor(() => - expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(call.event.getStateKey()!)), + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notifyContent.call_id, room.roomId), + ), ); defaultDispatcher.unregister(dispatcherRef); @@ -155,7 +167,9 @@ describe("IncomingCallEvent", () => { fireEvent.click(screen.getByRole("button", { name: "Close" })); await waitFor(() => - expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(call.event.getStateKey()!)), + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notifyContent.call_id, room.roomId), + ), ); defaultDispatcher.unregister(dispatcherRef); @@ -171,7 +185,9 @@ describe("IncomingCallEvent", () => { }); await waitFor(() => - expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(call.event.getStateKey()!)), + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notifyContent.call_id, room.roomId), + ), ); }); @@ -182,7 +198,9 @@ describe("IncomingCallEvent", () => { event.emit(MatrixEventEvent.BeforeRedaction, event, {} as unknown as MatrixEvent); await waitFor(() => - expect(toastStore.dismissToast).toHaveBeenCalledWith(getIncomingCallToastKey(call.event.getStateKey()!)), + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notifyContent.call_id, room.roomId), + ), ); }); }); From dee9d7939b4dfcc0fda2d71ce5926a7df3aca4bd Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 15 Nov 2023 11:49:45 +0100 Subject: [PATCH 04/13] change to callId Signed-off-by: Timo K --- src/toasts/IncomingCallToast.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 5ac81cc4ed7..544881adbb2 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -41,7 +41,7 @@ import { ActionPayload } from "../dispatcher/payloads"; import { Call } from "../models/Call"; import { AudioID } from "../LegacyCallHandler"; -export const getIncomingCallToastKey = (sessionId: string, roomId: string): string => `call_${sessionId}_${roomId}`; +export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`; const MAX_RING_TIME_MS = 10 * 1000; interface JoinCallButtonWithCallProps { @@ -92,7 +92,7 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { // Dismiss if session got ended remotely. const onSessionEnded = useCallback( (endedSessionRoomId: String, session: MatrixRTCSession): void => { - if (roomId == endedSessionRoomId && session.sessionId == notifyEvent.getContent().call_id) { + if (roomId == endedSessionRoomId && session.callId == notifyEvent.getContent().call_id) { dismissToast(); } }, From 4148d6d26c73cb4faa753f4934c8673e1826f5dc Mon Sep 17 00:00:00 2001 From: Timo K Date: Fri, 17 Nov 2023 21:20:06 +0100 Subject: [PATCH 05/13] fix tests Signed-off-by: Timo K --- .../structures/LegacyCallEventGrouper.ts | 2 +- test/Notifier-test.ts | 25 +++++++++++-------- test/createRoom-test.ts | 5 ++++ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/structures/LegacyCallEventGrouper.ts b/src/components/structures/LegacyCallEventGrouper.ts index e4f29be4bf2..05325293c10 100644 --- a/src/components/structures/LegacyCallEventGrouper.ts +++ b/src/components/structures/LegacyCallEventGrouper.ts @@ -37,7 +37,7 @@ const CONNECTING_STATES = [ const SUPPORTED_STATES = [CallState.Connected, CallState.Ringing, CallState.Ended]; const isCallEventType = (eventType: string): boolean => - eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call."); + eventType?.startsWith("m.call.") || eventType?.startsWith("org.matrix.call."); export const isCallEvent = (event: MatrixEvent): boolean => isCallEventType(event.getType()); diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts index d9b8dfcc1b6..93ec4ecd265 100644 --- a/test/Notifier-test.ts +++ b/test/Notifier-test.ts @@ -13,7 +13,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ - import { mocked, MockedObject } from "jest-mock"; import { ClientEvent, @@ -29,7 +28,6 @@ import { import { waitFor } from "@testing-library/react"; import BasePlatform from "../src/BasePlatform"; -import { ElementCall } from "../src/models/Call"; import Notifier from "../src/Notifier"; import SettingsStore from "../src/settings/SettingsStore"; import ToastStore from "../src/stores/ToastStore"; @@ -44,7 +42,7 @@ import { mockClientMethodsUser, mockPlatformPeg, } from "./test-utils"; -import { IncomingCallToast } from "../src/toasts/IncomingCallToast"; +import { getIncomingCallToastKey, IncomingCallToast } from "../src/toasts/IncomingCallToast"; import { SdkContextClass } from "../src/contexts/SDKContext"; import UserActivity from "../src/UserActivity"; import Modal from "../src/Modal"; @@ -389,12 +387,17 @@ describe("Notifier", () => { jest.resetAllMocks(); }); - const callOnEvent = (type?: string) => { + const emitCallNotifyEvent = (type?: string, roomMention = true) => { const callEvent = mkEvent({ - type: type ?? ElementCall.CALL_EVENT_TYPE.name, + type: type ?? EventType.CallNotify, user: "@alice:foo", room: roomId, - content: {}, + content: { + "application": "m.call", + "m.mentions": { user_ids: [], room: roomMention }, + "notify_type": "ring", + "call_id": "abc123", + }, event: true, }); emitLiveEvent(callEvent); @@ -410,15 +413,15 @@ describe("Notifier", () => { it("should show toast when group calls are supported", () => { setGroupCallsEnabled(true); - const callEvent = callOnEvent(); + const notifyEvent = emitCallNotifyEvent(); expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith( expect.objectContaining({ - key: `call_${callEvent.getStateKey()}`, + key: getIncomingCallToastKey(notifyEvent.getContent().call_id, roomId), priority: 100, component: IncomingCallToast, bodyClassName: "mx_IncomingCallToast", - props: { callEvent }, + props: { notifyEvent }, }), ); }); @@ -426,7 +429,7 @@ describe("Notifier", () => { it("should not show toast when group calls are not supported", () => { setGroupCallsEnabled(false); - callOnEvent(); + emitCallNotifyEvent(); expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); }); @@ -434,7 +437,7 @@ describe("Notifier", () => { it("should not show toast when calling with non-group call event", () => { setGroupCallsEnabled(true); - callOnEvent("event_type"); + emitCallNotifyEvent("event_type"); expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); }); diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts index a3d413e9823..fa1c31a02fb 100644 --- a/test/createRoom-test.ts +++ b/test/createRoom-test.ts @@ -16,6 +16,8 @@ limitations under the License. import { mocked, Mocked } from "jest-mock"; import { CryptoApi, MatrixClient, Device, Preset, RoomType } from "matrix-js-sdk/src/matrix"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg, getMockClientWithEventEmitter } from "./test-utils"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; @@ -74,6 +76,9 @@ describe("createRoom", () => { it("sets up Element video rooms correctly", async () => { const userId = client.getUserId()!; const createCallSpy = jest.spyOn(ElementCall, "create"); + const callMembershipSpy = jest.spyOn(MatrixRTCSession, "callMembershipsForRoom"); + callMembershipSpy.mockReturnValue([]); + const roomId = await createRoom(client, { roomType: RoomType.UnstableCall }); const userPower = client.createRoom.mock.calls[0][0].power_level_content_override?.users?.[userId]; From 353adc96eb09e72a51eeb3873547e9505ed0ff1c Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 20 Nov 2023 16:51:24 +0100 Subject: [PATCH 06/13] only ring in 1:1 calls notify in rooms < 15 member Signed-off-by: Timo K --- src/models/Call.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/models/Call.ts b/src/models/Call.ts index 8fda1404123..68a55c776fa 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -750,14 +750,15 @@ export class ElementCall extends Call { (m) => m.application === "m.call" && m.callId === "", ); - // We only want to ring in rooms that have less then RING_MEMBER_LIMIT participants. For really large rooms we don't want to ring. - const RING_MEMBER_LIMIT = 10; - if (!isVideoRoom && existingRoomCallMembers.length == 0 && room.getJoinedMemberCount() < RING_MEMBER_LIMIT) { + // We only want to ring in rooms that have less or equal to NOTIFY_MEMBER_LIMIT participants. For really large rooms we don't want to ring. + const NOTIFY_MEMBER_LIMIT = 15; + const memberCount = room.getJoinedMemberCount(); + if (!isVideoRoom && existingRoomCallMembers.length == 0 && memberCount <= NOTIFY_MEMBER_LIMIT) { // send ringing event const content: ICallNotifyContent = { "application": "m.call", "m.mentions": { user_ids: [], room: true }, - "notify_type": "ring", + "notify_type": memberCount == 2 ? "ring" : "notify", "call_id": "", }; From db6993e138ecfb090162b5522413a2cf7fa9dfb3 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 20 Nov 2023 17:16:30 +0100 Subject: [PATCH 07/13] call_id fallback Signed-off-by: Timo K --- src/Notifier.ts | 2 +- src/toasts/IncomingCallToast.tsx | 4 +++- test/Notifier-test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Notifier.ts b/src/Notifier.ts index 3136c1227ae..59a42c9e653 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -522,7 +522,7 @@ class NotifierClass { (ev?.getAge() ?? 0) < 10000 ) { ToastStore.sharedInstance().addOrReplaceToast({ - key: getIncomingCallToastKey(ev.getContent()?.call_id, ev.getRoomId() ?? ""), + key: getIncomingCallToastKey(ev.getContent()?.call_id ?? "", ev.getRoomId() ?? ""), priority: 100, component: IncomingCallToast, bodyClassName: "mx_IncomingCallToast", diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 544881adbb2..10b9512514d 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -85,7 +85,9 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { // Stop ringing on dismiss. const dismissToast = useCallback((): void => { - ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(notifyEvent.getContent().call_id, roomId)); + ToastStore.sharedInstance().dismissToast( + getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId), + ); audio.pause(); }, [audio, notifyEvent, roomId]); diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts index 93ec4ecd265..b59576ff614 100644 --- a/test/Notifier-test.ts +++ b/test/Notifier-test.ts @@ -417,7 +417,7 @@ describe("Notifier", () => { expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith( expect.objectContaining({ - key: getIncomingCallToastKey(notifyEvent.getContent().call_id, roomId), + key: getIncomingCallToastKey(notifyEvent.getContent().call_id ?? "", roomId), priority: 100, component: IncomingCallToast, bodyClassName: "mx_IncomingCallToast", From 4359297bc9154d3e391464a0fad30eb652de8d7a Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Tue, 21 Nov 2023 13:03:40 +0100 Subject: [PATCH 08/13] Update src/Notifier.ts Co-authored-by: Robin --- src/Notifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Notifier.ts b/src/Notifier.ts index 59a42c9e653..122d9d7f975 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -519,7 +519,7 @@ class NotifierClass { if ( EventType.CallNotify === ev.getType() && SettingsStore.getValue("feature_group_calls") && - (ev?.getAge() ?? 0) < 10000 + (ev.getAge() ?? 0) < 10000 ) { ToastStore.sharedInstance().addOrReplaceToast({ key: getIncomingCallToastKey(ev.getContent()?.call_id ?? "", ev.getRoomId() ?? ""), From 59600d0e08448f12f5933f976b86a80b00841cf8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 21 Nov 2023 14:35:12 +0100 Subject: [PATCH 09/13] review Signed-off-by: Timo K --- src/Notifier.ts | 12 +++++++++++- .../structures/LegacyCallEventGrouper.ts | 2 +- src/models/Call.ts | 3 ++- src/toasts/IncomingCallToast.tsx | 14 ++++++-------- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/Notifier.ts b/src/Notifier.ts index 122d9d7f975..4218aba9acf 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -521,8 +521,18 @@ class NotifierClass { SettingsStore.getValue("feature_group_calls") && (ev.getAge() ?? 0) < 10000 ) { + const content = ev.getContent(); + const roomId = ev.getRoomId(); + if (typeof content.call_id !== "string") { + logger.warn("Received malformatted CallNotify event. Did not contain 'call_id' of type 'string'"); + return; + } + if (!roomId) { + logger.warn("Could not get roomId for CallNotify event"); + return; + } ToastStore.sharedInstance().addOrReplaceToast({ - key: getIncomingCallToastKey(ev.getContent()?.call_id ?? "", ev.getRoomId() ?? ""), + key: getIncomingCallToastKey(content.call_id, roomId), priority: 100, component: IncomingCallToast, bodyClassName: "mx_IncomingCallToast", diff --git a/src/components/structures/LegacyCallEventGrouper.ts b/src/components/structures/LegacyCallEventGrouper.ts index 05325293c10..e4f29be4bf2 100644 --- a/src/components/structures/LegacyCallEventGrouper.ts +++ b/src/components/structures/LegacyCallEventGrouper.ts @@ -37,7 +37,7 @@ const CONNECTING_STATES = [ const SUPPORTED_STATES = [CallState.Connected, CallState.Ringing, CallState.Ended]; const isCallEventType = (eventType: string): boolean => - eventType?.startsWith("m.call.") || eventType?.startsWith("org.matrix.call."); + eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call."); export const isCallEvent = (event: MatrixEvent): boolean => isCallEventType(event.getType()); diff --git a/src/models/Call.ts b/src/models/Call.ts index 68a55c776fa..043c512c9ab 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -53,6 +53,7 @@ import { getCurrentLanguage } from "../languageHandler"; import { FontWatcher } from "../settings/watchers/FontWatcher"; import { PosthogAnalytics } from "../PosthogAnalytics"; import { UPDATE_EVENT } from "../stores/AsyncStore"; +import { getFunctionalMembers } from "../utils/room/getFunctionalMembers"; const TIMEOUT_MS = 16000; @@ -752,7 +753,7 @@ export class ElementCall extends Call { // We only want to ring in rooms that have less or equal to NOTIFY_MEMBER_LIMIT participants. For really large rooms we don't want to ring. const NOTIFY_MEMBER_LIMIT = 15; - const memberCount = room.getJoinedMemberCount(); + const memberCount = getFunctionalMembers(room).length; if (!isVideoRoom && existingRoomCallMembers.length == 0 && memberCount <= NOTIFY_MEMBER_LIMIT) { // send ringing event const content: ICallNotifyContent = { diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 10b9512514d..1997e51b1e2 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -40,6 +40,7 @@ import { useDispatcher } from "../hooks/useDispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import { Call } from "../models/Call"; import { AudioID } from "../LegacyCallHandler"; +import { useTypedEventEmitter } from "../hooks/useEventEmitter"; export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`; const MAX_RING_TIME_MS = 10 * 1000; @@ -146,14 +147,11 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { [dismissToast], ); - useEffect(() => { - const matrixRTC = MatrixClientPeg.safeGet().matrixRTC; - matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onSessionEnded); - function disconnect(): void { - matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onSessionEnded); - } - return disconnect; - }, [audio, notifyEvent, onSessionEnded]); + useTypedEventEmitter( + MatrixClientPeg.safeGet().matrixRTC, + MatrixRTCSessionManagerEvents.SessionEnded, + onSessionEnded, + ); return ( From a8ba3468e926032c8736f9cf018447ea11dc0336 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 21 Nov 2023 16:43:57 +0100 Subject: [PATCH 10/13] add tests Signed-off-by: Timo K --- test/models/Call-test.ts | 57 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index cbf1df0ffa4..78d0ad37fde 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -17,7 +17,15 @@ limitations under the License. import EventEmitter from "events"; import { mocked } from "jest-mock"; import { waitFor } from "@testing-library/react"; -import { RoomType, Room, RoomEvent, MatrixEvent, RoomStateEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; +import { + RoomType, + Room, + RoomEvent, + MatrixEvent, + RoomStateEvent, + PendingEventOrdering, + UNSTABLE_ELEMENT_FUNCTIONAL_USERS, +} from "matrix-js-sdk/src/matrix"; import { Widget } from "matrix-widget-api"; // eslint-disable-next-line no-restricted-imports import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; @@ -982,4 +990,51 @@ describe("ElementCall", () => { call.off(CallEvent.Destroy, onDestroy); }); }); + describe("create call", () => { + function setFunctionalMembers(members: string[]) { + room.currentState.setStateEvents([ + mkEvent({ + event: true, + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + user: "@user:example.com", + room: room.roomId, + skey: "", + content: { service_members: members }, + }), + ]); + } + beforeEach(async () => { + setFunctionalMembers(["@user:example.com", "@user2:example.com", "@user4:example.com"]); + }); + it("sends notify event on create in a room with more than two members", async () => { + const sendEventSpy = jest.spyOn(room.client, "sendEvent"); + await ElementCall.create(room); + expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", { + "application": "m.call", + "call_id": "", + "m.mentions": { room: true, user_ids: [] }, + "notify_type": "notify", + }); + }); + it("sends ring on create in a DM (two participants) room", async () => { + setFunctionalMembers(["@user:example.com", "@user2:example.com"]); + + const sendEventSpy = jest.spyOn(room.client, "sendEvent"); + await ElementCall.create(room); + expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", { + "application": "m.call", + "call_id": "", + "m.mentions": { room: true, user_ids: [] }, + "notify_type": "ring", + }); + }); + it("don't sent notify event if there are existing room call members", async () => { + jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockReturnValue([ + { application: "m.call", callId: "" } as unknown as CallMembership, + ]); + const sendEventSpy = jest.spyOn(room.client, "sendEvent"); + await ElementCall.create(room); + expect(sendEventSpy).not.toHaveBeenCalled(); + }); + }); }); From ca824a450774b2c453b95fc94f57ed837a52ecbd Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 21 Nov 2023 17:38:41 +0100 Subject: [PATCH 11/13] more tests Signed-off-by: Timo K --- test/toasts/IncomingCallToast-test.tsx | 49 ++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index c02eb29d10b..5296e2d8ba3 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -19,8 +19,14 @@ import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/re import { mocked, Mocked } from "jest-mock"; import { Room, RoomStateEvent, MatrixEvent, MatrixEventEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; import { ClientWidgetApi, Widget } from "matrix-widget-api"; - -import type { RoomMember } from "matrix-js-sdk/src/matrix"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +// eslint-disable-next-line no-restricted-imports +import { ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc/types"; + +import type { IContent, RoomMember } from "matrix-js-sdk/src/matrix"; import { useMockedCalls, MockedCall, @@ -45,6 +51,7 @@ describe("IncomingCallEvent", () => { let client: Mocked; let room: Room; + let notifyContent: ICallNotifyContent; let alice: RoomMember; let bob: RoomMember; let call: MockedCall; @@ -65,7 +72,10 @@ describe("IncomingCallEvent", () => { document.body.appendChild(audio); room = new Room("!1:example.org", client, "@alice:example.org"); - + notifyContent = { + call_id: "", + getRoomId: () => room.roomId, + } as unknown as ICallNotifyContent; alice = mkRoomMember(room.roomId, "@alice:example.org"); bob = mkRoomMember(room.roomId, "@bob:example.org"); @@ -101,12 +111,8 @@ describe("IncomingCallEvent", () => { jest.restoreAllMocks(); }); - const notifyContent = { - call_id: "", - }; const renderToast = () => { call.event.getContent = () => notifyContent as any; - render(); }; @@ -125,6 +131,20 @@ describe("IncomingCallEvent", () => { screen.getByRole("button", { name: "Close" }); }); + it("start ringing on ring notify event", () => { + call.event.getContent = () => + ({ + ...notifyContent, + notify_type: "ring", + } as any); + const playMock = jest.fn(); + const audio = { play: playMock, paused: true }; + + jest.spyOn(document, "getElementById").mockReturnValue(audio as any); + render(); + expect(playMock).toHaveBeenCalled(); + }); + it("correctly renders toast without a call", () => { call.destroy(); renderToast(); @@ -203,4 +223,19 @@ describe("IncomingCallEvent", () => { ), ); }); + + it("closes toast when the matrixRTC session has ended", async () => { + renderToast(); + + client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, { + callId: notifyContent.call_id, + room: room, + } as unknown as MatrixRTCSession); + + await waitFor(() => + expect(toastStore.dismissToast).toHaveBeenCalledWith( + getIncomingCallToastKey(notifyContent.call_id, room.roomId), + ), + ); + }); }); From ba62fc5de21772f003f669a15c42d9cd28bbd6e1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 21 Nov 2023 17:39:05 +0100 Subject: [PATCH 12/13] unused import Signed-off-by: Timo K --- test/toasts/IncomingCallToast-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx index 5296e2d8ba3..de3dd8be887 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -26,7 +26,7 @@ import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; // eslint-disable-next-line no-restricted-imports import { ICallNotifyContent } from "matrix-js-sdk/src/matrixrtc/types"; -import type { IContent, RoomMember } from "matrix-js-sdk/src/matrix"; +import type { RoomMember } from "matrix-js-sdk/src/matrix"; import { useMockedCalls, MockedCall, From 478da26df9e251ef725dd527dbc7b9e091ced853 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 21 Nov 2023 17:45:20 +0100 Subject: [PATCH 13/13] String -> string Signed-off-by: Timo K --- src/toasts/IncomingCallToast.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx index 1997e51b1e2..ad4c5af57cf 100644 --- a/src/toasts/IncomingCallToast.tsx +++ b/src/toasts/IncomingCallToast.tsx @@ -78,7 +78,7 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { // Start ringing if not already. useEffect(() => { - const isRingToast = (notifyEvent.getContent() as unknown as { notify_type: String })["notify_type"] == "ring"; + const isRingToast = (notifyEvent.getContent() as unknown as { notify_type: string })["notify_type"] == "ring"; if (isRingToast && audio.paused) { audio.play(); } @@ -94,7 +94,7 @@ export function IncomingCallToast({ notifyEvent }: Props): JSX.Element { // Dismiss if session got ended remotely. const onSessionEnded = useCallback( - (endedSessionRoomId: String, session: MatrixRTCSession): void => { + (endedSessionRoomId: string, session: MatrixRTCSession): void => { if (roomId == endedSessionRoomId && session.callId == notifyEvent.getContent().call_id) { dismissToast(); }