Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Add ringing for matrixRTC #11870

Merged
merged 13 commits into from
Nov 21, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/Notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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
toger5 marked this conversation as resolved.
Show resolved Hide resolved
) {
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 },
});
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/structures/LegacyCallEventGrouper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type of change is suspicious to me - we should be able to have full trust in our types when we say that eventType has type string

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this is not required anymore. I do remember this was a complete crash before. Maybe sth upstream was fixed. I will test a bit more but this should not be necassary.


export const isCallEvent = (event: MatrixEvent): boolean => isCallEventType(event.getType());

Expand Down
26 changes: 24 additions & 2 deletions src/models/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -738,10 +740,30 @@ 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 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();
toger5 marked this conversation as resolved.
Show resolved Hide resolved
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": memberCount == 2 ? "ring" : "notify",
"call_id": "",
};

await room.client.sendEvent(room.roomId, EventType.CallNotify, content);
Comment on lines +758 to +766
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, but this really feels like logic that should live in the JS-SDK somehow

Copy link
Contributor Author

@toger5 toger5 Nov 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CallNotifyContent is declared in the js-sdk.
But I guess: send_call_notify_event(notify_type: "ring" : "notify", mentions: IMentions, application: string, callId: string) is what you are proposing? I was considering this but I almost prefer this kind of signature for this amount if fields:
send(props: SendData) vs send(prop1: data1, prop2:data2, ...)

The only thing we could get implicitly is EventType.CallNotify. The rest should be configurable anyways.

}
}

protected async performConnection(
Expand Down
83 changes: 55 additions & 28 deletions src/toasts/IncomingCallToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = (callId: string, roomId: string): string => `call_${callId}_${roomId}`;
const MAX_RING_TIME_MS = 10 * 1000;

interface JoinCallButtonWithCallProps {
onClick: (e: ButtonEvent) => void;
Expand All @@ -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.callId == 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(
Expand All @@ -104,21 +118,23 @@ export function IncomingCallToast({ callEvent }: Props): JSX.Element {
),
);

// Dismiss on clicking join.
const onJoinClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();

// The toast will be automatically dismissed by the dispatcher callback above
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room?.roomId,
view_call: true,
metricsTrigger: undefined,
});
dismissToast();
},
[room, dismissToast],
[room],
);

// Dismiss on closing toast.
const onCloseClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
Expand All @@ -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]);
toger5 marked this conversation as resolved.
Show resolved Hide resolved

return (
<React.Fragment>
<RoomAvatar room={room ?? undefined} size="24px" />
<div>
<RoomAvatar room={room ?? undefined} size="24px" />
</div>
<div className="mx_IncomingCallToast_content">
<div className="mx_IncomingCallToast_info">
<span className="mx_IncomingCallToast_room">
Expand Down
25 changes: 14 additions & 11 deletions test/Notifier-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -410,31 +413,31 @@ 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 },
}),
);
});

it("should not show toast when group calls are not supported", () => {
setGroupCallsEnabled(false);

callOnEvent();
emitCallNotifyEvent();

expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled();
});

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();
});
Expand Down
5 changes: 5 additions & 0 deletions test/createRoom-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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];
Expand Down
Loading
Loading