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:
Robin
2026-04-27 14:37:42 +00:00
committed by GitHub
parent 2ea0c4106b
commit 03b730db58
30 changed files with 350 additions and 270 deletions
@@ -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 && (
+2 -10
View File
@@ -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]);
};
+12 -4
View File
@@ -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",
+7 -3
View File
@@ -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);
}
}
}
+151 -108
View File
@@ -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>
);
}