mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-30 15:01:43 +01:00
Support using Element Call for voice calls in DMs (#30817)
* Add voiceOnly options. * tweaks * Nearly working demo * Lots of minor fixes * Better working version * remove unused payload * bits and pieces * Cleanup based on new hints * Simple refactor for skipLobby (and remove returnToLobby) * Tidyup * Remove unused tests * Update tests for voice calls * Add video room support. * Add a test for video rooms * tidy * remove console log line * lint and tests * Bunch of fixes * Fixes * Use correct title * make linter happier * Update tests * cleanup * Drop only * update snaps * Document * lint * Update snapshots * Remove duplicate test * add brackets * fix jest
This commit is contained in:
parent
3d683ec5c6
commit
f3a880f1c3
@ -9,7 +9,7 @@ import type { EventType, Preset } from "matrix-js-sdk/src/matrix";
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import type { Credentials } from "../../plugins/homeserver";
|
||||
import type { Bot } from "../../pages/bot";
|
||||
import { Bot } from "../../pages/bot";
|
||||
|
||||
function assertCommonCallParameters(
|
||||
url: URLSearchParams,
|
||||
@ -27,27 +27,28 @@ function assertCommonCallParameters(
|
||||
expect(hash.get("preload")).toEqual("false");
|
||||
}
|
||||
|
||||
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification") {
|
||||
async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "notification", intent?: string) {
|
||||
const resp = await bot.sendStateEvent(
|
||||
roomId,
|
||||
"org.matrix.msc3401.call.member",
|
||||
{
|
||||
application: "m.call",
|
||||
call_id: "",
|
||||
device_id: "OiDFxsZrjz",
|
||||
expires: 180000000,
|
||||
foci_preferred: [
|
||||
"application": "m.call",
|
||||
"call_id": "",
|
||||
"m.call.intent": intent,
|
||||
"device_id": "OiDFxsZrjz",
|
||||
"expires": 180000000,
|
||||
"foci_preferred": [
|
||||
{
|
||||
livekit_alias: roomId,
|
||||
livekit_service_url: "https://example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
focus_active: {
|
||||
"focus_active": {
|
||||
focus_selection: "oldest_membership",
|
||||
type: "livekit",
|
||||
},
|
||||
scope: "m.room",
|
||||
"scope": "m.room",
|
||||
},
|
||||
`_@${bot.credentials.userId}_OiDFxsZrjz_m.call`,
|
||||
);
|
||||
@ -64,6 +65,7 @@ async function sendRTCState(bot: Bot, roomId: string, notification?: "ring" | "n
|
||||
event_id: resp.event_id,
|
||||
rel_type: "org.matrix.msc4075.rtc.notification.parent",
|
||||
},
|
||||
"m.call.intent": intent,
|
||||
"notification_type": notification,
|
||||
"sender_ts": 1758611895996,
|
||||
});
|
||||
@ -103,15 +105,21 @@ test.describe("Element Call", () => {
|
||||
});
|
||||
|
||||
test.describe("Group Chat", () => {
|
||||
let charlie: Bot;
|
||||
test.use({
|
||||
room: async ({ page, app, user, bot }, use) => {
|
||||
const roomId = await app.client.createRoom({ name: "TestRoom", invite: [bot.credentials.userId] });
|
||||
room: async ({ page, app, user, homeserver, bot }, use) => {
|
||||
charlie = new Bot(page, homeserver, { displayName: "Charlie" });
|
||||
await charlie.prepareClient();
|
||||
const roomId = await app.client.createRoom({
|
||||
name: "TestRoom",
|
||||
invite: [bot.credentials.userId, charlie.credentials.userId],
|
||||
});
|
||||
await use({ roomId });
|
||||
},
|
||||
});
|
||||
test("should be able to start a video call", async ({ page, user, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Video call" }).click();
|
||||
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||
@ -126,9 +134,16 @@ test.describe("Element Call", () => {
|
||||
expect(hash.get("skipLobby")).toEqual(null);
|
||||
});
|
||||
|
||||
test("should NOT be able to start a voice call", async ({ page, user, room, app }) => {
|
||||
// Voice calls do not exist in group rooms
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Voice call" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should be able to skip lobby by holding down shift", async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Video call" }).click();
|
||||
await page.keyboard.down("Shift");
|
||||
@ -147,8 +162,8 @@ test.describe("Element Call", () => {
|
||||
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId);
|
||||
const button = page.getByTestId("join-call-button");
|
||||
@ -156,7 +171,6 @@ test.describe("Element Call", () => {
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
console.log(frameUrlStr);
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
@ -168,29 +182,29 @@ test.describe("Element Call", () => {
|
||||
|
||||
[true, false].forEach((skipLobbyToggle) => {
|
||||
test(
|
||||
`should be able to join a call via incoming call toast (skipLobby=${skipLobbyToggle})`,
|
||||
`should be able to join a call via incoming video call toast (skipLobby=${skipLobbyToggle})`,
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId, "notification");
|
||||
await sendRTCState(bot, room.roomId, "notification", "video");
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const button = toast.getByRole("button", { name: "Join" });
|
||||
|
||||
if (skipLobbyToggle) {
|
||||
await toast.getByRole("switch").check();
|
||||
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-checked.png");
|
||||
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-checked.png`);
|
||||
} else {
|
||||
await toast.getByRole("switch").uncheck();
|
||||
await expect(toast).toMatchScreenshot("incoming-call-group-video-toast-unchecked.png");
|
||||
await expect(toast).toMatchScreenshot(`incoming-call-group-video-toast-unchecked.png`);
|
||||
}
|
||||
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
console.log(frameUrlStr);
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
@ -201,6 +215,34 @@ test.describe("Element Call", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
`should be able to join a call via incoming voice call toast`,
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible();
|
||||
await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50);
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId, "notification", "audio");
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const button = toast.getByRole("button", { name: "Join" });
|
||||
|
||||
await expect(toast).toMatchScreenshot(`incoming-call-group-voice-toast.png`);
|
||||
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
assertCommonCallParameters(url.searchParams, hash, user, room);
|
||||
|
||||
expect(hash.get("intent")).toEqual("join_existing");
|
||||
expect(hash.get("skipLobby")).toEqual("true");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("DMs", () => {
|
||||
@ -253,7 +295,6 @@ test.describe("Element Call", () => {
|
||||
|
||||
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId);
|
||||
@ -262,7 +303,6 @@ test.describe("Element Call", () => {
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
console.log(frameUrlStr);
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
@ -278,24 +318,31 @@ test.describe("Element Call", () => {
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
// Allow bob to create a call
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId, "ring");
|
||||
await sendRTCState(bot, room.roomId, "ring", "video");
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const button = toast.getByRole("button", { name: "Join" });
|
||||
const button = toast.getByRole("button", { name: "Accept" });
|
||||
if (skipLobbyToggle) {
|
||||
await toast.getByRole("switch").check();
|
||||
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-checked.png");
|
||||
} else {
|
||||
await toast.getByRole("switch").uncheck();
|
||||
await expect(toast).toMatchScreenshot("incoming-call-dm-video-toast-unchecked.png");
|
||||
}
|
||||
await expect(toast).toMatchScreenshot(
|
||||
`incoming-call-dm-video-toast-${skipLobbyToggle ? "checked" : "unchecked"}.png`,
|
||||
{
|
||||
// Hide UserId
|
||||
css: `
|
||||
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
`,
|
||||
},
|
||||
);
|
||||
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
console.log(frameUrlStr);
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
@ -306,6 +353,39 @@ test.describe("Element Call", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
`should be able to join a call via incoming voice call toast`,
|
||||
{ tag: ["@screenshot"] },
|
||||
async ({ page, user, bot, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||
// Fake a start of a call
|
||||
await sendRTCState(bot, room.roomId, "ring", "audio");
|
||||
const toast = page.locator(".mx_Toast_toast");
|
||||
const button = toast.getByRole("button", { name: "Accept" });
|
||||
|
||||
await expect(toast).toMatchScreenshot(`incoming-call-dm-voice-toast.png`, {
|
||||
// Hide UserId
|
||||
css: `
|
||||
.mx_IncomingCallToast_AvatarWithDetails span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
// And test joining
|
||||
await button.click();
|
||||
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||
await expect(frameUrlStr).toBeDefined();
|
||||
const url = new URL(frameUrlStr);
|
||||
const hash = new URLSearchParams(url.hash.slice(1));
|
||||
assertCommonCallParameters(url.searchParams, hash, user, room);
|
||||
|
||||
expect(hash.get("intent")).toEqual("join_existing_dm_voice");
|
||||
expect(hash.get("skipLobby")).toEqual("true");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Video Rooms", () => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@ -25,6 +25,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
|
||||
}
|
||||
|
||||
&.mx_LiveContentSummary_text_voice::before {
|
||||
mask-image: url("$(res)/img/element-icons/call/voice-call.svg");
|
||||
}
|
||||
|
||||
&.mx_LiveContentSummary_text_active {
|
||||
color: $accent;
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
@ -19,7 +20,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
|
||||
import { type ConnectionState } from "../../../models/Call";
|
||||
import { CallEvent, type ConnectionState } from "../../../models/Call";
|
||||
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
@ -67,6 +68,10 @@ export interface RoomListItemViewState {
|
||||
* Whether there are participants in the call.
|
||||
*/
|
||||
hasParticipantInCall: boolean;
|
||||
/**
|
||||
* Whether the call is a voice or video call.
|
||||
*/
|
||||
callType: CallType | undefined;
|
||||
/**
|
||||
* Pre-rendered and translated preview for the latest message in the room, or undefined
|
||||
* if no preview should be shown.
|
||||
@ -123,10 +128,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
// EC video call or video room
|
||||
const call = useCall(room.roomId);
|
||||
const connectionState = useConnectionState(call);
|
||||
const hasParticipantInCall = useParticipantCount(call) > 0;
|
||||
const participantCount = useParticipantCount(call);
|
||||
const callConnectionState = call ? connectionState : null;
|
||||
|
||||
const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;
|
||||
const showNotificationDecoration = hasVisibleNotification || participantCount > 0;
|
||||
|
||||
// Actions
|
||||
|
||||
@ -138,6 +143,9 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
});
|
||||
}, [room]);
|
||||
|
||||
const [callType, setCallType] = useState<CallType>(CallType.Video);
|
||||
useTypedEventEmitter(call ?? undefined, CallEvent.CallTypeChanged, setCallType);
|
||||
|
||||
return {
|
||||
name,
|
||||
notificationState,
|
||||
@ -148,9 +156,10 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
isBold,
|
||||
isVideoRoom,
|
||||
callConnectionState,
|
||||
hasParticipantInCall,
|
||||
hasParticipantInCall: participantCount > 0,
|
||||
messagePreview,
|
||||
showNotificationDecoration,
|
||||
callType: call ? callType : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -10,12 +10,10 @@ import React, { type FC } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { type Call } from "../../../models/Call";
|
||||
import { useParticipantCount } from "../../../hooks/useCall";
|
||||
|
||||
export enum LiveContentType {
|
||||
Video,
|
||||
// More coming soon
|
||||
Voice,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@ -33,6 +31,7 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
|
||||
<span
|
||||
className={classNames("mx_LiveContentSummary_text", {
|
||||
mx_LiveContentSummary_text_video: type === LiveContentType.Video,
|
||||
mx_LiveContentSummary_text_voice: type === LiveContentType.Voice,
|
||||
mx_LiveContentSummary_text_active: active,
|
||||
})}
|
||||
>
|
||||
@ -51,16 +50,3 @@ export const LiveContentSummary: FC<Props> = ({ type, text, active, participantC
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
interface LiveContentSummaryWithCallProps {
|
||||
call: Call;
|
||||
}
|
||||
|
||||
export const LiveContentSummaryWithCall: FC<LiveContentSummaryWithCallProps> = ({ call }) => (
|
||||
<LiveContentSummary
|
||||
type={LiveContentType.Video}
|
||||
text={_t("common|video")}
|
||||
active={false}
|
||||
participantCount={useParticipantCount(call)}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -12,6 +12,8 @@ import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/ic
|
||||
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid";
|
||||
import EmailIcon from "@vector-im/compound-design-tokens/assets/web/icons/email-solid";
|
||||
import { UnreadCounter, Unread } from "@vector-im/compound-web";
|
||||
import VoiceCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/voice-call-solid";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { Flex } from "@element-hq/web-shared-components";
|
||||
|
||||
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
|
||||
@ -24,9 +26,9 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
|
||||
*/
|
||||
notificationState: RoomNotificationState;
|
||||
/**
|
||||
* Whether the room has a video call.
|
||||
* Whether the room has a voice or video call.
|
||||
*/
|
||||
hasVideoCall: boolean;
|
||||
callType?: CallType;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -34,7 +36,7 @@ interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
|
||||
*/
|
||||
export function NotificationDecoration({
|
||||
notificationState,
|
||||
hasVideoCall,
|
||||
callType,
|
||||
...props
|
||||
}: NotificationDecorationProps): JSX.Element | null {
|
||||
// Listen to the notification state and update the component when it changes
|
||||
@ -58,7 +60,7 @@ export function NotificationDecoration({
|
||||
muted: notificationState.muted,
|
||||
}));
|
||||
|
||||
if (!hasAnyNotificationOrActivity && !muted && !hasVideoCall) return null;
|
||||
if (!hasAnyNotificationOrActivity && !muted && !callType) return null;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@ -69,7 +71,12 @@ export function NotificationDecoration({
|
||||
data-testid="notification-decoration"
|
||||
>
|
||||
{isUnsentMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
|
||||
{hasVideoCall && <VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{callType === CallType.Video && (
|
||||
<VideoCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{callType === CallType.Voice && (
|
||||
<VoiceCallIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />
|
||||
)}
|
||||
{invited && <EmailIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
|
||||
{(isMention || isNotification) && <UnreadCounter count={count || null} />}
|
||||
|
||||
@ -132,7 +132,7 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
<NotificationDecoration
|
||||
notificationState={vm.notificationState}
|
||||
aria-hidden={true}
|
||||
hasVideoCall={vm.hasParticipantInCall}
|
||||
callType={vm.callType}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -121,7 +121,7 @@ export enum Action {
|
||||
UpdateSystemFont = "update_system_font",
|
||||
|
||||
/**
|
||||
* Changes room based on payload parameters. Should be used with JoinRoomPayload.
|
||||
* Changes room based on payload parameters. Should be used with ViewRoomPayload.
|
||||
*/
|
||||
ViewRoom = "view_room",
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ interface BaseViewRoomPayload extends Pick<ActionPayload, "action"> {
|
||||
clear_search?: boolean; // Whether to clear the room list search
|
||||
view_call?: boolean; // Whether to view the call or call lobby for the room
|
||||
skipLobby?: boolean; // Whether to skip the call lobby when showing the call (only supported for element calls)
|
||||
voiceOnly?: boolean; // Whether the call is voice only (only supported for element calls)
|
||||
opts?: JoinRoomPayload["opts"];
|
||||
|
||||
deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action
|
||||
|
||||
@ -142,11 +142,6 @@ export const useRoomCall = (
|
||||
// If there are multiple options, the user will be prompted to choose.
|
||||
const callOptions = useMemo((): PlatformCallType[] => {
|
||||
const options: PlatformCallType[] = [];
|
||||
if (memberCount <= 2) {
|
||||
options.push(PlatformCallType.LegacyCall);
|
||||
} else if (mayEditWidgets || hasJitsiWidget) {
|
||||
options.push(PlatformCallType.JitsiCall);
|
||||
}
|
||||
if (groupCallsEnabled) {
|
||||
if (hasGroupCall || mayCreateElementCalls) {
|
||||
options.push(PlatformCallType.ElementCall);
|
||||
@ -155,6 +150,11 @@ export const useRoomCall = (
|
||||
return [PlatformCallType.ElementCall];
|
||||
}
|
||||
}
|
||||
if (memberCount <= 2) {
|
||||
options.push(PlatformCallType.LegacyCall);
|
||||
} else if (mayEditWidgets || hasJitsiWidget) {
|
||||
options.push(PlatformCallType.JitsiCall);
|
||||
}
|
||||
if (hasGroupCall && WidgetType.CALL.matches(groupCall.widget.type)) {
|
||||
// only allow joining the ongoing Element call if there is one.
|
||||
return [PlatformCallType.ElementCall];
|
||||
@ -231,7 +231,7 @@ export const useRoomCall = (
|
||||
if (widget && promptPinWidget) {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Top);
|
||||
} else {
|
||||
placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined);
|
||||
placeCall(room, CallType.Voice, callPlatformType, evt?.shiftKey || undefined, true);
|
||||
}
|
||||
},
|
||||
[promptPinWidget, room, widget],
|
||||
@ -244,7 +244,7 @@ export const useRoomCall = (
|
||||
} else {
|
||||
// If we have pressed shift then always skip the lobby, otherwise `undefined` will defer
|
||||
// to the defaults of the call implementation.
|
||||
placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined);
|
||||
placeCall(room, CallType.Video, callPlatformType, evt?.shiftKey || undefined, false);
|
||||
}
|
||||
},
|
||||
[widget, promptPinWidget, room],
|
||||
@ -279,7 +279,13 @@ export const useRoomCall = (
|
||||
const roomDoesNotExist = room instanceof LocalRoom && room.state !== LocalRoomState.CREATED;
|
||||
|
||||
// We hide the voice call button if it'd have the same effect as the video call button
|
||||
let hideVoiceCallButton = isManagedHybridWidgetEnabled(room) || !callOptions.includes(PlatformCallType.LegacyCall);
|
||||
let hideVoiceCallButton =
|
||||
isManagedHybridWidgetEnabled(room) ||
|
||||
// Disable voice calls if Legacy calls are disabled
|
||||
(!callOptions.includes(PlatformCallType.LegacyCall) &&
|
||||
// Disable voice calls in ECall if the room is a group (we only present video calls for groups of users)
|
||||
(!callOptions.includes(PlatformCallType.ElementCall) || memberCount > 2));
|
||||
|
||||
let hideVideoCallButton = false;
|
||||
// We hide both buttons if:
|
||||
// - they require widgets but widgets are disabled
|
||||
|
||||
@ -603,6 +603,7 @@
|
||||
"video": "Video",
|
||||
"video_room": "Video room",
|
||||
"view_message": "View message",
|
||||
"voice": "Voice",
|
||||
"warning": "Warning"
|
||||
},
|
||||
"composer": {
|
||||
@ -4096,9 +4097,11 @@
|
||||
"user_busy_description": "The user you called is busy.",
|
||||
"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",
|
||||
"you_are_presenting": "You are presenting"
|
||||
},
|
||||
"web_default_device_name": "%(appName)s: %(browserName)s on %(osName)s",
|
||||
|
||||
@ -84,6 +84,7 @@ export enum CallEvent {
|
||||
Participants = "participants",
|
||||
Close = "close",
|
||||
Destroy = "destroy",
|
||||
CallTypeChanged = "call_type_changed",
|
||||
}
|
||||
|
||||
interface CallEventHandlerMap {
|
||||
@ -94,6 +95,7 @@ interface CallEventHandlerMap {
|
||||
) => void;
|
||||
[CallEvent.Close]: () => void;
|
||||
[CallEvent.Destroy]: () => void;
|
||||
[CallEvent.CallTypeChanged]: (callType: CallType) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -103,6 +105,18 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
|
||||
protected readonly widgetUid: string;
|
||||
protected readonly room: Room;
|
||||
|
||||
private _callType: CallType = CallType.Video;
|
||||
public get callType(): CallType {
|
||||
return this._callType;
|
||||
}
|
||||
|
||||
protected set callType(callType: CallType) {
|
||||
if (this._callType !== callType) {
|
||||
this.emit(CallEvent.CallTypeChanged, callType);
|
||||
}
|
||||
this._callType = callType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The time after which device member state should be considered expired.
|
||||
*/
|
||||
@ -544,7 +558,24 @@ export enum ElementCallIntent {
|
||||
StartCall = "start_call",
|
||||
JoinExisting = "join_existing",
|
||||
StartCallDM = "start_call_dm",
|
||||
StartCallDMVoice = "start_call_dm_voice",
|
||||
JoinExistingDM = "join_existing_dm",
|
||||
JoinExistingDMVoice = "join_existing_dm_voice",
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters to be passed during widget creation.
|
||||
* These parameters are hints only, and may not be accepted by the implementation.
|
||||
*/
|
||||
export interface WidgetGenerationParameters {
|
||||
/**
|
||||
* Skip showing the lobby screen of a call.
|
||||
*/
|
||||
skipLobby?: boolean;
|
||||
/**
|
||||
* Does the user intent to start a voice call?
|
||||
*/
|
||||
voiceOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -586,7 +617,12 @@ export class ElementCall extends Call {
|
||||
* @param client The current client.
|
||||
* @param roomId The room ID for the call.
|
||||
*/
|
||||
private static appendRoomParams(params: URLSearchParams, client: MatrixClient, roomId: string): void {
|
||||
private static appendRoomParams(
|
||||
params: URLSearchParams,
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
{ voiceOnly }: WidgetGenerationParameters,
|
||||
): void {
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
// If the room isn't known, or the room is a video room then skip setting an intent.
|
||||
@ -610,13 +646,17 @@ export class ElementCall extends Call {
|
||||
// is released and upgraded.
|
||||
if (isDM) {
|
||||
if (hasCallStarted) {
|
||||
params.append("intent", ElementCallIntent.JoinExistingDM);
|
||||
params.append(
|
||||
"intent",
|
||||
voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM,
|
||||
);
|
||||
params.append("preload", "false");
|
||||
} else {
|
||||
params.append("intent", ElementCallIntent.StartCallDM);
|
||||
params.append("intent", voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM);
|
||||
params.append("preload", "false");
|
||||
}
|
||||
} else {
|
||||
// Group chats do not have a voice option.
|
||||
if (hasCallStarted) {
|
||||
params.append("intent", ElementCallIntent.JoinExisting);
|
||||
params.append("preload", "false");
|
||||
@ -717,7 +757,7 @@ export class ElementCall extends Call {
|
||||
.forEach((font) => params.append("font", font));
|
||||
}
|
||||
this.appendAnalyticsParams(params, client);
|
||||
this.appendRoomParams(params, client, roomId);
|
||||
this.appendRoomParams(params, client, roomId, opts);
|
||||
|
||||
const replacedUrl = params.toString().replace(/%24/g, "$");
|
||||
url.hash = `#?${replacedUrl}`;
|
||||
@ -751,11 +791,43 @@ export class ElementCall extends Call {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the correct intent for a widget, so that Element Call presents the correct
|
||||
* default config.
|
||||
* @param client The matrix client.
|
||||
* @param roomId
|
||||
* @param voiceOnly Should the call be voice-only, or video (default).
|
||||
*/
|
||||
public static getWidgetIntent(client: MatrixClient, roomId: string, voiceOnly?: boolean): ElementCallIntent {
|
||||
const room = client.getRoom(roomId);
|
||||
if (room !== null && !isVideoRoom(room)) {
|
||||
const isDM = !!DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
const oldestCallMember = client.matrixRTC.getRoomSession(room).getOldestMembership();
|
||||
const hasCallStarted = !!oldestCallMember && oldestCallMember.sender !== client.getSafeUserId();
|
||||
if (isDM) {
|
||||
if (hasCallStarted) {
|
||||
return voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM;
|
||||
} else {
|
||||
return voiceOnly ? ElementCallIntent.StartCallDMVoice : ElementCallIntent.StartCallDM;
|
||||
}
|
||||
} else {
|
||||
if (hasCallStarted) {
|
||||
return ElementCallIntent.JoinExisting;
|
||||
} else {
|
||||
return ElementCallIntent.StartCall;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If unknown, default to joining an existing call.
|
||||
return ElementCallIntent.JoinExisting;
|
||||
}
|
||||
|
||||
private static getWidgetData(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
currentData: IWidgetData,
|
||||
overwriteData: IWidgetData,
|
||||
voiceOnly?: boolean,
|
||||
): IWidgetData {
|
||||
let perParticipantE2EE = false;
|
||||
if (
|
||||
@ -763,9 +835,13 @@ export class ElementCall extends Call {
|
||||
!SettingsStore.getValue("feature_disable_call_per_sender_encryption")
|
||||
)
|
||||
perParticipantE2EE = true;
|
||||
|
||||
const intent = ElementCall.getWidgetIntent(client, roomId, voiceOnly);
|
||||
|
||||
return {
|
||||
...currentData,
|
||||
...overwriteData,
|
||||
intent,
|
||||
perParticipantE2EE,
|
||||
};
|
||||
}
|
||||
@ -791,7 +867,7 @@ export class ElementCall extends Call {
|
||||
this.updateParticipants();
|
||||
}
|
||||
|
||||
public static get(room: Room): ElementCall | null {
|
||||
public static get(room: Room, voiceOnly?: boolean): ElementCall | null {
|
||||
const apps = WidgetStore.instance.getApps(room.roomId);
|
||||
const hasEcWidget = apps.some((app) => WidgetType.CALL.matches(app.type));
|
||||
const session = room.client.matrixRTC.getRoomSession(room);
|
||||
@ -874,7 +950,10 @@ export class ElementCall extends Call {
|
||||
if (this.session.memberships.length === 0 && !this.presented && !this.room.isCallRoom()) this.destroy();
|
||||
};
|
||||
|
||||
private readonly onMembershipChanged = (): void => this.updateParticipants();
|
||||
private readonly onMembershipChanged = (): void => {
|
||||
this.updateParticipants();
|
||||
this.callType = this.session.getConsensusCallIntent() === "audio" ? CallType.Voice : CallType.Video;
|
||||
};
|
||||
|
||||
private updateParticipants(): void {
|
||||
const participants = new Map<RoomMember, Set<string>>();
|
||||
|
||||
@ -365,7 +365,9 @@ export class RoomViewStore extends EventEmitter {
|
||||
call.presented = true;
|
||||
// Immediately start the call. This will connect to all required widget events
|
||||
// and allow the widget to show the lobby.
|
||||
if (call.connectionState === ConnectionState.Disconnected) call.start({ skipLobby: payload.skipLobby });
|
||||
if (call.connectionState === ConnectionState.Disconnected) {
|
||||
call.start({ skipLobby: payload.skipLobby, voiceOnly: payload.voiceOnly });
|
||||
}
|
||||
}
|
||||
// If we switch to a different room from the call, we are no longer presenting it
|
||||
const prevRoomCall = this.state.roomId ? CallStore.instance.getCall(this.state.roomId) : null;
|
||||
|
||||
@ -14,6 +14,7 @@ import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"
|
||||
import CrossIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
|
||||
import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { AvatarWithDetails } from "@element-hq/web-shared-components";
|
||||
|
||||
import { _t } from "../languageHandler";
|
||||
@ -23,12 +24,8 @@ import defaultDispatcher from "../dispatcher/dispatcher";
|
||||
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import ToastStore from "../stores/ToastStore";
|
||||
import {
|
||||
LiveContentSummary,
|
||||
LiveContentSummaryWithCall,
|
||||
LiveContentType,
|
||||
} from "../components/views/rooms/LiveContentSummary";
|
||||
import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall";
|
||||
import { LiveContentSummary, LiveContentType } from "../components/views/rooms/LiveContentSummary";
|
||||
import { useCall, useJoinCallButtonDisabledTooltip, useParticipantCount } from "../hooks/useCall";
|
||||
import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton";
|
||||
import { useDispatcher } from "../hooks/useDispatcher";
|
||||
import { type ActionPayload } from "../dispatcher/payloads";
|
||||
@ -36,6 +33,7 @@ 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.
|
||||
@ -71,9 +69,15 @@ interface JoinCallButtonWithCallProps {
|
||||
onClick: (e: ButtonEvent) => void;
|
||||
call: Call | null;
|
||||
disabledTooltip: string | undefined;
|
||||
isRinging: boolean;
|
||||
}
|
||||
|
||||
function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButtonWithCallProps): JSX.Element {
|
||||
function JoinCallButtonWithCall({
|
||||
onClick,
|
||||
call,
|
||||
disabledTooltip,
|
||||
isRinging,
|
||||
}: JoinCallButtonWithCallProps): JSX.Element {
|
||||
let disTooltip = disabledTooltip;
|
||||
const disabledBecauseFullTooltip = useJoinCallButtonDisabledTooltip(call);
|
||||
disTooltip = disabledTooltip ?? disabledBecauseFullTooltip ?? undefined;
|
||||
@ -88,7 +92,7 @@ function JoinCallButtonWithCall({ onClick, call, disabledTooltip }: JoinCallButt
|
||||
Icon={CheckIcon}
|
||||
size="sm"
|
||||
>
|
||||
{_t("action|join")}
|
||||
{isRinging ? _t("action|accept") : _t("action|join")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
@ -152,7 +156,7 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
|
||||
// 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";
|
||||
const isRingToast = notificationContent.notification_type === "ring";
|
||||
if (isRingToast && !soundHasStarted.current && !LegacyCallHandler.instance.isPlaying(AudioID.Ring)) {
|
||||
// Start ringing if not already.
|
||||
soundHasStarted.current = true;
|
||||
@ -243,10 +247,11 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
|
||||
room_id: room?.roomId,
|
||||
view_call: true,
|
||||
skipLobby: ("shiftKey" in e && e.shiftKey) || skipLobbyToggle,
|
||||
voiceOnly: notificationContent["m.call.intent"] === "audio",
|
||||
metricsTrigger: undefined,
|
||||
});
|
||||
},
|
||||
[room, skipLobbyToggle],
|
||||
[room, skipLobbyToggle, notificationContent],
|
||||
);
|
||||
|
||||
// Dismiss on closing toast.
|
||||
@ -262,34 +267,53 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
|
||||
useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall);
|
||||
useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange);
|
||||
useEventEmitter(room, RoomEvent.Timeline, onTimelineChange);
|
||||
|
||||
const callLiveContentSummary = call ? (
|
||||
<LiveContentSummaryWithCall call={call} />
|
||||
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={LiveContentType.Video}
|
||||
text={_t("common|video")}
|
||||
type={isVoice ? LiveContentType.Voice : LiveContentType.Video}
|
||||
text={isVoice ? _t("common|voice") : _t("common|video")}
|
||||
active={false}
|
||||
participantCount={0}
|
||||
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" }} />{" "}
|
||||
{_t("voip|video_call_started")}
|
||||
{notificationContent.notification_type === "ring"
|
||||
? _t("voip|video_call_incoming")
|
||||
: _t("voip|video_call_started")}
|
||||
</div>
|
||||
)}
|
||||
<AvatarWithDetails
|
||||
avatar={<RoomAvatar room={room ?? undefined} size="32px" />}
|
||||
details={callLiveContentSummary}
|
||||
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} />
|
||||
<ToggleInput
|
||||
onChange={(e) => setSkipLobbyToggle(e.target.checked)}
|
||||
checked={skipLobbyToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mx_IncomingCallToast_buttons">
|
||||
<DeclineCallButtonWithNotificationEvent
|
||||
notificationEvent={notificationEvent}
|
||||
@ -299,6 +323,7 @@ export function IncomingCallToast({ notificationEvent }: Props): JSX.Element {
|
||||
<JoinCallButtonWithCall
|
||||
onClick={onJoinClick}
|
||||
call={call}
|
||||
isRinging={notificationContent.notification_type === "ring"}
|
||||
disabledTooltip={otherCallIsOngoing ? "Ongoing call" : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -27,7 +27,8 @@ export const placeCall = async (
|
||||
room: Room,
|
||||
callType: CallType,
|
||||
platformCallType: PlatformCallType,
|
||||
skipLobby?: boolean,
|
||||
skipLobby: boolean | undefined,
|
||||
voiceOnly: boolean,
|
||||
): Promise<void> => {
|
||||
const { analyticsName } = getPlatformCallTypeProps(platformCallType);
|
||||
PosthogTrackers.trackInteraction(analyticsName);
|
||||
@ -39,6 +40,7 @@ export const placeCall = async (
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
view_call: true,
|
||||
voiceOnly,
|
||||
skipLobby,
|
||||
metricsTrigger: undefined,
|
||||
});
|
||||
|
||||
@ -181,6 +181,7 @@ export function setUpClientRoomAndStores(): {
|
||||
const roomSession = new MockEventEmitter({
|
||||
memberships: [],
|
||||
getOldestMembership: jest.fn().mockReturnValue(undefined),
|
||||
getConsensusCallIntent: jest.fn().mockReturnValue(undefined),
|
||||
room,
|
||||
}) as Mocked<MatrixRTCSession>;
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
|
||||
import { NotificationDecoration } from "../../../../../src/components/views/rooms/NotificationDecoration";
|
||||
@ -22,7 +23,7 @@ describe("<NotificationDecoration />", () => {
|
||||
|
||||
it("should not render if RoomNotificationState.hasAnyNotificationOrActivity=true", () => {
|
||||
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false);
|
||||
render(<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />);
|
||||
render(<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />);
|
||||
expect(screen.queryByTestId("notification-decoration")).toBeNull();
|
||||
});
|
||||
|
||||
@ -30,7 +31,7 @@ describe("<NotificationDecoration />", () => {
|
||||
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
jest.spyOn(roomNotificationState, "isUnsentMessage", "get").mockReturnValue(true);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
@ -39,7 +40,7 @@ describe("<NotificationDecoration />", () => {
|
||||
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
jest.spyOn(roomNotificationState, "invited", "get").mockReturnValue(true);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
@ -49,7 +50,7 @@ describe("<NotificationDecoration />", () => {
|
||||
jest.spyOn(roomNotificationState, "isMention", "get").mockReturnValue(true);
|
||||
jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(1);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
@ -59,7 +60,7 @@ describe("<NotificationDecoration />", () => {
|
||||
jest.spyOn(roomNotificationState, "isNotification", "get").mockReturnValue(true);
|
||||
jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(1);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
@ -69,7 +70,7 @@ describe("<NotificationDecoration />", () => {
|
||||
jest.spyOn(roomNotificationState, "isNotification", "get").mockReturnValue(true);
|
||||
jest.spyOn(roomNotificationState, "count", "get").mockReturnValue(0);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
@ -78,7 +79,7 @@ describe("<NotificationDecoration />", () => {
|
||||
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
jest.spyOn(roomNotificationState, "isActivityNotification", "get").mockReturnValue(true);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
@ -87,14 +88,21 @@ describe("<NotificationDecoration />", () => {
|
||||
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
jest.spyOn(roomNotificationState, "muted", "get").mockReturnValue(true);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={false} />,
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={undefined} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
it("should render the video decoration", () => {
|
||||
it("should render the video call decoration", () => {
|
||||
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} hasVideoCall={true} />,
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={CallType.Video} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
it("should render the audio call decoration", () => {
|
||||
jest.spyOn(roomNotificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(false);
|
||||
const { asFragment } = render(
|
||||
<NotificationDecoration notificationState={roomNotificationState} callType={CallType.Voice} />,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { render, screen, waitFor } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { mocked } from "jest-mock";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import { mkRoom, stubClient, withClientContextRenderOptions } from "../../../../../test-utils";
|
||||
import { RoomListItemView } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListItemView";
|
||||
@ -64,6 +65,7 @@ describe("<RoomListItemView />", () => {
|
||||
isBold: false,
|
||||
isVideoRoom: false,
|
||||
callConnectionState: null,
|
||||
callType: CallType.Video,
|
||||
hasParticipantInCall: false,
|
||||
name: room.name,
|
||||
showNotificationDecoration: false,
|
||||
|
||||
@ -103,6 +103,17 @@ exports[`<RoomListItemView /> should display notification decoration 1`] = `
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-accent-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="_unread-counter_9mg0k_8"
|
||||
>
|
||||
|
||||
@ -16,6 +16,28 @@ exports[`<NotificationDecoration /> should render the activity decoration 1`] =
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> should render the audio call decoration 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9"
|
||||
data-testid="notification-decoration"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<svg
|
||||
fill="var(--cpd-color-icon-accent-primary)"
|
||||
height="20px"
|
||||
viewBox="0 0 24 24"
|
||||
width="20px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m20.958 16.374.039 3.527q0 .427-.33.756-.33.33-.756.33a16 16 0 0 1-6.57-1.105 16.2 16.2 0 0 1-5.563-3.663 16.1 16.1 0 0 1-3.653-5.573 16.3 16.3 0 0 1-1.115-6.56q0-.427.33-.757T4.095 3l3.528.039a1.07 1.07 0 0 1 1.085.93l.543 3.954q.039.271-.039.504a1.1 1.1 0 0 1-.271.426l-1.64 1.64q.505 1.008 1.154 1.909c.433.6 1.444 1.696 1.444 1.696s1.095 1.01 1.696 1.444q.9.65 1.909 1.153l1.64-1.64q.193-.193.426-.27t.504-.04l3.954.543q.406.059.668.359t.262.727"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> should render the invitation decoration 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
@ -142,7 +164,7 @@ exports[`<NotificationDecoration /> should render the unset message decoration 1
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<NotificationDecoration /> should render the video decoration 1`] = `
|
||||
exports[`<NotificationDecoration /> should render the video call decoration 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_flex_4dswl_9"
|
||||
|
||||
@ -32,6 +32,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
|
||||
import { CallView as _CallView } from "../../../../../src/components/views/voip/CallView";
|
||||
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
|
||||
import { CallStore } from "../../../../../src/stores/CallStore";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
|
||||
const CallView = wrapInMatrixClientContext(_CallView);
|
||||
|
||||
@ -50,6 +51,7 @@ describe("CallView", () => {
|
||||
|
||||
stubClient();
|
||||
client = mocked(MatrixClientPeg.safeGet());
|
||||
DMRoomMap.makeShared(client);
|
||||
|
||||
room = new Room("!1:example.org", client, "@alice:example.org", {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
|
||||
@ -188,6 +188,7 @@ describe("IncomingCallToast", () => {
|
||||
room_id: room.roomId,
|
||||
skipLobby: true,
|
||||
view_call: true,
|
||||
voiceOnly: false,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
@ -215,6 +216,7 @@ describe("IncomingCallToast", () => {
|
||||
room_id: room.roomId,
|
||||
skipLobby: false,
|
||||
view_call: true,
|
||||
voiceOnly: false,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
@ -239,6 +241,7 @@ describe("IncomingCallToast", () => {
|
||||
room_id: room.roomId,
|
||||
skipLobby: true,
|
||||
view_call: true,
|
||||
voiceOnly: false,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user