Add Element Call room settings (#9347)

Co-authored-by: Robin <robin@robin.town>
This commit is contained in:
Šimon Brandner 2022-10-07 20:10:17 +02:00 committed by GitHub
parent 4ff9681408
commit 26a74a193f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 539 additions and 67 deletions

View file

@ -21,6 +21,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/settings.svg'); 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 { .mx_RoomSettingsDialog_securityIcon::before {
mask-image: url('$(res)/img/element-icons/security.svg'); mask-image: url('$(res)/img/element-icons/security.svg');
} }

View file

@ -119,6 +119,7 @@ export interface IConfigOptions {
element_call: { element_call: {
url: string; url: string;
use_exclusively: boolean; use_exclusively: boolean;
brand: string;
}; };
logout_redirect_url?: string; logout_redirect_url?: string;

View file

@ -33,6 +33,7 @@ export const DEFAULTS: IConfigOptions = {
element_call: { element_call: {
url: "https://call.element.io", url: "https://call.element.io",
use_exclusively: false, use_exclusively: false,
brand: "Element Call",
}, },
// @ts-ignore - we deliberately use the camelCase version here so we trigger // @ts-ignore - we deliberately use the camelCase version here so we trigger

View file

@ -32,8 +32,10 @@ import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import { Action } from '../../../dispatcher/actions'; import { Action } from '../../../dispatcher/actions';
import { VoipRoomSettingsTab } from "../settings/tabs/room/VoipRoomSettingsTab";
export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; 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_SECURITY_TAB = "ROOM_SECURITY_TAB";
export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB"; export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB";
export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB"; export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
@ -96,6 +98,14 @@ export default class RoomSettingsDialog extends React.Component<IProps, IState>
<GeneralRoomSettingsTab roomId={this.props.roomId} />, <GeneralRoomSettingsTab roomId={this.props.roomId} />,
"RoomSettingsGeneral", "RoomSettingsGeneral",
)); ));
if (SettingsStore.getValue("feature_group_calls")) {
tabs.push(new Tab(
ROOM_VOIP_TAB,
_td("Voice & Video"),
"mx_RoomSettingsDialog_voiceIcon",
<VoipRoomSettingsTab roomId={this.props.roomId} />,
));
}
tabs.push(new Tab( tabs.push(new Tab(
ROOM_SECURITY_TAB, ROOM_SECURITY_TAB,
_td("Security & Privacy"), _td("Security & Privacy"),

View file

@ -21,7 +21,7 @@ import AccessibleButton from "./AccessibleButton";
import Tooltip, { Alignment } from './Tooltip'; import Tooltip, { Alignment } from './Tooltip';
interface IProps extends React.ComponentProps<typeof AccessibleButton> { interface IProps extends React.ComponentProps<typeof AccessibleButton> {
title: string; title?: string;
tooltip?: React.ReactNode; tooltip?: React.ReactNode;
label?: string; label?: string;
tooltipClassName?: string; tooltipClassName?: string;
@ -78,7 +78,7 @@ export default class AccessibleTooltipButton extends React.PureComponent<IProps,
const { title, tooltip, children, tooltipClassName, forceHide, alignment, onHideTooltip, const { title, tooltip, children, tooltipClassName, forceHide, alignment, onHideTooltip,
...props } = this.props; ...props } = this.props;
const tip = this.state.hover && <Tooltip const tip = this.state.hover && (title || tooltip) && <Tooltip
tooltipClassName={tooltipClassName} tooltipClassName={tooltipClassName}
label={tooltip || title} label={tooltip || title}
alignment={alignment} alignment={alignment}
@ -86,11 +86,11 @@ export default class AccessibleTooltipButton extends React.PureComponent<IProps,
return ( return (
<AccessibleButton <AccessibleButton
{...props} {...props}
onMouseOver={this.showTooltip} onMouseOver={this.showTooltip || props.onMouseOver}
onMouseLeave={this.hideTooltip} onMouseLeave={this.hideTooltip || props.onMouseLeave}
onFocus={this.onFocus} onFocus={this.onFocus || props.onFocus}
onBlur={this.hideTooltip} onBlur={this.hideTooltip || props.onBlur}
aria-label={title} aria-label={title || props["aria-label"]}
> >
{ children } { children }
{ this.props.label } { this.props.label }

View file

@ -27,6 +27,8 @@ interface IProps {
label: string; label: string;
// The translated caption for the switch // The translated caption for the switch
caption?: string; caption?: string;
// Tooltip to display
tooltip?: string;
// Whether or not to disable the toggle switch // Whether or not to disable the toggle switch
disabled?: boolean; disabled?: boolean;
// True to put the toggle in front of the label // True to put the toggle in front of the label
@ -53,7 +55,8 @@ export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
checked={this.props.value} checked={this.props.value}
disabled={this.props.disabled} disabled={this.props.disabled}
onChange={this.props.onChange} onChange={this.props.onChange}
aria-label={this.props.label} title={this.props.label}
tooltip={this.props.tooltip}
/>; />;
if (this.props.toggleInFront) { if (this.props.toggleInFront) {
@ -66,7 +69,7 @@ export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
"mx_SettingsFlag_toggleInFront": this.props.toggleInFront, "mx_SettingsFlag_toggleInFront": this.props.toggleInFront,
}); });
return ( return (
<div className={classes}> <div data-testid={this.props["data-testid"]} className={classes}>
{ firstPart } { firstPart }
{ secondPart } { secondPart }
</div> </div>

View file

@ -114,7 +114,7 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
checked={this.state.value} checked={this.state.value}
onChange={this.onChange} onChange={this.onChange}
disabled={this.props.disabled || !canChange} disabled={this.props.disabled || !canChange}
aria-label={label} title={label}
/> />
</div> </div>
); );

View file

@ -18,21 +18,27 @@ limitations under the License.
import React from "react"; import React from "react";
import classNames from "classnames"; import classNames from "classnames";
import AccessibleButton from "./AccessibleButton"; import AccessibleTooltipButton from "./AccessibleTooltipButton";
interface IProps { interface IProps {
// Whether or not this toggle is in the 'on' position. // Whether or not this toggle is in the 'on' position.
checked: boolean; checked: boolean;
// Title to use
title?: string;
// Whether or not the user can interact with the switch // Whether or not the user can interact with the switch
disabled?: boolean; disabled?: boolean;
// Tooltip to show
tooltip?: string;
// Called when the checked state changes. First argument will be the new state. // Called when the checked state changes. First argument will be the new state.
onChange(checked: boolean): void; onChange(checked: boolean): void;
} }
// Controlled Toggle Switch element, written with Accessibility in mind // 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 = () => { const _onClick = () => {
if (disabled) return; if (disabled) return;
onChange(!checked); onChange(!checked);
@ -45,14 +51,16 @@ export default ({ checked, disabled = false, onChange, ...props }: IProps) => {
}); });
return ( return (
<AccessibleButton {...props} <AccessibleTooltipButton {...props}
className={classes} className={classes}
onClick={_onClick} onClick={_onClick}
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
aria-disabled={disabled} aria-disabled={disabled}
title={title}
tooltip={tooltip}
> >
<div className="mx_ToggleSwitch_ball" /> <div className="mx_ToggleSwitch_ball" />
</AccessibleButton> </AccessibleTooltipButton>
); );
}; };

View file

@ -195,10 +195,11 @@ const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavi
let menu: JSX.Element | null = null; let menu: JSX.Element | null = null;
if (menuOpen) { if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect(); const buttonRect = buttonRef.current!.getBoundingClientRect();
const brand = SdkConfig.get("element_call").brand;
menu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}> menu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList> <IconizedContextMenuOptionList>
<IconizedContextMenuOption label={_t("Video call (Jitsi)")} onClick={onJitsiClick} /> <IconizedContextMenuOption label={_t("Video call (Jitsi)")} onClick={onJitsiClick} />
<IconizedContextMenuOption label={_t("Video call (Element Call)")} onClick={onElementClick} /> <IconizedContextMenuOption label={_t("Video call (%(brand)s)", { brand })} onClick={onElementClick} />
</IconizedContextMenuOptionList> </IconizedContextMenuOptionList>
</IconizedContextMenu>; </IconizedContextMenu>;
} }

View file

@ -153,7 +153,7 @@ const DeviceDetails: React.FC<Props> = ({
checked={isPushNotificationsEnabled(pusher, localNotificationSettings)} checked={isPushNotificationsEnabled(pusher, localNotificationSettings)}
disabled={isCheckboxDisabled(pusher, localNotificationSettings)} disabled={isCheckboxDisabled(pusher, localNotificationSettings)}
onChange={checked => setPushNotifications?.(device.device_id, checked)} 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' data-testid='device-detail-push-notification-checkbox'
/> />
<p className='mx_DeviceDetails_sectionHeading'> <p className='mx_DeviceDetails_sectionHeading'>

View file

@ -31,6 +31,8 @@ import PowerSelector from "../../../elements/PowerSelector";
import SettingsFieldset from '../../SettingsFieldset'; import SettingsFieldset from '../../SettingsFieldset';
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import { VoiceBroadcastInfoEventType } from '../../../../../voice-broadcast'; import { VoiceBroadcastInfoEventType } from '../../../../../voice-broadcast';
import { ElementCall } from "../../../../../models/Call";
import SdkConfig from "../../../../../SdkConfig";
interface IEventShowOpts { interface IEventShowOpts {
isState?: boolean; isState?: boolean;
@ -60,6 +62,10 @@ const plEventsToShow: Record<string, IEventShowOpts> = {
[EventType.Reaction]: { isState: false, hideForSpace: true }, [EventType.Reaction]: { isState: false, hideForSpace: true },
[EventType.RoomRedaction]: { 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) // 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 }, "im.vector.modular.widgets": { isState: true, hideForSpace: true },
[VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true }, [VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true },
@ -252,6 +258,11 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
if (SettingsStore.getValue("feature_pinning")) { if (SettingsStore.getValue("feature_pinning")) {
plEventsToLabels[EventType.RoomPinnedEvents] = _td("Manage pinned events"); 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<string, IPowerLevelDescriptor> = { const powerLevelDescriptors: Record<string, IPowerLevelDescriptor> = {
"users_default": { "users_default": {
@ -435,7 +446,8 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
let label = plEventsToLabels[eventType]; let label = plEventsToLabels[eventType];
if (label) { if (label) {
label = _t(label); const brand = SdkConfig.get("element_call").brand;
label = _t(label, { brand });
} else { } else {
label = _t("Send %(eventType)s events", { eventType }); label = _t("Send %(eventType)s events", { eventType });
} }

View file

@ -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<ElementCallSwitchProps> = ({ 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<boolean>(() => {
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 <LabelledToggleSwitch
data-testid="element-call-switch"
label={_t("Enable %(brand)s as an additional calling option in this room", { brand })}
caption={_t(
"%(brand)s is end-to-end encrypted, " +
"but is currently limited to smaller numbers of users.",
{ brand },
)}
value={elementCallEnabled}
onChange={onChange}
disabled={!maySend}
tooltip={_t("You do not have sufficient permissions to change this.")}
/>;
};
interface Props {
roomId: string;
}
export const VoipRoomSettingsTab: React.FC<Props> = ({ roomId }) => {
return <SettingsTab heading={_t("Voice & Video")}>
<SettingsSubsection heading={_t("Call type")}>
<ElementCallSwitch roomId={roomId} />
</SettingsSubsection>
</SettingsTab>;
};

View file

@ -46,6 +46,7 @@ import { findDMForUser } from "./utils/dm/findDMForUser";
import { privateShouldBeEncrypted } from "./utils/rooms"; import { privateShouldBeEncrypted } from "./utils/rooms";
import { waitForMember } from "./utils/membership"; import { waitForMember } from "./utils/membership";
import { PreferredRoomVersions } from "./utils/PreferredRoomVersions"; import { PreferredRoomVersions } from "./utils/PreferredRoomVersions";
import SettingsStore from "./settings/SettingsStore";
// we define a number of interfaces which take their names from the js-sdk // we define a number of interfaces which take their names from the js-sdk
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -168,6 +169,16 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
}, },
}; };
} }
} 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 // By default, view the room after creating it

View file

@ -1650,6 +1650,8 @@
"Modify widgets": "Modify widgets", "Modify widgets": "Modify widgets",
"Voice broadcasts": "Voice broadcasts", "Voice broadcasts": "Voice broadcasts",
"Manage pinned events": "Manage pinned events", "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", "Default role": "Default role",
"Send messages": "Send messages", "Send messages": "Send messages",
"Invite users": "Invite users", "Invite users": "Invite users",
@ -1689,6 +1691,10 @@
"Security & Privacy": "Security & Privacy", "Security & Privacy": "Security & Privacy",
"Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.", "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.",
"Encrypted": "Encrypted", "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 revoke sharing for email address": "Unable to revoke sharing for email address",
"Unable to share email address": "Unable to share 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", "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", "Recently visited rooms": "Recently visited rooms",
"No recently visited rooms": "No recently visited rooms", "No recently visited rooms": "No recently visited rooms",
"Video call (Jitsi)": "Video call (Jitsi)", "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", "Ongoing call": "Ongoing call",
"You do not have permission to start video calls": "You do not have permission to start video calls", "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", "There's no one here to call": "There's no one here to call",

View file

@ -42,36 +42,53 @@ exports[`<LocationShareMenu /> with live location disabled goes to labs flag scr
Enable live location sharing Enable live location sharing
</span> </span>
<_default <_default
aria-label="Enable live location sharing"
checked={false} checked={false}
onChange={[Function]} onChange={[Function]}
title="Enable live location sharing"
> >
<AccessibleButton <AccessibleTooltipButton
aria-checked={false} aria-checked={false}
aria-disabled={false} aria-disabled={false}
aria-label="Enable live location sharing"
className="mx_ToggleSwitch mx_ToggleSwitch_enabled" className="mx_ToggleSwitch mx_ToggleSwitch_enabled"
element="div"
onClick={[Function]} onClick={[Function]}
role="switch" role="switch"
tabIndex={0} title="Enable live location sharing"
> >
<div <AccessibleButton
aria-checked={false} aria-checked={false}
aria-disabled={false} aria-disabled={false}
aria-label="Enable live location sharing" aria-label="Enable live location sharing"
className="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled" className="mx_ToggleSwitch mx_ToggleSwitch_enabled"
element="div"
onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]} onFocus={[Function]}
onKeyUp={[Function]} onMouseLeave={[Function]}
onMouseOver={[Function]}
role="switch" role="switch"
tabIndex={0} tabIndex={0}
> >
<div <div
className="mx_ToggleSwitch_ball" aria-checked={false}
/> aria-disabled={false}
</div> aria-label="Enable live location sharing"
</AccessibleButton> 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}
>
<div
className="mx_ToggleSwitch_ball"
/>
</div>
</AccessibleButton>
</AccessibleTooltipButton>
</_default> </_default>
</div> </div>
</LabelledToggleSwitch> </LabelledToggleSwitch>

View file

@ -505,7 +505,9 @@ describe("RoomHeader (React Testing Library)", () => {
+ "and there's an ongoing call", + "and there's an ongoing call",
async () => { async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); 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); await ElementCall.create(room);
renderHeader(); renderHeader();
@ -519,7 +521,9 @@ describe("RoomHeader (React Testing Library)", () => {
+ "use Element Call exclusively", + "use Element Call exclusively",
async () => { async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); 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(); renderHeader();
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
@ -541,7 +545,9 @@ describe("RoomHeader (React Testing Library)", () => {
+ "and the user lacks permission", + "and the user lacks permission",
() => { () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); 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 }); mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 });
renderHeader(); renderHeader();

View file

@ -18,37 +18,54 @@ exports[`<Notifications /> main notification switches email switches renders ema
Enable email notifications for tester@test.com Enable email notifications for tester@test.com
</span> </span>
<_default <_default
aria-label="Enable email notifications for tester@test.com"
checked={false} checked={false}
disabled={false} disabled={false}
onChange={[Function]} onChange={[Function]}
title="Enable email notifications for tester@test.com"
> >
<AccessibleButton <AccessibleTooltipButton
aria-checked={false} aria-checked={false}
aria-disabled={false} aria-disabled={false}
aria-label="Enable email notifications for tester@test.com"
className="mx_ToggleSwitch mx_ToggleSwitch_enabled" className="mx_ToggleSwitch mx_ToggleSwitch_enabled"
element="div"
onClick={[Function]} onClick={[Function]}
role="switch" role="switch"
tabIndex={0} title="Enable email notifications for tester@test.com"
> >
<div <AccessibleButton
aria-checked={false} aria-checked={false}
aria-disabled={false} aria-disabled={false}
aria-label="Enable email notifications for tester@test.com" aria-label="Enable email notifications for tester@test.com"
className="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled" className="mx_ToggleSwitch mx_ToggleSwitch_enabled"
element="div"
onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]} onFocus={[Function]}
onKeyUp={[Function]} onMouseLeave={[Function]}
onMouseOver={[Function]}
role="switch" role="switch"
tabIndex={0} tabIndex={0}
> >
<div <div
className="mx_ToggleSwitch_ball" aria-checked={false}
/> aria-disabled={false}
</div> aria-label="Enable email notifications for tester@test.com"
</AccessibleButton> 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}
>
<div
className="mx_ToggleSwitch_ball"
/>
</div>
</AccessibleButton>
</AccessibleTooltipButton>
</_default> </_default>
</div> </div>
</LabelledToggleSwitch> </LabelledToggleSwitch>
@ -84,37 +101,54 @@ exports[`<Notifications /> main notification switches renders only enable notifi
</Caption> </Caption>
</span> </span>
<_default <_default
aria-label="Enable notifications for this account"
checked={false} checked={false}
disabled={false} disabled={false}
onChange={[Function]} onChange={[Function]}
title="Enable notifications for this account"
> >
<AccessibleButton <AccessibleTooltipButton
aria-checked={false} aria-checked={false}
aria-disabled={false} aria-disabled={false}
aria-label="Enable notifications for this account"
className="mx_ToggleSwitch mx_ToggleSwitch_enabled" className="mx_ToggleSwitch mx_ToggleSwitch_enabled"
element="div"
onClick={[Function]} onClick={[Function]}
role="switch" role="switch"
tabIndex={0} title="Enable notifications for this account"
> >
<div <AccessibleButton
aria-checked={false} aria-checked={false}
aria-disabled={false} aria-disabled={false}
aria-label="Enable notifications for this account" aria-label="Enable notifications for this account"
className="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled" className="mx_ToggleSwitch mx_ToggleSwitch_enabled"
element="div"
onBlur={[Function]}
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]} onFocus={[Function]}
onKeyUp={[Function]} onMouseLeave={[Function]}
onMouseOver={[Function]}
role="switch" role="switch"
tabIndex={0} tabIndex={0}
> >
<div <div
className="mx_ToggleSwitch_ball" aria-checked={false}
/> aria-disabled={false}
</div> aria-label="Enable notifications for this account"
</AccessibleButton> 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}
>
<div
className="mx_ToggleSwitch_ball"
/>
</div>
</AccessibleButton>
</AccessibleTooltipButton>
</_default> </_default>
</div> </div>
</LabelledToggleSwitch> </LabelledToggleSwitch>

View file

@ -16,30 +16,35 @@ limitations under the License.
import React from "react"; import React from "react";
import { fireEvent, render, RenderResult } from "@testing-library/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 RolesRoomSettingsTab from "../../../../../../src/components/views/settings/tabs/room/RolesRoomSettingsTab";
import { mkStubRoom, stubClient } from "../../../../../test-utils"; import { mkStubRoom, stubClient } from "../../../../../test-utils";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { VoiceBroadcastInfoEventType } from "../../../../../../src/voice-broadcast"; import { VoiceBroadcastInfoEventType } from "../../../../../../src/voice-broadcast";
import SettingsStore from "../../../../../../src/settings/SettingsStore";
import { ElementCall } from "../../../../../../src/models/Call";
describe("RolesRoomSettingsTab", () => { describe("RolesRoomSettingsTab", () => {
const roomId = "!room:example.com"; const roomId = "!room:example.com";
let rolesRoomSettingsTab: RenderResult;
let cli: MatrixClient; let cli: MatrixClient;
const renderTab = (): RenderResult => {
return render(<RolesRoomSettingsTab roomId={roomId} />);
};
const getVoiceBroadcastsSelect = () => { const getVoiceBroadcastsSelect = () => {
return rolesRoomSettingsTab.container.querySelector("select[label='Voice broadcasts']"); return renderTab().container.querySelector("select[label='Voice broadcasts']");
}; };
const getVoiceBroadcastsSelectedOption = () => { const getVoiceBroadcastsSelectedOption = () => {
return rolesRoomSettingsTab.container.querySelector("select[label='Voice broadcasts'] option:checked"); return renderTab().container.querySelector("select[label='Voice broadcasts'] option:checked");
}; };
beforeEach(() => { beforeEach(() => {
stubClient(); stubClient();
cli = MatrixClientPeg.get(); cli = MatrixClientPeg.get();
rolesRoomSettingsTab = render(<RolesRoomSettingsTab roomId={roomId} />);
mkStubRoom(roomId, "test room", cli); 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();
});
});
}); });

View file

@ -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(<VoipRoomSettingsTab roomId={roomId} />);
};
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,
},
}),
));
});
});
});
});

View file

@ -25,6 +25,7 @@ import WidgetStore from "../src/stores/WidgetStore";
import WidgetUtils from "../src/utils/WidgetUtils"; import WidgetUtils from "../src/utils/WidgetUtils";
import { JitsiCall, ElementCall } from "../src/models/Call"; import { JitsiCall, ElementCall } from "../src/models/Call";
import createRoom, { canEncryptToAllUsers } from '../src/createRoom'; import createRoom, { canEncryptToAllUsers } from '../src/createRoom';
import SettingsStore from "../src/settings/SettingsStore";
describe("createRoom", () => { describe("createRoom", () => {
mockPlatformPeg(); mockPlatformPeg();
@ -85,7 +86,7 @@ describe("createRoom", () => {
[ElementCall.MEMBER_EVENT_TYPE.name]: callMemberPower, [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 // We should have had enough power to be able to set up the call
expect(userPower).toBeGreaterThanOrEqual(callPower); expect(userPower).toBeGreaterThanOrEqual(callPower);
@ -109,6 +110,26 @@ describe("createRoom", () => {
expect(createJitsiCallSpy).not.toHaveBeenCalled(); expect(createJitsiCallSpy).not.toHaveBeenCalled();
expect(createElementCallSpy).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", () => { describe("canEncryptToAllUsers", () => {

View file

@ -36,7 +36,7 @@ describe('recordClientInformation()', () => {
const sdkConfig: IConfigOptions = { const sdkConfig: IConfigOptions = {
brand: 'Test Brand', brand: 'Test Brand',
element_call: { url: '', use_exclusively: false }, element_call: { url: '', use_exclusively: false, brand: "Element Call" },
}; };
const platform = { const platform = {