Update toast styles, improve incoming call notifications (#33043)
* Update design of incoming call notifications * Make toast show avatars of group call participants * Further expand test coverage for call notifications * Update screenshots * Update screenshots * Delete unused variables * Upgrade Element Call to v0.19.2 For the new group call intents. * Consolidate some branches * Apply Compound spacing variables a little more * Fix lints * Exclude Element Call assets from being re-minified to fix build
This commit is contained in:
@@ -8,13 +8,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { type FC } from "react";
|
||||
import classNames from "classnames";
|
||||
import { GroupIcon, VideoCallSolidIcon, VoiceCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { GroupIcon, VideoCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
export enum LiveContentType {
|
||||
Video,
|
||||
Voice,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -27,14 +26,14 @@ interface Props {
|
||||
/**
|
||||
* Summary line used to call out live, interactive content such as calls.
|
||||
*/
|
||||
export const LiveContentSummary: FC<Props> = ({ type, text, active, participantCount }) => (
|
||||
export const LiveContentSummary: FC<Props> = ({ text, active, participantCount }) => (
|
||||
<span className="mx_LiveContentSummary">
|
||||
<span
|
||||
className={classNames("mx_LiveContentSummary_text", {
|
||||
mx_LiveContentSummary_text_active: active,
|
||||
})}
|
||||
>
|
||||
{type === LiveContentType.Video ? <VideoCallSolidIcon /> : <VoiceCallSolidIcon />}
|
||||
<VideoCallSolidIcon />
|
||||
{text}
|
||||
</span>
|
||||
{participantCount > 0 && (
|
||||
|
||||
@@ -50,15 +50,7 @@ export const useParticipantCount = (call: Call | null): number => {
|
||||
}, [participants]);
|
||||
};
|
||||
|
||||
export const useParticipatingMembers = (call: Call): RoomMember[] => {
|
||||
export const useParticipatingMembers = (call: Call | null): RoomMember[] => {
|
||||
const participants = useParticipants(call);
|
||||
|
||||
return useMemo(() => {
|
||||
const members: RoomMember[] = [];
|
||||
for (const [member, devices] of participants) {
|
||||
// Repeat the member for as many devices as they're using
|
||||
for (let i = 0; i < devices.size; i++) members.push(member);
|
||||
}
|
||||
return members;
|
||||
}, [participants]);
|
||||
return useMemo(() => [...participants.keys()], [participants]);
|
||||
};
|
||||
|
||||
@@ -588,7 +588,6 @@
|
||||
"video": "Video",
|
||||
"video_room": "Video room",
|
||||
"view_message": "View message",
|
||||
"voice": "Voice",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"composer": {
|
||||
@@ -3895,6 +3894,16 @@
|
||||
"call_held": "%(peerName)s held the call",
|
||||
"call_held_resume": "You held the call <a>Resume</a>",
|
||||
"call_held_switch": "You held the call <a>Switch</a>",
|
||||
"call_members": {
|
||||
"exhaustive": {
|
||||
"one": "<avatars/> on the call",
|
||||
"other": "<avatars/> on the call"
|
||||
},
|
||||
"overflow": {
|
||||
"one": "<avatars/> +%(overflowCount)s on the call",
|
||||
"other": "<avatars/> +%(overflowCount)s on the call"
|
||||
}
|
||||
},
|
||||
"call_toast_unknown_room": "Unknown room",
|
||||
"camera_disabled": "Your camera is turned off",
|
||||
"camera_enabled": "Your camera is still enabled",
|
||||
@@ -3904,7 +3913,6 @@
|
||||
"connection_lost": "Connectivity to the server has been lost",
|
||||
"connection_lost_description": "You cannot place calls without a connection to the server.",
|
||||
"consulting": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
||||
"decline_call": "Decline",
|
||||
"default_device": "Default Device",
|
||||
"dial": "Dial",
|
||||
"dialpad": "Dialpad",
|
||||
@@ -3918,10 +3926,12 @@
|
||||
"enable_microphone": "Unmute microphone",
|
||||
"expand": "Return to call",
|
||||
"get_call_link": "Share call link",
|
||||
"group_call_started": "Group call started",
|
||||
"hangup": "Hangup",
|
||||
"hide_sidebar_button": "Hide sidebar",
|
||||
"input_devices": "Input devices",
|
||||
"jitsi_call": "Jitsi Conference",
|
||||
"join_with_video": "Join with video",
|
||||
"legacy_call": "Legacy Call",
|
||||
"maximise": "Fill screen",
|
||||
"maximise_call": "Maximise call",
|
||||
@@ -3955,7 +3965,6 @@
|
||||
"show_sidebar_button": "Show sidebar",
|
||||
"silence": "Silence call",
|
||||
"silenced": "Notifications silenced",
|
||||
"skip_lobby_toggle_option": "Join immediately",
|
||||
"start_screenshare": "Start sharing your screen",
|
||||
"stop_screenshare": "Stop sharing your screen",
|
||||
"too_many_calls": "Too Many Calls",
|
||||
@@ -3977,7 +3986,6 @@
|
||||
"user_is_presenting": "%(sharerName)s is presenting",
|
||||
"video_call": "Video call",
|
||||
"video_call_incoming": "Incoming video call",
|
||||
"video_call_started": "Video call started",
|
||||
"video_call_using": "Video call using:",
|
||||
"voice_call": "Voice call",
|
||||
"voice_call_incoming": "Incoming voice call",
|
||||
|
||||
@@ -592,6 +592,8 @@ export class JitsiCall extends Call {
|
||||
export enum ElementCallIntent {
|
||||
StartCall = "start_call",
|
||||
JoinExisting = "join_existing",
|
||||
StartCallVoice = "start_call_voice",
|
||||
JoinExistingVoice = "join_existing_voice",
|
||||
StartCallDM = "start_call_dm",
|
||||
StartCallDMVoice = "start_call_dm_voice",
|
||||
JoinExistingDM = "join_existing_dm",
|
||||
@@ -685,11 +687,13 @@ export class ElementCall extends Call {
|
||||
params.append("intent", voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM);
|
||||
}
|
||||
} else {
|
||||
// Group chats do not have a voice option.
|
||||
if (hasCallStarted) {
|
||||
params.append("intent", ElementCallIntent.JoinExisting);
|
||||
params.append(
|
||||
"intent",
|
||||
voiceOnly ? ElementCallIntent.JoinExistingVoice : ElementCallIntent.JoinExisting,
|
||||
);
|
||||
} else {
|
||||
params.append("intent", ElementCallIntent.StartCall);
|
||||
params.append("intent", voiceOnly ? ElementCallIntent.StartCallVoice : ElementCallIntent.StartCall);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, {
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
type ComponentType,
|
||||
type SVGAttributes,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useId,
|
||||
} from "react";
|
||||
import {
|
||||
type Room,
|
||||
type MatrixEvent,
|
||||
@@ -15,11 +25,16 @@ import {
|
||||
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 { AvatarStack, Button, Form, Heading, InlineField, Label, ToggleInput, Tooltip } from "@vector-im/compound-web";
|
||||
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 {
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
ExpandIcon,
|
||||
VideoCallSolidIcon,
|
||||
VoiceCallSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { AvatarWithDetails } from "@element-hq/web-shared-components";
|
||||
|
||||
import { _t } from "../languageHandler";
|
||||
@@ -29,8 +44,7 @@ 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 { useCall, useParticipatingMembers } from "../hooks/useCall";
|
||||
import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton";
|
||||
import { useDispatcher } from "../hooks/useDispatcher";
|
||||
import { type ActionPayload } from "../dispatcher/payloads";
|
||||
@@ -39,6 +53,7 @@ import LegacyCallHandler, { AudioID } from "../LegacyCallHandler";
|
||||
import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter";
|
||||
import { CallStore, CallStoreEvent } from "../stores/CallStore";
|
||||
import DMRoomMap from "../utils/DMRoomMap";
|
||||
import MemberAvatar from "../components/views/avatars/MemberAvatar";
|
||||
|
||||
/**
|
||||
* Get the key for the incoming call toast. A combination of the call ID and room ID.
|
||||
@@ -76,20 +91,24 @@ interface JoinCallButtonWithCallProps {
|
||||
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="md"
|
||||
>
|
||||
{isRinging ? _t("action|accept") : _t("action|join")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
function JoinCallButtonWithCall({ onClick, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element {
|
||||
const button = (
|
||||
<Button
|
||||
className="mx_IncomingCallToast_actionButton"
|
||||
onClick={onClick}
|
||||
disabled={disabledTooltip != undefined}
|
||||
kind="primary"
|
||||
Icon={CheckIcon}
|
||||
size="md"
|
||||
>
|
||||
{_t("action|join")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return disabledTooltip === undefined ? (
|
||||
button
|
||||
) : (
|
||||
<Tooltip description={disabledTooltip ?? _t("voip|video_call")}>{button}</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,19 +134,16 @@ function DeclineCallButtonWithNotificationEvent({
|
||||
[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="md"
|
||||
>
|
||||
{_t("action|decline")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
className="mx_IncomingCallToast_actionButton"
|
||||
onClick={onClick}
|
||||
kind="secondary"
|
||||
disabled={declining}
|
||||
Icon={CloseIcon}
|
||||
size="md"
|
||||
>
|
||||
{_t("action|ignore")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +226,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E
|
||||
|
||||
// Dismiss if another device from this user joins.
|
||||
const onParticipantChange = useCallback(
|
||||
(participants: Map<RoomMember, Set<string>>, prevParticipants: Map<RoomMember, Set<string>>) => {
|
||||
(participants: Map<RoomMember, Set<string>>) => {
|
||||
if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) {
|
||||
dismissToast();
|
||||
}
|
||||
@@ -238,32 +254,33 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E
|
||||
),
|
||||
);
|
||||
|
||||
const [skipLobbyToggle, setSkipLobbyToggle] = useState(true);
|
||||
const [videoToggle, setVideoToggle] = useState(true);
|
||||
const videoToggleId = useId();
|
||||
|
||||
// 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();
|
||||
const isVoice = notificationContent["m.call.intent"] === "audio";
|
||||
|
||||
const viewCall = useCallback(
|
||||
(skipLobby: boolean) => {
|
||||
// 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",
|
||||
skipLobby,
|
||||
voiceOnly: isVoice || !videoToggle,
|
||||
metricsTrigger: undefined,
|
||||
});
|
||||
},
|
||||
[room, skipLobbyToggle, notificationContent],
|
||||
[room, isVoice, videoToggle],
|
||||
);
|
||||
|
||||
const onJoinClick = useCallback(() => viewCall(true), [viewCall]);
|
||||
const onExpandClick = useCallback(() => viewCall(false), [viewCall]);
|
||||
|
||||
// Dismiss on closing toast.
|
||||
const onCloseClick = useCallback(
|
||||
(e: ButtonEvent): void => {
|
||||
e.stopPropagation();
|
||||
|
||||
dismissToast();
|
||||
},
|
||||
[dismissToast],
|
||||
@@ -272,75 +289,101 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E
|
||||
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}
|
||||
/>
|
||||
);
|
||||
const members = useParticipatingMembers(call);
|
||||
const avatars = (): ReactNode => (
|
||||
<AvatarStack className="mx_IncomingCallToast_avatars">
|
||||
{members.slice(0, 3).map((m) => (
|
||||
<MemberAvatar key={m.userId} size="20px" member={m} aria-label={m.name} />
|
||||
))}
|
||||
</AvatarStack>
|
||||
);
|
||||
|
||||
let detailsInformation: ReactNode;
|
||||
if (notificationContent.notification_type === "ring") {
|
||||
detailsInformation = <span>{otherUserId}</span>;
|
||||
} else if (members.length > 0) {
|
||||
detailsInformation =
|
||||
members.length > 3
|
||||
? _t(
|
||||
"voip|call_members|overflow",
|
||||
{ count: members.length, overflowCount: members.length - 3 },
|
||||
{ avatars },
|
||||
)
|
||||
: _t("voip|call_members|exhaustive", { count: members.length }, { avatars });
|
||||
}
|
||||
|
||||
let title: string;
|
||||
let Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
let iconLabel: string;
|
||||
// Special title for group calls
|
||||
if (otherUserId === undefined) title = _t("voip|group_call_started");
|
||||
if (isVoice) {
|
||||
title ??= _t("voip|voice_call_incoming");
|
||||
Icon = VoiceCallSolidIcon;
|
||||
iconLabel = _t("voip|voice_call");
|
||||
} else {
|
||||
title ??= _t("voip|video_call_incoming");
|
||||
Icon = VideoCallSolidIcon;
|
||||
iconLabel = _t("voip|video_call");
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="mx_IncomingCallToast_content">
|
||||
<div className="mx_IncomingCallToast_title">
|
||||
<Icon aria-label={iconLabel} width={20} height={20} />
|
||||
<Heading as="h2" type="body" size="lg" weight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
<AccessibleButton
|
||||
className="mx_IncomingCallToast_closeButton"
|
||||
onClick={onCloseClick}
|
||||
title={_t("action|close")}
|
||||
className="mx_IncomingCallToast_expandButton"
|
||||
onClick={onExpandClick}
|
||||
title={_t("action|expand")}
|
||||
>
|
||||
<CloseIcon />
|
||||
<ExpandIcon width={16} height={16} aria-hidden />
|
||||
</AccessibleButton>
|
||||
</>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<AvatarWithDetails
|
||||
avatar={<RoomAvatar room={room ?? undefined} size="40px" />}
|
||||
details={detailsInformation}
|
||||
title={room ? room.name : _t("voip|call_toast_unknown_room")}
|
||||
className="mx_IncomingCallToast_AvatarWithDetails"
|
||||
/>
|
||||
{!isVoice && (
|
||||
<Form.Root
|
||||
onSubmit={(evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<InlineField
|
||||
name="videoToggle"
|
||||
control={
|
||||
<ToggleInput
|
||||
id={videoToggleId}
|
||||
checked={videoToggle}
|
||||
onChange={(e) => setVideoToggle(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Label htmlFor={videoToggleId}>{_t("voip|join_with_video")}</Label>
|
||||
</InlineField>
|
||||
</Form.Root>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user