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

Commit

Permalink
Add ringing for matrixRTC (#11870)
Browse files Browse the repository at this point in the history
* 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 <toger5@hotmail.de>

* use typed CallNotifyContent

Signed-off-by: Timo K <toger5@hotmail.de>

* update tests

Signed-off-by: Timo K <toger5@hotmail.de>

* change to callId

Signed-off-by: Timo K <toger5@hotmail.de>

* fix tests

Signed-off-by: Timo K <toger5@hotmail.de>

* only ring in 1:1 calls
notify in rooms < 15 member

Signed-off-by: Timo K <toger5@hotmail.de>

* call_id fallback

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/Notifier.ts

Co-authored-by: Robin <robin@robin.town>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* add tests

Signed-off-by: Timo K <toger5@hotmail.de>

* more tests

Signed-off-by: Timo K <toger5@hotmail.de>

* unused import

Signed-off-by: Timo K <toger5@hotmail.de>

* String -> string

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Robin <robin@robin.town>
  • Loading branch information
toger5 and robintown authored Nov 21, 2023
1 parent 7ca0cd1 commit a26c2d3
Show file tree
Hide file tree
Showing 7 changed files with 231 additions and 51 deletions.
22 changes: 18 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,27 @@ 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
) {
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.getStateKey()!),
key: getIncomingCallToastKey(content.call_id, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { callEvent: ev },
props: { notifyEvent: ev },
});
}
}
Expand Down
27 changes: 25 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 All @@ -51,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;

Expand Down Expand Up @@ -758,10 +761,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 = getFunctionalMembers(room).length;
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);
}
}

protected async performConnection(
Expand Down
81 changes: 54 additions & 27 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,15 @@ 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 { AudioID } from "../LegacyCallHandler";
import { useTypedEventEmitter } from "../hooks/useEventEmitter";

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 +67,48 @@ 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 +121,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 +147,17 @@ export function IncomingCallToast({ callEvent }: Props): JSX.Element {
[dismissToast],
);

useTypedEventEmitter(
MatrixClientPeg.safeGet().matrixRTC,
MatrixRTCSessionManagerEvents.SessionEnded,
onSessionEnded,
);

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

0 comments on commit a26c2d3

Please sign in to comment.