/* 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, 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"; 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, 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 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 = (callId: string, roomId: string): string => `call_${callId}_${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; 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 ( ); } 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 ( ); } 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, 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; const room = MatrixClientPeg.safeGet().getRoom(roomId) ?? undefined; const call = useCall(roomId); const [connectedCalls, setConnectedCalls] = useState(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(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 => { ToastStore.sharedInstance().dismissToast(toastKey); LegacyCallHandler.instance.pause(AudioID.Ring); }, [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( (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>, prevParticipants: Map>) => { 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({ 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" ? ( {otherUserId} ) : ( ); return ( <>
{isVoice ? (
{" "} {_t("voip|voice_call_incoming")}
) : (
{" "} {notificationContent.notification_type === "ring" ? _t("voip|video_call_incoming") : _t("voip|video_call_started")}
)} } details={detailsInformation} title={room ? room.name : _t("voip|call_toast_unknown_room")} className="mx_IncomingCallToast_AvatarWithDetails" /> {!isVoice && (
{_t("voip|skip_lobby_toggle_option")} setSkipLobbyToggle(e.target.checked)} checked={skipLobbyToggle} />
)}
); }