mirror of
https://github.com/element-hq/element-web.git
synced 2026-01-11 19:56:47 +00:00
* Remove `element_call.participant_limit` * fix disabledTooltip * reducer ftw * Remove unused bits * prettier
330 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|