element-web/src/toasts/IncomingCallToast.tsx
Will Hunt 13ded7db84
Remove element_call.participant_limit config and associated code. (#31638)
* Remove `element_call.participant_limit`

* fix disabledTooltip

* reducer ftw

* Remove unused bits

* prettier
2026-01-05 11:19:14 +00:00

330 lines
14 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
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 { 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";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { CheckIcon, VoiceCallIcon, CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { AvatarWithDetails } from "@element-hq/web-shared-components";
import { _t } from "../languageHandler";
import RoomAvatar from "../components/views/avatars/RoomAvatar";
import { MatrixClientPeg } from "../MatrixClientPeg";
import defaultDispatcher from "../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../dispatcher/actions";
import ToastStore from "../stores/ToastStore";
import { LiveContentSummary, LiveContentType } from "../components/views/rooms/LiveContentSummary";
import { useCall, useParticipantCount } from "../hooks/useCall";
import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton";
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 { 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.
* @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}`;
/**
* Get the ts when the notification event was sent.
* This can be either the origin_server_ts or a ts the sender of this event claims as
* the time they sent it (sender_ts).
* The origin_server_ts is the fallback if sender_ts seems wrong.
* @param event The RTCNotification event.
* @returns The timestamp to use as the expect start time to apply the `lifetime` to.
*/
export const getNotificationEventSendTs = (event: MatrixEvent): number => {
const content = event.getContent() as Partial<IRTCNotificationContent>;
const sendTs = content.sender_ts;
if (sendTs && Math.abs(sendTs - event.getTs()) >= 15000) {
logger.warn(
"Received RTCNotification event. With large sender_ts origin_server_ts offset -> using origin_server_ts",
);
return event.getTs();
}
return sendTs ?? event.getTs();
};
const MAX_RING_TIME_MS = 90 * 1000;
interface JoinCallButtonWithCallProps {
onClick: (e: ButtonEvent) => void;
call: Call | null;
disabledTooltip: string | undefined;
isRinging: boolean;
}
function JoinCallButtonWithCall({ onClick, disabledTooltip, isRinging }: JoinCallButtonWithCallProps): JSX.Element {
return (
<Tooltip description={disabledTooltip ?? _t("voip|video_call")}>
<Button
className="mx_IncomingCallToast_actionButton"
onClick={onClick}
disabled={disabledTooltip != undefined}
kind="primary"
Icon={CheckIcon}
size="sm"
>
{isRinging ? _t("action|accept") : _t("action|join")}
</Button>
</Tooltip>
);
}
interface DeclineCallButtonWithNotificationEventProps {
onDeclined: (e: ButtonEvent) => void;
notificationEvent: MatrixEvent;
room?: Room;
}
function DeclineCallButtonWithNotificationEvent({
notificationEvent,
room,
onDeclined,
}: DeclineCallButtonWithNotificationEventProps): JSX.Element {
const [declining, setDeclining] = useState(false);
const onClick = useCallback(
async (e: ButtonEvent) => {
e.stopPropagation();
setDeclining(true);
await room?.client.sendRtcDecline(room.roomId, notificationEvent.getId() ?? "");
onDeclined(e);
},
[notificationEvent, onDeclined, room?.client, room?.roomId],
);
return (
<Tooltip description={_t("voip|decline_call")}>
<Button
className="mx_IncomingCallToast_actionButton"
onClick={onClick}
kind="primary"
destructive
disabled={declining}
Icon={CloseIcon}
size="sm"
>
{_t("action|decline")}
</Button>
</Tooltip>
);
}
interface Props {
notificationEvent: MatrixEvent;
}
export function IncomingCallToast({ notificationEvent }: 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>;
const room = MatrixClientPeg.safeGet().getRoom(roomId) ?? undefined;
const call = useCall(roomId);
const [connectedCalls, setConnectedCalls] = useState<Call[]>(Array.from(CallStore.instance.connectedCalls));
useEventEmitter(CallStore.instance, CallStoreEvent.ConnectedCalls, () => {
setConnectedCalls(Array.from(CallStore.instance.connectedCalls));
});
const otherCallIsOngoing = connectedCalls.find((call) => call.roomId !== roomId);
const soundHasStarted = useRef<boolean>(false);
useEffect(() => {
// This section can race, so we use a ref to keep track of whether we have started trying to play.
// This is because `LegacyCallHandler.play` tries to load the sound and then play it asynchonously
// and `LegacyCallHandler.isPlaying` will not be `true` until the sound starts playing.
const isRingToast = notificationContent.notification_type === "ring";
if (isRingToast && !soundHasStarted.current && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) {
// Start ringing if not already.
soundHasStarted.current = true;
void LegacyCallHandler.instance.play(AudioID.Ring);
}
}, [notificationContent.notification_type, soundHasStarted]);
// 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));
LegacyCallHandler.instance.pause(AudioID.Ring);
}, [notificationEvent, roomId]);
// Dismiss if session got ended remotely.
const onCall = useCallback(
(call: Call, callRoomId: string): void => {
const roomId = notificationEvent.getRoomId();
if (!roomId && roomId !== callRoomId) return;
if (call === null || call.participants.size === 0) {
dismissToast();
}
},
[dismissToast, notificationEvent],
);
// Dismiss if session got declined remotely.
const onTimelineChange = useCallback(
(ev: MatrixEvent) => {
const userId = room?.client.getUserId();
if (
ev.getType() === EventType.RTCDecline &&
userId !== undefined &&
ev.getSender() === userId && // It is our decline not someone elses
ev.relationEventId === notificationEvent.getId() // The event declines this ringing toast.
) {
dismissToast();
}
},
[dismissToast, notificationEvent, room?.client],
);
// Dismiss if another device from this user joins.
const onParticipantChange = useCallback(
(participants: Map<RoomMember, Set<string>>, prevParticipants: Map<RoomMember, Set<string>>) => {
if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) {
dismissToast();
}
},
[dismissToast, room?.client],
);
// Dismiss on timeout.
useEffect(() => {
const lifetime = notificationContent.lifetime ?? MAX_RING_TIME_MS;
const timeout = setTimeout(dismissToast, getNotificationEventSendTs(notificationEvent) + lifetime - Date.now());
return () => clearTimeout(timeout);
});
// Dismiss on viewing call.
useDispatcher(
defaultDispatcher,
useCallback(
(payload: ActionPayload) => {
if (payload.action === Action.ViewRoom && payload.room_id === roomId && payload.view_call) {
dismissToast();
}
},
[roomId, dismissToast],
),
);
const [skipLobbyToggle, setSkipLobbyToggle] = useState(true);
// Dismiss on clicking join.
// If the skip lobby option is undefined, it will use to the shift key state to decide if the lobby is skipped.
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,
skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle,
voiceOnly: notificationContent["m.call.intent"] === "audio",
metricsTrigger: undefined,
});
},
[room, skipLobbyToggle, notificationContent],
);
// Dismiss on closing toast.
const onCloseClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
dismissToast();
},
[dismissToast],
);
useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall);
useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange);
useEventEmitter(room, RoomEvent.Timeline, onTimelineChange);
const isVoice = notificationContent["m.call.intent"] === "audio";
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId);
const participantCount = useParticipantCount(call);
const detailsInformation =
notificationContent.notification_type === "ring" ? (
<span>{otherUserId}</span>
) : (
<LiveContentSummary
type={isVoice ? LiveContentType.Voice : LiveContentType.Video}
text={isVoice ? _t("common|voice") : _t("common|video")}
active={false}
participantCount={participantCount}
/>
);
return (
<TooltipProvider>
<>
<div className="mx_IncomingCallToast_content">
{isVoice ? (
<div className="mx_IncomingCallToast_message">
<VoiceCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "}
{_t("voip|voice_call_incoming")}
</div>
) : (
<div className="mx_IncomingCallToast_message">
<VideoCallIcon width="20px" height="20px" style={{ position: "relative", top: "4px" }} />{" "}
{notificationContent.notification_type === "ring"
? _t("voip|video_call_incoming")
: _t("voip|video_call_started")}
</div>
)}
<AvatarWithDetails
avatar={<RoomAvatar room={room ?? undefined} size="32px" />}
details={detailsInformation}
title={room ? room.name : _t("voip|call_toast_unknown_room")}
className="mx_IncomingCallToast_AvatarWithDetails"
/>
{!isVoice && (
<div className="mx_IncomingCallToast_toggleWithLabel">
<span>{_t("voip|skip_lobby_toggle_option")}</span>
<ToggleInput
onChange={(e) => setSkipLobbyToggle(e.target.checked)}
checked={skipLobbyToggle}
/>
</div>
)}
<div className="mx_IncomingCallToast_buttons">
<DeclineCallButtonWithNotificationEvent
notificationEvent={notificationEvent}
room={room}
onDeclined={onCloseClick}
/>
<JoinCallButtonWithCall
onClick={onJoinClick}
call={call}
isRinging={notificationContent.notification_type === "ring"}
disabledTooltip={otherCallIsOngoing ? "Ongoing call" : undefined}
/>
</div>
</div>
<AccessibleButton
className="mx_IncomingCallToast_closeButton"
onClick={onCloseClick}
title={_t("action|close")}
>
<CloseIcon />
</AccessibleButton>
</>
</TooltipProvider>
);
}