Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion playwright/e2e/voip/element-call.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n
},
"m.relates_to": {
event_id: resp.event_id,
rel_type: "org.matrix.msc4075.rtc.notification.parent",
rel_type: "m.reference",
},
"m.call.intent": intent,
"notification_type": notification,
Expand Down
128 changes: 92 additions & 36 deletions src/Notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
*/

import {
type MatrixEvent,
MatrixEvent,
MatrixEventEvent,
type Room,
RoomEvent,
Expand All @@ -25,7 +25,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { type SessionMembershipData, type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";

import { MatrixClientPeg } from "./MatrixClientPeg";
import { PosthogAnalytics } from "./PosthogAnalytics";
Expand Down Expand Up @@ -481,44 +481,100 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
}

/**
* Some events require special handling such as showing in-app toasts
* Handle `EventType.RTCNotification` notifications.
* @param ev The notification event.
* @param toaster The toast store.
* @param room The room that contains the notification
* @returns A promise that will always resolve.
*/
private async handleRTCNotification(ev: MatrixEvent, toaster: ToastStore, room: Room): Promise<void> {
// TODO: Use the call_id to get the *correct* call. We assume there is only one call per room here.
const rtcSession = room && room.client.matrixRTC.getRoomSession(room);
if (
rtcSession?.slotDescription?.application == "m.call" &&
rtcSession.memberships.some((membership) => membership.userId === room.client.getUserId())
) {
// If we're already joined to the session, don't notify.
return;
}

// XXX: Should use parseCallNotificationContent once the types are exported.
const content = ev.getContent() as IRTCNotificationContent;
const roomId = ev.getRoomId();
const referencedMembershipEventId = ev.getRelation()?.event_id;

// Check maximum age of a call notification event that will trigger a ringing notification
if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) {
logger.warn("Received outdated RTCNotification event.");
return;
}
if (!roomId) {
logger.warn("Could not get roomId for RTCNotification event");
return;
}
if (!referencedMembershipEventId) {
logger.warn("Could not get referenced membership for notification");
return;
}
if (content["m.relates_to"].rel_type !== "m.reference") {
logger.warn("Ignored RTCNotification due to invalid rel_type");
return;
}

let callMembership = room?.findEventById(referencedMembershipEventId);

if (!callMembership) {
// Attempt to fetch from the homeserver, if we do not have the event locally.
// This is a rare case as obviously the referenced event for a m.call notification must
// be sent first.
try {
callMembership = new MatrixEvent(await room.client.fetchRoomEvent(roomId, referencedMembershipEventId));
} catch (ex) {
logger.warn(`Call membership for notification could not be found`, ex);
}
}
// If the event could not be found even after requesting it from the homeserver.
if (!callMembership) {
// We will not show a call notification if there is no valid call membership.
logger.warn(
`Could not find call membership (${referencedMembershipEventId} ${roomId}) for notification event.`,
);
return;
}

// If we cannot determine the key, we'll accept it but assume it's empty string.
// This means if you have malformed notifications or call memberships your notifications
// will overwrite, but the solution to that is to use well-formed events.
const callId = callMembership.getContent<SessionMembershipData>().call_id ?? "";
const key = getIncomingCallToastKey(callId, roomId);

if (toaster.hasToast(key)) {
logger.debug(`Detected duplicate notification for call ${key}, ignoring`);
return;
}

toaster.addOrReplaceToast({
key,
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notificationEvent: ev },
});
}

/**
* Some events require special handling such as showing in-app toasts.
* This function may either create a toast or ignore the event based
* on current app state.
*/
private performCustomEventHandling(ev: MatrixEvent): void {
const toaster = ToastStore.sharedInstance();
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(ev.getRoomId());
const rtcSession = room ? cli.matrixRTC.getRoomSession(room) : null;
let thisUserHasConnectedDevice = false;
if (rtcSession?.slotDescription?.application == "m.call") {
// Get the current state, the actual IncomingCallToast will update as needed by
// listening to the rtcSession directly.
thisUserHasConnectedDevice = rtcSession.memberships.some((m) => m.userId === cli.getUserId());
}

if (EventType.RTCNotification === ev.getType() && !thisUserHasConnectedDevice) {
const content = ev.getContent() as IRTCNotificationContent;
const roomId = ev.getRoomId();
const eventId = ev.getId();

// Check maximum age of a call notification event that will trigger a ringing notification
if (Date.now() - getNotificationEventSendTs(ev) > content.lifetime) {
logger.warn("Received outdated RTCNotification event.");
return;
}
if (!roomId) {
logger.warn("Could not get roomId for RTCNotification event");
return;
}
if (!eventId) {
logger.warn("Could not get eventId for RTCNotification event");
return;
}
ToastStore.sharedInstance().addOrReplaceToast({
key: getIncomingCallToastKey(eventId, roomId),
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
props: { notificationEvent: ev },
});

if (room && EventType.RTCNotification === ev.getType()) {
// We don't need to await this.
void this.handleRTCNotification(ev, toaster, room);
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/stores/ToastStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export default class ToastStore extends EventEmitter {
}
}

/**
* Is a toast currently present on the store.
* @param key The toast key to look for.
*/
public hasToast(key: string): boolean {
return this.toasts.some((toast) => toast.key === key);
}

public getToasts(): IToast<any>[] {
return this.toasts;
}
Expand Down
44 changes: 30 additions & 14 deletions src/toasts/IncomingCallToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ Please see LICENSE files in the repository root for full details.
*/

import React, { type JSX, useCallback, useEffect, useRef, useState } from "react";
import { type Room, type MatrixEvent, type RoomMember, RoomEvent, EventType } from "matrix-js-sdk/src/matrix";
import {
type Room,
type MatrixEvent,
type RoomMember,
RoomEvent,
EventType,
MatrixEventEvent,
} from "matrix-js-sdk/src/matrix";
import { Button, ToggleInput, Tooltip, TooltipProvider } from "@vector-im/compound-web";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
import { logger } from "matrix-js-sdk/src/logger";
Expand All @@ -29,18 +36,17 @@ import { useDispatcher } from "../hooks/useDispatcher";
import { type ActionPayload } from "../dispatcher/payloads";
import { type Call, CallEvent } from "../models/Call";
import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
import { useEventEmitter } from "../hooks/useEventEmitter";
import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter";
import { CallStore, CallStoreEvent } from "../stores/CallStore";
import DMRoomMap from "../utils/DMRoomMap";

/**
* Get the key for the incoming call toast. A combination of the event ID and room ID.
* @param notificationEventId The ID of the notification event.
* Get the key for the incoming call toast. A combination of the call ID and room ID.
* @param callId The ID of the call.
* @param roomId The ID of the room.
* @returns The key for the incoming call toast.
*/
export const getIncomingCallToastKey = (notificationEventId: string, roomId: string): string =>
`call_${notificationEventId}_${roomId}`;
export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`;

/**
* Get the ts when the notification event was sent.
Expand Down Expand Up @@ -126,10 +132,18 @@ function DeclineCallButtonWithNotificationEvent({
}

interface Props {
/**
* A MatrixRTC notification event which has a content type of `IRTCNotificationContent`
*/
notificationEvent: MatrixEvent;
/**
* The unique key of the toast notification, used to dismiss the toast if the
* notification expires for any reason.
*/
toastKey: string;
}

export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.Element {
const roomId = notificationEvent.getRoomId()!;
// Use a partial type so ts still helps us to not miss any type checks.
const notificationContent = notificationEvent.getContent() as Partial<IRTCNotificationContent>;
Expand All @@ -155,14 +169,16 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {

// Stop ringing on dismiss.
const dismissToast = useCallback((): void => {
const notificationId = notificationEvent.getId();
if (!notificationId) {
logger.warn("Could not get eventId for RTCNotification event");
return;
}
ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(notificationId, roomId));
ToastStore.sharedInstance().dismissToast(toastKey);
LegacyCallHandler.instance.pause(AudioID.Ring);
}, [notificationEvent, roomId]);
}, [toastKey]);

// Dismiss if the notification event or call event is redacted
useTypedEventEmitter(room, MatrixEventEvent.BeforeRedaction, (ev: MatrixEvent) => {
if ([ev.getId(), ev.getRelation()?.event_id].includes(ev.getId())) {
dismissToast();
}
});

// Dismiss if session got ended remotely.
const onCall = useCallback(
Expand Down
Loading
Loading