diff --git a/res/css/views/dialogs/_RoomSettingsDialog.pcss b/res/css/views/dialogs/_RoomSettingsDialog.pcss index a242a99596b..8631ec5d7d5 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.pcss +++ b/res/css/views/dialogs/_RoomSettingsDialog.pcss @@ -21,6 +21,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/settings.svg'); } +.mx_RoomSettingsDialog_voiceIcon::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); +} + .mx_RoomSettingsDialog_securityIcon::before { mask-image: url('$(res)/img/element-icons/security.svg'); } diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index a6f7d0cdb4d..9739cc88702 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -119,6 +119,7 @@ export interface IConfigOptions { element_call: { url: string; use_exclusively: boolean; + brand: string; }; logout_redirect_url?: string; diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 14fd0694d02..2e1e4a44724 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -33,6 +33,7 @@ export const DEFAULTS: IConfigOptions = { element_call: { url: "https://call.element.io", use_exclusively: false, + brand: "Element Call", }, // @ts-ignore - we deliberately use the camelCase version here so we trigger diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index ce8d24cd3fd..d20aca98d9c 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -32,8 +32,10 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import BaseDialog from "./BaseDialog"; import { Action } from '../../../dispatcher/actions'; +import { VoipRoomSettingsTab } from "../settings/tabs/room/VoipRoomSettingsTab"; export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; +export const ROOM_VOIP_TAB = "ROOM_VOIP_TAB"; export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB"; export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB"; export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB"; @@ -96,6 +98,14 @@ export default class RoomSettingsDialog extends React.Component , "RoomSettingsGeneral", )); + if (SettingsStore.getValue("feature_group_calls")) { + tabs.push(new Tab( + ROOM_VOIP_TAB, + _td("Voice & Video"), + "mx_RoomSettingsDialog_voiceIcon", + , + )); + } tabs.push(new Tab( ROOM_SECURITY_TAB, _td("Security & Privacy"), diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 0f52879cc8d..8fd3a17096d 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -21,7 +21,7 @@ import AccessibleButton from "./AccessibleButton"; import Tooltip, { Alignment } from './Tooltip'; interface IProps extends React.ComponentProps { - title: string; + title?: string; tooltip?: React.ReactNode; label?: string; tooltipClassName?: string; @@ -78,7 +78,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { children } { this.props.label } diff --git a/src/components/views/elements/LabelledToggleSwitch.tsx b/src/components/views/elements/LabelledToggleSwitch.tsx index 90b419c735a..eb251d1bd62 100644 --- a/src/components/views/elements/LabelledToggleSwitch.tsx +++ b/src/components/views/elements/LabelledToggleSwitch.tsx @@ -27,6 +27,8 @@ interface IProps { label: string; // The translated caption for the switch caption?: string; + // Tooltip to display + tooltip?: string; // Whether or not to disable the toggle switch disabled?: boolean; // True to put the toggle in front of the label @@ -53,7 +55,8 @@ export default class LabelledToggleSwitch extends React.PureComponent { checked={this.props.value} disabled={this.props.disabled} onChange={this.props.onChange} - aria-label={this.props.label} + title={this.props.label} + tooltip={this.props.tooltip} />; if (this.props.toggleInFront) { @@ -66,7 +69,7 @@ export default class LabelledToggleSwitch extends React.PureComponent { "mx_SettingsFlag_toggleInFront": this.props.toggleInFront, }); return ( -
+
{ firstPart } { secondPart }
diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index e369b29c183..76348342a9b 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -114,7 +114,7 @@ export default class SettingsFlag extends React.Component { checked={this.state.value} onChange={this.onChange} disabled={this.props.disabled || !canChange} - aria-label={label} + title={label} />
); diff --git a/src/components/views/elements/ToggleSwitch.tsx b/src/components/views/elements/ToggleSwitch.tsx index f56633786a9..6a95b5d9a09 100644 --- a/src/components/views/elements/ToggleSwitch.tsx +++ b/src/components/views/elements/ToggleSwitch.tsx @@ -18,21 +18,27 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import AccessibleButton from "./AccessibleButton"; +import AccessibleTooltipButton from "./AccessibleTooltipButton"; interface IProps { // Whether or not this toggle is in the 'on' position. checked: boolean; + // Title to use + title?: string; + // Whether or not the user can interact with the switch disabled?: boolean; + // Tooltip to show + tooltip?: string; + // Called when the checked state changes. First argument will be the new state. onChange(checked: boolean): void; } // Controlled Toggle Switch element, written with Accessibility in mind -export default ({ checked, disabled = false, onChange, ...props }: IProps) => { +export default ({ checked, disabled = false, title, tooltip, onChange, ...props }: IProps) => { const _onClick = () => { if (disabled) return; onChange(!checked); @@ -45,14 +51,16 @@ export default ({ checked, disabled = false, onChange, ...props }: IProps) => { }); return ( -
- + ); }; diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index f0c55b6988c..6f0eac864f5 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -195,10 +195,11 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi let menu: JSX.Element | null = null; if (menuOpen) { const buttonRect = buttonRef.current!.getBoundingClientRect(); + const brand = SdkConfig.get("element_call").brand; menu = - + ; } diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 719afb1cb41..4330798dcab 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -153,7 +153,7 @@ const DeviceDetails: React.FC = ({ checked={isPushNotificationsEnabled(pusher, localNotificationSettings)} disabled={isCheckboxDisabled(pusher, localNotificationSettings)} onChange={checked => setPushNotifications?.(device.device_id, checked)} - aria-label={_t("Toggle push notifications on this session.")} + title={_t("Toggle push notifications on this session.")} data-testid='device-detail-push-notification-checkbox' />

diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index bab69042435..1c70c3ea39f 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -31,6 +31,8 @@ import PowerSelector from "../../../elements/PowerSelector"; import SettingsFieldset from '../../SettingsFieldset'; import SettingsStore from "../../../../../settings/SettingsStore"; import { VoiceBroadcastInfoEventType } from '../../../../../voice-broadcast'; +import { ElementCall } from "../../../../../models/Call"; +import SdkConfig from "../../../../../SdkConfig"; interface IEventShowOpts { isState?: boolean; @@ -60,6 +62,10 @@ const plEventsToShow: Record = { [EventType.Reaction]: { isState: false, hideForSpace: true }, [EventType.RoomRedaction]: { isState: false, hideForSpace: true }, + // MSC3401: Native Group VoIP signaling + [ElementCall.CALL_EVENT_TYPE.name]: { isState: true, hideForSpace: true }, + [ElementCall.MEMBER_EVENT_TYPE.name]: { isState: true, hideForSpace: true }, + // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": { isState: true, hideForSpace: true }, [VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true }, @@ -252,6 +258,11 @@ export default class RolesRoomSettingsTab extends React.Component { if (SettingsStore.getValue("feature_pinning")) { plEventsToLabels[EventType.RoomPinnedEvents] = _td("Manage pinned events"); } + // MSC3401: Native Group VoIP signaling + if (SettingsStore.getValue("feature_group_calls")) { + plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("Start %(brand)s calls"); + plEventsToLabels[ElementCall.MEMBER_EVENT_TYPE.name] = _td("Join %(brand)s calls"); + } const powerLevelDescriptors: Record = { "users_default": { @@ -435,7 +446,8 @@ export default class RolesRoomSettingsTab extends React.Component { let label = plEventsToLabels[eventType]; if (label) { - label = _t(label); + const brand = SdkConfig.get("element_call").brand; + label = _t(label, { brand }); } else { label = _t("Send %(eventType)s events", { eventType }); } diff --git a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx new file mode 100644 index 00000000000..29863b64aab --- /dev/null +++ b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx @@ -0,0 +1,99 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useCallback, useMemo, useState } from 'react'; +import { JoinRule } from "matrix-js-sdk/src/@types/partials"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { _t } from "../../../../../languageHandler"; +import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; +import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; +import SettingsSubsection from "../../shared/SettingsSubsection"; +import SettingsTab from "../SettingsTab"; +import { ElementCall } from "../../../../../models/Call"; +import { useRoomState } from "../../../../../hooks/useRoomState"; +import SdkConfig from "../../../../../SdkConfig"; + +interface ElementCallSwitchProps { + roomId: string; +} + +const ElementCallSwitch: React.FC = ({ roomId }) => { + const room = useMemo(() => MatrixClientPeg.get().getRoom(roomId), [roomId]); + const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); + const [content, events, maySend] = useRoomState(room, useCallback((state) => { + const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + return [ + content ?? {}, + content?.["events"] ?? {}, + state?.maySendStateEvent(EventType.RoomPowerLevels, MatrixClientPeg.get().getUserId()), + ]; + }, [])); + + const [elementCallEnabled, setElementCallEnabled] = useState(() => { + return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0; + }); + + const onChange = useCallback((enabled: boolean): void => { + setElementCallEnabled(enabled); + + if (enabled) { + const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0; + const moderatorLevel = content.kick ?? 50; + + events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; + events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; + } else { + const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; + + events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; + events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; + } + + MatrixClientPeg.get().sendStateEvent(roomId, EventType.RoomPowerLevels, { + "events": events, + ...content, + }); + }, [roomId, content, events, isPublic]); + + const brand = SdkConfig.get("element_call").brand; + + return ; +}; + +interface Props { + roomId: string; +} + +export const VoipRoomSettingsTab: React.FC = ({ roomId }) => { + return + + + + ; +}; diff --git a/src/createRoom.ts b/src/createRoom.ts index 19d92164a02..88e3f8ef9f6 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -46,6 +46,7 @@ import { findDMForUser } from "./utils/dm/findDMForUser"; import { privateShouldBeEncrypted } from "./utils/rooms"; import { waitForMember } from "./utils/membership"; import { PreferredRoomVersions } from "./utils/PreferredRoomVersions"; +import SettingsStore from "./settings/SettingsStore"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -168,6 +169,16 @@ export default async function createRoom(opts: IOpts): Promise { }, }; } + } else if (SettingsStore.getValue("feature_group_calls")) { + createOpts.power_level_content_override = { + events: { + ...DEFAULT_EVENT_POWER_LEVELS, + // Element Call should be disabled by default + [ElementCall.MEMBER_EVENT_TYPE.name]: 100, + // Make sure only admins can enable it + [ElementCall.CALL_EVENT_TYPE.name]: 100, + }, + }; } // By default, view the room after creating it diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7107275cee5..8da63749df6 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1650,6 +1650,8 @@ "Modify widgets": "Modify widgets", "Voice broadcasts": "Voice broadcasts", "Manage pinned events": "Manage pinned events", + "Start %(brand)s calls": "Start %(brand)s calls", + "Join %(brand)s calls": "Join %(brand)s calls", "Default role": "Default role", "Send messages": "Send messages", "Invite users": "Invite users", @@ -1689,6 +1691,10 @@ "Security & Privacy": "Security & Privacy", "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.", "Encrypted": "Encrypted", + "Enable %(brand)s as an additional calling option in this room": "Enable %(brand)s as an additional calling option in this room", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.", + "You do not have sufficient permissions to change this.": "You do not have sufficient permissions to change this.", + "Call type": "Call type", "Unable to revoke sharing for email address": "Unable to revoke sharing for email address", "Unable to share email address": "Unable to share email address", "Your email address hasn't been verified yet": "Your email address hasn't been verified yet", @@ -1892,7 +1898,7 @@ "Recently visited rooms": "Recently visited rooms", "No recently visited rooms": "No recently visited rooms", "Video call (Jitsi)": "Video call (Jitsi)", - "Video call (Element Call)": "Video call (Element Call)", + "Video call (%(brand)s)": "Video call (%(brand)s)", "Ongoing call": "Ongoing call", "You do not have permission to start video calls": "You do not have permission to start video calls", "There's no one here to call": "There's no one here to call", diff --git a/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap b/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap index 76f50889c05..f30cdac00a6 100644 --- a/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap +++ b/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap @@ -42,36 +42,53 @@ exports[` with live location disabled goes to labs flag scr Enable live location sharing <_default - aria-label="Enable live location sharing" checked={false} onChange={[Function]} + title="Enable live location sharing" > - -

-
- + aria-checked={false} + aria-disabled={false} + aria-label="Enable live location sharing" + className="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyUp={[Function]} + onMouseLeave={[Function]} + onMouseOver={[Function]} + role="switch" + tabIndex={0} + > +
+
+ +
diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index e75502eff44..c278bfa1b43 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -505,7 +505,9 @@ describe("RoomHeader (React Testing Library)", () => { + "and there's an ongoing call", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); - SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + SdkConfig.put( + { element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" } }, + ); await ElementCall.create(room); renderHeader(); @@ -519,7 +521,9 @@ describe("RoomHeader (React Testing Library)", () => { + "use Element Call exclusively", async () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); - SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + SdkConfig.put( + { element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" } }, + ); renderHeader(); expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); @@ -541,7 +545,9 @@ describe("RoomHeader (React Testing Library)", () => { + "and the user lacks permission", () => { mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); - SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + SdkConfig.put( + { element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" } }, + ); mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); renderHeader(); diff --git a/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap b/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap index f9f4bcd58a3..c00d74b56aa 100644 --- a/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap +++ b/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap @@ -18,37 +18,54 @@ exports[` main notification switches email switches renders ema Enable email notifications for tester@test.com <_default - aria-label="Enable email notifications for tester@test.com" checked={false} disabled={false} onChange={[Function]} + title="Enable email notifications for tester@test.com" > - -
-
- + aria-checked={false} + aria-disabled={false} + aria-label="Enable email notifications for tester@test.com" + className="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyUp={[Function]} + onMouseLeave={[Function]} + onMouseOver={[Function]} + role="switch" + tabIndex={0} + > +
+
+ +
@@ -84,37 +101,54 @@ exports[` main notification switches renders only enable notifi <_default - aria-label="Enable notifications for this account" checked={false} disabled={false} onChange={[Function]} + title="Enable notifications for this account" > - -
-
- + aria-checked={false} + aria-disabled={false} + aria-label="Enable notifications for this account" + className="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onKeyDown={[Function]} + onKeyUp={[Function]} + onMouseLeave={[Function]} + onMouseOver={[Function]} + role="switch" + tabIndex={0} + > +
+
+ +
diff --git a/test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx b/test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx index 9c041de4c0d..dc2427e1436 100644 --- a/test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/room/RolesRoomSettingsTab-test.tsx @@ -16,30 +16,35 @@ limitations under the License. import React from "react"; import { fireEvent, render, RenderResult } from "@testing-library/react"; -import { EventType, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { EventType } from "matrix-js-sdk/src/@types/event"; import RolesRoomSettingsTab from "../../../../../../src/components/views/settings/tabs/room/RolesRoomSettingsTab"; import { mkStubRoom, stubClient } from "../../../../../test-utils"; import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; import { VoiceBroadcastInfoEventType } from "../../../../../../src/voice-broadcast"; +import SettingsStore from "../../../../../../src/settings/SettingsStore"; +import { ElementCall } from "../../../../../../src/models/Call"; describe("RolesRoomSettingsTab", () => { const roomId = "!room:example.com"; - let rolesRoomSettingsTab: RenderResult; let cli: MatrixClient; + const renderTab = (): RenderResult => { + return render(); + }; + const getVoiceBroadcastsSelect = () => { - return rolesRoomSettingsTab.container.querySelector("select[label='Voice broadcasts']"); + return renderTab().container.querySelector("select[label='Voice broadcasts']"); }; const getVoiceBroadcastsSelectedOption = () => { - return rolesRoomSettingsTab.container.querySelector("select[label='Voice broadcasts'] option:checked"); + return renderTab().container.querySelector("select[label='Voice broadcasts'] option:checked"); }; beforeEach(() => { stubClient(); cli = MatrixClientPeg.get(); - rolesRoomSettingsTab = render(); mkStubRoom(roomId, "test room", cli); }); @@ -66,4 +71,96 @@ describe("RolesRoomSettingsTab", () => { ); }); }); + + describe("Element Call", () => { + const setGroupCallsEnabled = (val: boolean): void => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "feature_group_calls") return val; + }); + }; + + const getStartCallSelect = (tab: RenderResult) => { + return tab.container.querySelector("select[label='Start Element Call calls']"); + }; + + const getStartCallSelectedOption = (tab: RenderResult) => { + return tab.container.querySelector("select[label='Start Element Call calls'] option:checked"); + }; + + const getJoinCallSelect = (tab: RenderResult) => { + return tab.container.querySelector("select[label='Join Element Call calls']"); + }; + + const getJoinCallSelectedOption = (tab: RenderResult) => { + return tab.container.querySelector("select[label='Join Element Call calls'] option:checked"); + }; + + describe("Element Call enabled", () => { + beforeEach(() => { + setGroupCallsEnabled(true); + }); + + describe("Join Element calls", () => { + it("defaults to moderator for joining calls", () => { + expect(getJoinCallSelectedOption(renderTab())?.textContent).toBe("Moderator"); + }); + + it("can change joining calls power level", () => { + const tab = renderTab(); + + fireEvent.change(getJoinCallSelect(tab), { + target: { value: 0 }, + }); + + expect(getJoinCallSelectedOption(tab)?.textContent).toBe("Default"); + expect(cli.sendStateEvent).toHaveBeenCalledWith( + roomId, + EventType.RoomPowerLevels, + { + events: { + [ElementCall.MEMBER_EVENT_TYPE.name]: 0, + }, + }, + ); + }); + }); + + describe("Start Element calls", () => { + it("defaults to moderator for starting calls", () => { + expect(getStartCallSelectedOption(renderTab())?.textContent).toBe("Moderator"); + }); + + it("can change starting calls power level", () => { + const tab = renderTab(); + + fireEvent.change(getStartCallSelect(tab), { + target: { value: 0 }, + }); + + expect(getStartCallSelectedOption(tab)?.textContent).toBe("Default"); + expect(cli.sendStateEvent).toHaveBeenCalledWith( + roomId, + EventType.RoomPowerLevels, + { + events: { + [ElementCall.CALL_EVENT_TYPE.name]: 0, + }, + }, + ); + }); + }); + }); + + it("hides when group calls disabled", () => { + setGroupCallsEnabled(false); + + const tab = renderTab(); + + expect(getStartCallSelect(tab)).toBeFalsy(); + expect(getStartCallSelectedOption(tab)).toBeFalsy(); + + expect(getJoinCallSelect(tab)).toBeFalsy(); + expect(getJoinCallSelectedOption(tab)).toBeFalsy(); + }); + }); }); diff --git a/test/components/views/settings/tabs/room/VoipRoomSettingsTab-test.tsx b/test/components/views/settings/tabs/room/VoipRoomSettingsTab-test.tsx new file mode 100644 index 00000000000..0295170ab37 --- /dev/null +++ b/test/components/views/settings/tabs/room/VoipRoomSettingsTab-test.tsx @@ -0,0 +1,141 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { fireEvent, render, RenderResult, waitFor } from "@testing-library/react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { JoinRule } from "matrix-js-sdk/src/@types/partials"; + +import { mkStubRoom, stubClient } from "../../../../../test-utils"; +import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; +import { VoipRoomSettingsTab } from "../../../../../../src/components/views/settings/tabs/room/VoipRoomSettingsTab"; +import { ElementCall } from "../../../../../../src/models/Call"; + +describe("RolesRoomSettingsTab", () => { + const roomId = "!room:example.com"; + let cli: MatrixClient; + let room: Room; + + const renderTab = (): RenderResult => { + return render(); + }; + + beforeEach(() => { + stubClient(); + cli = MatrixClientPeg.get(); + room = mkStubRoom(roomId, "test room", cli); + + jest.spyOn(cli, "sendStateEvent"); + jest.spyOn(cli, "getRoom").mockReturnValue(room); + }); + + describe("Element Call", () => { + const mockPowerLevels = (events): void => { + jest.spyOn(room.currentState, "getStateEvents").mockReturnValue({ + getContent: () => ({ + events, + }), + } as unknown as MatrixEvent); + }; + + const getElementCallSwitch = (tab: RenderResult): HTMLElement => { + return tab.container.querySelector("[data-testid='element-call-switch']"); + }; + + describe("correct state", () => { + it("shows enabled when call member power level is 0", () => { + mockPowerLevels({ [ElementCall.MEMBER_EVENT_TYPE.name]: 0 }); + + const tab = renderTab(); + + expect(getElementCallSwitch(tab).querySelector("[aria-checked='true']")).toBeTruthy(); + }); + + it.each([1, 50, 100])("shows disabled when call member power level is 0", (level: number) => { + mockPowerLevels({ [ElementCall.MEMBER_EVENT_TYPE.name]: level }); + + const tab = renderTab(); + + expect(getElementCallSwitch(tab).querySelector("[aria-checked='false']")).toBeTruthy(); + }); + }); + + describe("enabling/disabling", () => { + describe("enabling Element calls", () => { + beforeEach(() => { + mockPowerLevels({ [ElementCall.MEMBER_EVENT_TYPE.name]: 100 }); + }); + + it("enables Element calls in public room", async () => { + jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Public); + + const tab = renderTab(); + + fireEvent.click(getElementCallSwitch(tab).querySelector(".mx_ToggleSwitch")); + await waitFor(() => expect(cli.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.RoomPowerLevels, + expect.objectContaining({ + events: { + [ElementCall.CALL_EVENT_TYPE.name]: 50, + [ElementCall.MEMBER_EVENT_TYPE.name]: 0, + }, + }), + )); + }); + + it("enables Element calls in private room", async () => { + jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite); + + const tab = renderTab(); + + fireEvent.click(getElementCallSwitch(tab).querySelector(".mx_ToggleSwitch")); + await waitFor(() => expect(cli.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.RoomPowerLevels, + expect.objectContaining({ + events: { + [ElementCall.CALL_EVENT_TYPE.name]: 0, + [ElementCall.MEMBER_EVENT_TYPE.name]: 0, + }, + }), + )); + }); + }); + + it("disables Element calls", async () => { + mockPowerLevels({ [ElementCall.MEMBER_EVENT_TYPE.name]: 0 }); + + const tab = renderTab(); + + fireEvent.click(getElementCallSwitch(tab).querySelector(".mx_ToggleSwitch")); + await waitFor(() => expect(cli.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.RoomPowerLevels, + expect.objectContaining({ + events: { + [ElementCall.CALL_EVENT_TYPE.name]: 100, + [ElementCall.MEMBER_EVENT_TYPE.name]: 100, + }, + }), + )); + }); + }); + }); +}); diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts index 7dbd4a2a41c..842449c6875 100644 --- a/test/createRoom-test.ts +++ b/test/createRoom-test.ts @@ -25,6 +25,7 @@ import WidgetStore from "../src/stores/WidgetStore"; import WidgetUtils from "../src/utils/WidgetUtils"; import { JitsiCall, ElementCall } from "../src/models/Call"; import createRoom, { canEncryptToAllUsers } from '../src/createRoom'; +import SettingsStore from "../src/settings/SettingsStore"; describe("createRoom", () => { mockPlatformPeg(); @@ -85,7 +86,7 @@ describe("createRoom", () => { [ElementCall.MEMBER_EVENT_TYPE.name]: callMemberPower, }, }, - }]] = client.createRoom.mock.calls as any; // no good type + }]] = client.createRoom.mock.calls; // We should have had enough power to be able to set up the call expect(userPower).toBeGreaterThanOrEqual(callPower); @@ -109,6 +110,26 @@ describe("createRoom", () => { expect(createJitsiCallSpy).not.toHaveBeenCalled(); expect(createElementCallSpy).not.toHaveBeenCalled(); }); + + it("correctly sets up MSC3401 power levels", async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "feature_group_calls") return true; + }); + + await createRoom({}); + + const [[{ + power_level_content_override: { + events: { + [ElementCall.CALL_EVENT_TYPE.name]: callPower, + [ElementCall.MEMBER_EVENT_TYPE.name]: callMemberPower, + }, + }, + }]] = client.createRoom.mock.calls; + + expect(callPower).toBe(100); + expect(callMemberPower).toBe(100); + }); }); describe("canEncryptToAllUsers", () => { diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts index 0f1d030e791..24355d49c85 100644 --- a/test/utils/device/clientInformation-test.ts +++ b/test/utils/device/clientInformation-test.ts @@ -36,7 +36,7 @@ describe('recordClientInformation()', () => { const sdkConfig: IConfigOptions = { brand: 'Test Brand', - element_call: { url: '', use_exclusively: false }, + element_call: { url: '', use_exclusively: false, brand: "Element Call" }, }; const platform = {