diff --git a/package.json b/package.json index 389478941f..15dc5b6f79 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@percy/cypress": "^3.1.1", "@sentry/types": "^6.10.0", "@sinonjs/fake-timers": "^9.1.2", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index 837d3050f3..b877cb90af 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -118,6 +118,7 @@ export interface IConfigOptions { }; element_call: { url: string; + use_exclusively: boolean; }; logout_redirect_url?: string; diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 624dc86a33..a924388ead 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -820,10 +820,10 @@ export default class LegacyCallHandler extends EventEmitter { } } - public placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): void { + public async placeCall(roomId: string, type?: CallType, transferee?: MatrixCall): Promise { // We might be using managed hybrid widgets if (isManagedHybridWidgetEnabled()) { - addManagedHybridWidget(roomId); + await addManagedHybridWidget(roomId); return; } @@ -870,9 +870,9 @@ export default class LegacyCallHandler extends EventEmitter { } else if (members.length === 2) { logger.info(`Place ${type} call in ${roomId}`); - this.placeMatrixCall(roomId, type, transferee); + await this.placeMatrixCall(roomId, type, transferee); } else { // > 2 - this.placeJitsiCall(roomId, type); + await this.placeJitsiCall(roomId, type); } } diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index d466a05074..7a86982723 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -32,6 +32,7 @@ export const DEFAULTS: IConfigOptions = { }, element_call: { url: "https://call.element.io", + use_exclusively: false, }, // @ts-ignore - we deliberately use the camelCase version here so we trigger diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index fed78d7617..1c20195b68 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -30,7 +30,7 @@ import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { EventType } from 'matrix-js-sdk/src/@types/event'; import { RoomState, RoomStateEvent } from 'matrix-js-sdk/src/models/room-state'; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; -import { CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { throttle } from "lodash"; import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { ClientEvent } from "matrix-js-sdk/src/client"; @@ -149,7 +149,7 @@ interface IRoomProps extends MatrixClientProps { enum MainSplitContentType { Timeline, MaximisedWidget, - Video, // immersive voip + Call, } export interface IRoomState { room?: Room; @@ -299,7 +299,6 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { e2eStatus={E2EStatus.Normal} onAppsClick={null} appsShown={false} - onCallPlaced={null} excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} @@ -350,7 +349,6 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement e2eStatus={E2EStatus.Normal} onAppsClick={null} appsShown={false} - onCallPlaced={null} excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} @@ -517,7 +515,7 @@ export class RoomView extends React.Component { private getMainSplitContentType = (room: Room) => { if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) { - return MainSplitContentType.Video; + return MainSplitContentType.Call; } if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { return MainSplitContentType.MaximisedWidget; @@ -1660,10 +1658,6 @@ export class RoomView extends React.Component { return ret; } - private onCallPlaced = (type: CallType): void => { - LegacyCallHandler.instance.placeCall(this.state.room?.roomId, type); - }; - private onAppsClick = () => { dis.dispatch({ action: "appsDrawer", @@ -2330,7 +2324,7 @@ export class RoomView extends React.Component { const mainClasses = classNames("mx_RoomView", { mx_RoomView_inCall: Boolean(activeCall), - mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Video, + mx_RoomView_immersive: this.state.mainSplitContentType === MainSplitContentType.Call, }); const showChatEffects = SettingsStore.getValue('showChatEffects'); @@ -2371,7 +2365,7 @@ export class RoomView extends React.Component { { previewBar } ; break; - case MainSplitContentType.Video: { + case MainSplitContentType.Call: { mainSplitContentClassName = "mx_MainSplit_video"; mainSplitBody = <> @@ -2382,7 +2376,6 @@ export class RoomView extends React.Component { const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline]; - let onCallPlaced = this.onCallPlaced; let onAppsClick = this.onAppsClick; let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; @@ -2399,13 +2392,12 @@ export class RoomView extends React.Component { onForgetClick = null; onSearchClick = null; break; - case MainSplitContentType.Video: + case MainSplitContentType.Call: excludedRightPanelPhaseButtons = [ RightPanelPhases.ThreadPanel, RightPanelPhases.PinnedMessages, RightPanelPhases.NotificationPanel, ]; - onCallPlaced = null; onAppsClick = null; onForgetClick = null; onSearchClick = null; @@ -2432,7 +2424,6 @@ export class RoomView extends React.Component { e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null} appsShown={this.state.showApps} - onCallPlaced={onCallPlaced} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} showButtons={!this.viewsLocalRoom} enableRoomOptionsMenu={!this.viewsLocalRoom} diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index d2429f1a7b..05316ffff6 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -76,7 +76,7 @@ const Button: React.FC = ({ children, className, onClick }) => { }; export const useWidgets = (room: Room) => { - const [apps, setApps] = useState(WidgetStore.instance.getApps(room.roomId)); + const [apps, setApps] = useState(() => WidgetStore.instance.getApps(room.roomId)); const updateApps = useCallback(() => { // Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index d64d3d7e32..0d01e039c4 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -15,12 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { FC, useState, useMemo, useCallback } from 'react'; import classNames from 'classnames'; import { throttle } from 'lodash'; -import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk/src/matrix'; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { Room } from "matrix-js-sdk/src/models/room"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import defaultDispatcher from "../../../dispatcher/dispatcher"; @@ -30,13 +32,14 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; import { E2EStatus } from '../../../utils/ShieldUtils'; import { IOOBData } from '../../../stores/ThreepidInviteStore'; import { SearchScope } from './SearchBar'; -import { ContextMenuTooltipButton } from '../../structures/ContextMenu'; +import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import RoomContextMenu from "../context_menus/RoomContextMenu"; import { contextMenuBelow } from './RoomTile'; import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore'; @@ -48,6 +51,272 @@ import { BetaPill } from "../beta/BetaCard"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; +import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler"; +import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings"; +import SdkConfig from "../../../SdkConfig"; +import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; +import { useWidgets } from "../right_panel/RoomSummaryCard"; +import { WidgetType } from "../../../widgets/WidgetType"; +import { useCall } from "../../../hooks/useCall"; +import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; +import { ElementCall } from "../../../models/Call"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, +} from "../context_menus/IconizedContextMenu"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; + +class DisabledWithReason { + constructor(public readonly reason: string) { } +} + +interface VoiceCallButtonProps { + room: Room; + busy: boolean; + setBusy: (value: boolean) => void; + behavior: DisabledWithReason | "legacy_or_jitsi"; +} + +/** + * Button for starting voice calls, supporting only legacy 1:1 calls and Jitsi + * widgets. + */ +const VoiceCallButton: FC = ({ room, busy, setBusy, behavior }) => { + const { onClick, tooltip, disabled } = useMemo(() => { + if (behavior instanceof DisabledWithReason) { + return { + onClick: () => {}, + tooltip: behavior.reason, + disabled: true, + }; + } else { // behavior === "legacy_or_jitsi" + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice); + setBusy(false); + }, + disabled: false, + }; + } + }, [behavior, room, setBusy]); + + return ; +}; + +interface VideoCallButtonProps { + room: Room; + busy: boolean; + setBusy: (value: boolean) => void; + behavior: DisabledWithReason | "legacy_or_jitsi" | "element" | "jitsi_or_element"; +} + +/** + * Button for starting video calls, supporting both legacy 1:1 calls, Jitsi + * widgets, and native group calls. If multiple calling options are available, + * this shows a menu to pick between them. + */ +const VideoCallButton: FC = ({ room, busy, setBusy, behavior }) => { + const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); + + const startLegacyCall = useCallback(async () => { + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video); + setBusy(false); + }, [setBusy, room]); + + const startElementCall = useCallback(() => { + setBusy(true); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + setBusy(false); + }, [setBusy, room]); + + const { onClick, tooltip, disabled } = useMemo(() => { + if (behavior instanceof DisabledWithReason) { + return { + onClick: () => {}, + tooltip: behavior.reason, + disabled: true, + }; + } else if (behavior === "legacy_or_jitsi") { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + await startLegacyCall(); + }, + disabled: false, + }; + } else if (behavior === "element") { + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + startElementCall(); + }, + disabled: false, + }; + } else { // behavior === "jitsi_or_element" + return { + onClick: async (ev: ButtonEvent) => { + ev.preventDefault(); + openMenu(); + }, + disabled: false, + }; + } + }, [behavior, startLegacyCall, startElementCall, openMenu]); + + const onJitsiClick = useCallback(async (ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + await startLegacyCall(); + }, [closeMenu, startLegacyCall]); + + const onElementClick = useCallback((ev: ButtonEvent) => { + ev.preventDefault(); + closeMenu(); + startElementCall(); + }, [closeMenu, startElementCall]); + + let menu: JSX.Element | null = null; + if (menuOpen) { + const buttonRect = buttonRef.current!.getBoundingClientRect(); + menu = + + + + + ; + } + + return <> + + { menu } + ; +}; + +interface CallButtonsProps { + room: Room; +} + +// The header buttons for placing calls have become stupidly complex, so here +// they are as a separate component +const CallButtons: FC = ({ room }) => { + const [busy, setBusy] = useState(false); + const showButtons = useSettingValue("showCallButtonsInComposer"); + const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); + const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); + const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]); + const useElementCallExclusively = useMemo(() => SdkConfig.get("element_call").use_exclusively, []); + + const hasLegacyCall = useEventEmitterState( + LegacyCallHandler.instance, + LegacyCallHandlerEvent.CallsChanged, + useCallback(() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, [room]), + ); + + const widgets = useWidgets(room); + const hasJitsiWidget = useMemo(() => widgets.some(widget => WidgetType.JITSI.matches(widget.type)), [widgets]); + + const hasGroupCall = useCall(room.roomId) !== null; + + const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState( + room, + RoomStateEvent.Update, + useCallback(() => [ + getJoinedNonFunctionalMembers(room), + room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client), + room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client), + ], [room]), + ); + + const makeVoiceCallButton = (behavior: VoiceCallButtonProps["behavior"]): JSX.Element => + ; + const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => + ; + + if (isVideoRoom || !showButtons) { + return null; + } else if (groupCallsEnabled) { + if (useElementCallExclusively) { + if (hasGroupCall) { + return makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))); + } else if (mayCreateElementCalls) { + return makeVideoCallButton("element"); + } else { + return makeVideoCallButton( + new DisabledWithReason(_t("You do not have permission to start video calls")), + ); + } + } else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } + ; + } else if (functionalMembers.length <= 1) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + ; + } else if (functionalMembers.length === 2) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton("legacy_or_jitsi") } + ; + } else if (mayEditWidgets) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton(mayCreateElementCalls ? "jitsi_or_element" : "legacy_or_jitsi") } + ; + } else { + const videoCallBehavior = mayCreateElementCalls + ? "element" + : new DisabledWithReason(_t("You do not have permission to start video calls")); + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls"))) } + { makeVideoCallButton(videoCallBehavior) } + ; + } + } else if (hasLegacyCall || hasJitsiWidget) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("Ongoing call"))) } + ; + } else if (functionalMembers.length <= 1) { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + { makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call"))) } + ; + } else if (functionalMembers.length === 2 || mayEditWidgets) { + return <> + { makeVoiceCallButton("legacy_or_jitsi") } + { makeVideoCallButton("legacy_or_jitsi") } + ; + } else { + return <> + { makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls"))) } + { makeVideoCallButton(new DisabledWithReason(_t("You do not have permission to start video calls"))) } + ; + } +}; export interface ISearchInfo { searchTerm: string; @@ -55,15 +324,14 @@ export interface ISearchInfo { searchCount: number; } -interface IProps { +export interface IProps { room: Room; oobData?: IOOBData; inRoom: boolean; - onSearchClick: () => void; - onInviteClick: () => void; - onForgetClick: () => void; - onCallPlaced: (type: CallType) => void; - onAppsClick: () => void; + onSearchClick: (() => void) | null; + onInviteClick: (() => void) | null; + onForgetClick: (() => void) | null; + onAppsClick: (() => void) | null; e2eStatus: E2EStatus; appsShown: boolean; searchInfo: ISearchInfo; @@ -89,7 +357,7 @@ export default class RoomHeader extends React.Component { static contextType = RoomContext; public context!: React.ContextType; - constructor(props, context) { + constructor(props: IProps, context: IState) { super(props, context); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); @@ -141,30 +409,14 @@ export default class RoomHeader extends React.Component { }; private onContextMenuCloseClick = () => { - this.setState({ contextMenuPosition: null }); + this.setState({ contextMenuPosition: undefined }); }; private renderButtons(): JSX.Element[] { const buttons: JSX.Element[] = []; - if (this.props.inRoom && - this.props.onCallPlaced && - !this.context.tombstone && - SettingsStore.getValue("showCallButtonsInComposer") - ) { - const voiceCallButton = this.props.onCallPlaced(CallType.Voice)} - title={_t("Voice call")} - key="voice" - />; - const videoCallButton = this.props.onCallPlaced(CallType.Video)} - title={_t("Video call")} - key="video" - />; - buttons.push(voiceCallButton, videoCallButton); + if (this.props.inRoom && !this.context.tombstone) { + buttons.push(); } if (this.props.onForgetClick) { @@ -212,8 +464,8 @@ export default class RoomHeader extends React.Component { return buttons; } - private renderName(oobName) { - let contextMenu: JSX.Element; + private renderName(oobName: string) { + let contextMenu: JSX.Element | null = null; if (this.state.contextMenuPosition && this.props.room) { contextMenu = ( { } public render() { - let searchStatus = null; + let searchStatus: JSX.Element | null = null; // don't display the search count until the search completes and // gives us a valid (possibly zero) searchCount. @@ -291,7 +543,7 @@ export default class RoomHeader extends React.Component { className="mx_RoomHeader_topic" />; - let roomAvatar; + let roomAvatar: JSX.Element | null = null; if (this.props.room) { roomAvatar = { />; } - let buttons; + let buttons: JSX.Element | null = null; if (this.props.showButtons) { buttons =
diff --git a/src/dispatcher/payloads/ViewRoomPayload.ts b/src/dispatcher/payloads/ViewRoomPayload.ts index cd62f7ca3f..e497939ff0 100644 --- a/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/src/dispatcher/payloads/ViewRoomPayload.ts @@ -47,6 +47,7 @@ export interface ViewRoomPayload extends Pick { forceTimeline?: boolean; // Whether to override default behaviour to end up at a timeline show_room_tile?: boolean; // Whether to ensure that the room tile is visible in the room list clear_search?: boolean; // Whether to clear the room list search + view_call?: boolean; // Whether to view the call or call lobby for the room deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action diff --git a/src/hooks/useEventEmitter.ts b/src/hooks/useEventEmitter.ts index 46a7d8f184..ff1592028a 100644 --- a/src/hooks/useEventEmitter.ts +++ b/src/hooks/useEventEmitter.ts @@ -87,7 +87,7 @@ export function useEventEmitterState( eventName: string | symbol, fn: Mapper, ): T { - const [value, setValue] = useState(fn()); + const [value, setValue] = useState(fn); const handler = useCallback((...args: any[]) => { setValue(fn(...args)); }, [fn]); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1534c4d51f..5c1359d499 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -865,6 +865,7 @@ "Spaces": "Spaces", "Widgets": "Widgets", "Rooms": "Rooms", + "Voice & Video": "Voice & Video", "Moderation": "Moderation", "Analytics": "Analytics", "Message Previews": "Message Previews", @@ -910,6 +911,7 @@ "Send read receipts": "Send read receipts", "Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)", "Element Call video rooms": "Element Call video rooms", + "New group call experience": "New group call experience", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Favourite Messages (under active development)": "Favourite Messages (under active development)", "Voice broadcast (under active development)": "Voice broadcast (under active development)", @@ -1591,7 +1593,6 @@ "No Microphones detected": "No Microphones detected", "Camera": "Camera", "No Webcams detected": "No Webcams detected", - "Voice & Video": "Voice & Video", "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.", "Upgrade this space to the recommended room version": "Upgrade this space to the recommended room version", @@ -1868,6 +1869,12 @@ "Room %(name)s": "Room %(name)s", "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)", + "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", + "You do not have permission to start voice calls": "You do not have permission to start voice calls", "Forget room": "Forget room", "Hide Widgets": "Hide Widgets", "Show Widgets": "Show Widgets", diff --git a/src/models/Call.ts b/src/models/Call.ts index 9b11261e85..bc8bb6a65a 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -635,11 +635,13 @@ export class ElementCall extends Call { } public static get(room: Room): ElementCall | null { - // Only supported in video rooms (for now) + // Only supported in the new group call experience or in video rooms if ( - SettingsStore.getValue("feature_video_rooms") - && SettingsStore.getValue("feature_element_call_video_rooms") - && room.isCallRoom() + SettingsStore.getValue("feature_group_calls") || ( + SettingsStore.getValue("feature_video_rooms") + && SettingsStore.getValue("feature_element_call_video_rooms") + && room.isCallRoom() + ) ) { const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType => room.currentState.getStateEvents(eventType), diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 0567d10fb8..5220f9d060 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -92,6 +92,7 @@ export enum LabGroup { Spaces, Widgets, Rooms, + VoiceAndVideo, Moderation, Analytics, MessagePreviews, @@ -111,6 +112,7 @@ export const labGroupNames: Record = { [LabGroup.Spaces]: _td("Spaces"), [LabGroup.Widgets]: _td("Widgets"), [LabGroup.Rooms]: _td("Rooms"), + [LabGroup.VoiceAndVideo]: _td("Voice & Video"), [LabGroup.Moderation]: _td("Moderation"), [LabGroup.Analytics]: _td("Analytics"), [LabGroup.MessagePreviews]: _td("Message Previews"), @@ -191,7 +193,7 @@ export type ISetting = IBaseSetting | IFeature; export const SETTINGS: {[setting: string]: ISetting} = { "feature_video_rooms": { isFeature: true, - labsGroup: LabGroup.Rooms, + labsGroup: LabGroup.VoiceAndVideo, displayName: _td("Video rooms"), supportedLevels: LEVELS_FEATURE, default: false, @@ -426,11 +428,18 @@ export const SETTINGS: {[setting: string]: ISetting} = { "feature_element_call_video_rooms": { isFeature: true, supportedLevels: LEVELS_FEATURE, - labsGroup: LabGroup.Rooms, + labsGroup: LabGroup.VoiceAndVideo, displayName: _td("Element Call video rooms"), controller: new ReloadOnChangeController(), default: false, }, + "feature_group_calls": { + isFeature: true, + supportedLevels: LEVELS_FEATURE, + labsGroup: LabGroup.VoiceAndVideo, + displayName: _td("New group call experience"), + default: false, + }, "feature_location_share_live": { isFeature: true, labsGroup: LabGroup.Messaging, diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 3c127275a2..3db9c08434 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -53,38 +53,75 @@ import { UPDATE_EVENT } from "./AsyncStore"; const NUM_JOIN_RETRY = 5; -const INITIAL_STATE = { - // Whether we're joining the currently viewed room (see isJoining()) +interface State { + /** + * Whether we're joining the currently viewed (see isJoining()) + */ + joining: boolean; + /** + * Any error that has occurred during joining + */ + joinError: Error | null; + /** + * The ID of the room currently being viewed + */ + roomId: string | null; + /** + * The ID of the room being subscribed to (in Sliding Sync) + */ + subscribingRoomId: string | null; + /** + * The event to scroll to when the room is first viewed + */ + initialEventId: string | null; + initialEventPixelOffset: number | null; + /** + * Whether to highlight the initial event + */ + isInitialEventHighlighted: boolean; + /** + * Whether to scroll the initial event into view + */ + initialEventScrollIntoView: boolean; + /** + * The alias of the room (or null if not originally specified in view_room) + */ + roomAlias: string | null; + /** + * Whether the current room is loading + */ + roomLoading: boolean; + /** + * Any error that has occurred during loading + */ + roomLoadError: MatrixError | null; + replyingToEvent: MatrixEvent | null; + shouldPeek: boolean; + viaServers: string[]; + wasContextSwitch: boolean; + /** + * Whether we're viewing a call or call lobby in this room + */ + viewingCall: boolean; +} + +const INITIAL_STATE: State = { joining: false, - // Any error that has occurred during joining - joinError: null as Error, - // The room ID of the room currently being viewed - roomId: null as string, - // The room ID being subscribed to (in Sliding Sync) - subscribingRoomId: null as string, - - // The event to scroll to when the room is first viewed - initialEventId: null as string, - initialEventPixelOffset: null as number, - // Whether to highlight the initial event + joinError: null, + roomId: null, + subscribingRoomId: null, + initialEventId: null, + initialEventPixelOffset: null, isInitialEventHighlighted: false, - // whether to scroll `event_id` into view initialEventScrollIntoView: true, - - // The room alias of the room (or null if not originally specified in view_room) - roomAlias: null as string, - // Whether the current room is loading + roomAlias: null, roomLoading: false, - // Any error that has occurred during loading - roomLoadError: null as MatrixError, - - replyingToEvent: null as MatrixEvent, - + roomLoadError: null, + replyingToEvent: null, shouldPeek: false, - - viaServers: [] as string[], - + viaServers: [], wasContextSwitch: false, + viewingCall: false, }; type Listener = (isActive: boolean) => void; @@ -98,7 +135,7 @@ export class RoomViewStore extends EventEmitter { // the app. We need to eagerly create the instance. public static readonly instance = new RoomViewStore(defaultDispatcher); - private state = INITIAL_STATE; // initialize state + private state: State = INITIAL_STATE; // initialize state private dis: MatrixDispatcher; private dispatchToken: string; @@ -120,7 +157,7 @@ export class RoomViewStore extends EventEmitter { this.emit(roomId, isActive); } - private setState(newState: Partial): void { + private setState(newState: Partial): void { // If values haven't changed, there's nothing to do. // This only tries a shallow comparison, so unchanged objects will slip // through, but that's probably okay for now. @@ -172,6 +209,7 @@ export class RoomViewStore extends EventEmitter { roomAlias: null, viaServers: [], wasContextSwitch: false, + viewingCall: false, }); break; case Action.ViewRoomError: @@ -286,6 +324,7 @@ export class RoomViewStore extends EventEmitter { roomLoadError: null, viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, + viewingCall: payload.view_call ?? false, }); // set this room as the room subscription. We need to await for it as this will fetch // all room state for this room, which is required before we get the state below. @@ -303,11 +342,11 @@ export class RoomViewStore extends EventEmitter { return; } - const newState = { + const newState: Partial = { roomId: payload.room_id, - roomAlias: payload.room_alias, - initialEventId: payload.event_id, - isInitialEventHighlighted: payload.highlighted, + roomAlias: payload.room_alias ?? null, + initialEventId: payload.event_id ?? null, + isInitialEventHighlighted: payload.highlighted ?? false, initialEventScrollIntoView: payload.scroll_into_view ?? true, roomLoading: false, roomLoadError: null, @@ -317,8 +356,12 @@ export class RoomViewStore extends EventEmitter { joining: payload.joining || false, // Reset replyingToEvent because we don't want cross-room because bad UX replyingToEvent: null, - viaServers: payload.via_servers, - wasContextSwitch: payload.context_switch, + viaServers: payload.via_servers ?? [], + wasContextSwitch: payload.context_switch ?? false, + viewingCall: payload.view_call ?? ( + // Reset to false when switching rooms + payload.room_id === this.state.roomId ? this.state.viewingCall : false + ), }; // Allow being given an event to be replied to when switching rooms but sanity check its for this room @@ -351,13 +394,14 @@ export class RoomViewStore extends EventEmitter { roomId: null, initialEventId: null, initialEventPixelOffset: null, - isInitialEventHighlighted: null, + isInitialEventHighlighted: false, initialEventScrollIntoView: true, roomAlias: payload.room_alias, roomLoading: true, roomLoadError: null, viaServers: payload.via_servers, wasContextSwitch: payload.context_switch, + viewingCall: payload.view_call ?? false, }); try { const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); @@ -577,4 +621,8 @@ export class RoomViewStore extends EventEmitter { public getWasContextSwitch(): boolean { return this.state.wasContextSwitch; } + + public isViewingCall(): boolean { + return this.state.viewingCall; + } } diff --git a/src/stores/WidgetEchoStore.ts b/src/stores/WidgetEchoStore.ts index 2923d46b09..ac524eb4cf 100644 --- a/src/stores/WidgetEchoStore.ts +++ b/src/stores/WidgetEchoStore.ts @@ -111,7 +111,7 @@ class WidgetEchoStore extends EventEmitter { } } -let singletonWidgetEchoStore = null; +let singletonWidgetEchoStore: WidgetEchoStore | null = null; if (!singletonWidgetEchoStore) { singletonWidgetEchoStore = new WidgetEchoStore(); } diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index baaf85eb44..7181f143c3 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -17,23 +17,48 @@ limitations under the License. import React from 'react'; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from 'enzyme'; -import { Room, PendingEventOrdering, MatrixEvent, MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { render, screen, act, fireEvent, waitFor, getByRole } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { mocked, Mocked } from "jest-mock"; +import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; +import { PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { CallType } from "matrix-js-sdk/src/webrtc/call"; -import * as TestUtils from '../../../test-utils'; +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { + stubClient, + mkRoomMember, + setupAsyncStoreWithClient, + resetAsyncStoreWithClient, + mockPlatformPeg, +} from "../../../test-utils"; import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import DMRoomMap from '../../../../src/utils/DMRoomMap'; -import RoomHeader from '../../../../src/components/views/rooms/RoomHeader'; +import RoomHeader, { IProps as RoomHeaderProps } from "../../../../src/components/views/rooms/RoomHeader"; import { SearchScope } from '../../../../src/components/views/rooms/SearchBar'; import { E2EStatus } from '../../../../src/utils/ShieldUtils'; import { mkEvent } from '../../../test-utils'; import { IRoomState } from "../../../../src/components/structures/RoomView"; import RoomContext from '../../../../src/contexts/RoomContext'; +import SdkConfig from "../../../../src/SdkConfig"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { ElementCall, JitsiCall } from "../../../../src/models/Call"; +import { CallStore } from "../../../../src/stores/CallStore"; +import LegacyCallHandler from "../../../../src/LegacyCallHandler"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; +import WidgetStore from "../../../../src/stores/WidgetStore"; -describe('RoomHeader', () => { +describe('RoomHeader (Enzyme)', () => { it('shows the room avatar in a room with only ourselves', () => { // When we render a non-DM room with 1 person in it const room = createRoom({ name: "X Room", isDm: false, userIds: [] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -48,7 +73,7 @@ describe('RoomHeader', () => { // When we render a non-DM room with 2 people in it const room = createRoom( { name: "Y Room", isDm: false, userIds: ["other"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -62,7 +87,7 @@ describe('RoomHeader', () => { it('shows the room avatar in a room with >2 people', () => { // When we render a non-DM room with 3 people in it const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -76,7 +101,7 @@ describe('RoomHeader', () => { it('shows the room avatar in a DM with only ourselves', () => { // When we render a non-DM room with 1 person in it const room = createRoom({ name: "Z Room", isDm: true, userIds: [] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -93,7 +118,7 @@ describe('RoomHeader', () => { // When we render a DM room with only 2 people in it const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] }); - const rendered = render(room); + const rendered = mountHeader(room); // Then we use the other user's avatar as our room's image avatar const image = findImg(rendered, ".mx_BaseAvatar_image"); @@ -106,8 +131,9 @@ describe('RoomHeader', () => { it('shows the room avatar in a DM with >2 people', () => { // When we render a DM room with 3 people in it const room = createRoom({ - name: "Z Room", isDm: true, userIds: ["other1", "other2"] }); - const rendered = render(room); + name: "Z Room", isDm: true, userIds: ["other1", "other2"], + }); + const rendered = mountHeader(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); @@ -119,8 +145,8 @@ describe('RoomHeader', () => { }); it("renders call buttons normally", () => { - const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room); + const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] }); + const wrapper = mountHeader(room); expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(1); expect(wrapper.find('[aria-label="Video call"]').hostNodes()).toHaveLength(1); @@ -128,7 +154,7 @@ describe('RoomHeader', () => { it("hides call buttons when the room is tombstoned", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, {}, { + const wrapper = mountHeader(room, {}, { tombstone: mkEvent({ event: true, type: "m.room.tombstone", @@ -146,25 +172,25 @@ describe('RoomHeader', () => { it("should render buttons if not passing showButtons (default true)", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room); + const wrapper = mountHeader(room); expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(1); }); it("should not render buttons if passing showButtons = false", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, { showButtons: false }); + const wrapper = mountHeader(room, { showButtons: false }); expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(0); }); it("should render the room options context menu if not passing enableRoomOptionsMenu (default true)", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room); + const wrapper = mountHeader(room); expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(1); }); it("should not render the room options context menu if passing enableRoomOptionsMenu = false", () => { const room = createRoom({ name: "Room", isDm: false, userIds: [] }); - const wrapper = render(room, { enableRoomOptionsMenu: false }); + const wrapper = mountHeader(room, { enableRoomOptionsMenu: false }); expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(0); }); }); @@ -176,7 +202,7 @@ interface IRoomCreationInfo { } function createRoom(info: IRoomCreationInfo) { - TestUtils.stubClient(); + stubClient(); const client: MatrixClient = MatrixClientPeg.get(); const roomId = '!1234567890:domain'; @@ -210,15 +236,15 @@ function createRoom(info: IRoomCreationInfo) { return room; } -function render(room: Room, propsOverride = {}, roomContext?: Partial): ReactWrapper { +function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial): ReactWrapper { const props = { room, inRoom: true, - onSearchClick: () => {}, + onSearchClick: () => { }, onInviteClick: null, - onForgetClick: () => {}, + onForgetClick: () => { }, onCallPlaced: (_type) => { }, - onAppsClick: () => {}, + onAppsClick: () => { }, e2eStatus: E2EStatus.Normal, appsShown: true, searchInfo: { @@ -307,3 +333,395 @@ function findImg(wrapper: ReactWrapper, selector: string): ReactWrapper { expect(els).toHaveLength(1); return els.at(0); } + +describe("RoomHeader (React Testing Library)", () => { + let client: Mocked; + let room: Room; + let alice: RoomMember; + let bob: RoomMember; + let carol: RoomMember; + + beforeEach(async () => { + mockPlatformPeg({ supportsJitsiScreensharing: () => true }); + + stubClient(); + client = mocked(MatrixClientPeg.get()); + client.getUserId.mockReturnValue("@alice:example.org"); + + room = new Room("!1:example.org", client, "@alice:example.org", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + room.currentState.setStateEvents([mkCreationEvent(room.roomId, "@alice:example.org")]); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => { + if (roomId !== room.roomId) throw new Error("Unknown room"); + const event = mkEvent({ + event: true, + type: eventType, + room: roomId, + user: alice.userId, + skey: stateKey, + content, + }); + room.addLiveEvents([event]); + return { event_id: event.getId() }; + }); + + alice = mkRoomMember(room.roomId, "@alice:example.org"); + bob = mkRoomMember(room.roomId, "@bob:example.org"); + carol = mkRoomMember(room.roomId, "@carol:example.org"); + + client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + + await Promise.all([CallStore.instance, WidgetStore.instance].map( + store => setupAsyncStoreWithClient(store, client), + )); + }); + + afterEach(async () => { + await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient)); + client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); + jest.restoreAllMocks(); + SdkConfig.put({}); + }); + + const mockRoomType = (type: string) => { + jest.spyOn(room, "getType").mockReturnValue(type); + }; + const mockRoomMembers = (members: RoomMember[]) => { + jest.spyOn(room, "getJoinedMembers").mockReturnValue(members); + jest.spyOn(room, "getMember").mockImplementation( + userId => members.find(member => member.userId === userId) ?? null, + ); + }; + const mockEnabledSettings = (settings: string[]) => { + jest.spyOn(SettingsStore, "getValue").mockImplementation( + settingName => settings.includes(settingName), + ); + }; + const mockEventPowerLevels = (events: { [eventType: string]: number }) => { + room.currentState.setStateEvents([ + mkEvent({ + event: true, + type: EventType.RoomPowerLevels, + room: room.roomId, + user: alice.userId, + skey: "", + content: { events, state_default: 0 }, + }), + ]); + }; + const mockLegacyCall = () => { + jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue({} as unknown as MatrixCall); + }; + + const renderHeader = (props: Partial = {}, roomContext: Partial = {}) => { + render( + + { }} + onInviteClick={null} + onForgetClick={() => { }} + onAppsClick={() => { }} + e2eStatus={E2EStatus.Normal} + appsShown={true} + searchInfo={{ + searchTerm: "", + searchScope: SearchScope.Room, + searchCount: 0, + }} + {...props} + /> + , + ); + }; + + it("hides call buttons in video rooms", () => { + mockRoomType(RoomType.UnstableCall); + mockEnabledSettings(["showCallButtonsInComposer", "feature_video_rooms", "feature_element_call_video_rooms"]); + + renderHeader(); + expect(screen.queryByRole("button", { name: /call/i })).toBeNull(); + }); + + it("hides call buttons if showCallButtonsInComposer is disabled", () => { + mockEnabledSettings([]); + + renderHeader(); + expect(screen.queryByRole("button", { name: /call/i })).toBeNull(); + }); + + it( + "hides the voice call button and disables the video call button if configured to use Element Call exclusively " + + "and there's an ongoing call", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + await ElementCall.create(room); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }, + ); + + it( + "hides the voice call button and starts an Element call when the video call button is pressed if configured to " + + "use Element Call exclusively", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it( + "hides the voice call button and disables the video call button if configured to use Element Call exclusively " + + "and the user lacks permission", + () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + SdkConfig.put({ element_call: { url: "https://call.element.io", use_exclusively: true } }); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); + + renderHeader(); + expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull(); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }, + ); + + it("disables call buttons in the new group call experience if there's an ongoing Element call", async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + await ElementCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's an ongoing legacy 1:1 call", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockLegacyCall(); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's an existing Jitsi widget", async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + await JitsiCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons in the new group call experience if there's no other members", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it( + "starts a legacy 1:1 call when call buttons are pressed in the new group call experience if there's 1 other " + + "member", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }, + ); + + it( + "creates a Jitsi widget when call buttons are pressed in the new group call experience if the user lacks " + + "permission to start Element calls", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 }); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }, + ); + + it( + "creates a Jitsi widget when the voice call button is pressed and shows a menu when the video call button is " + + "pressed in the new group call experience", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + // First try creating a Jitsi widget from the menu + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /jitsi/i })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + + // Then try starting an Element call from the menu + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /element/i })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it( + "disables the voice call button and starts an Element call when the video call button is pressed in the new " + + "group call experience if the user lacks permission to edit widgets", + async () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + })); + defaultDispatcher.unregister(dispatcherRef); + }, + ); + + it("disables call buttons in the new group call experience if the user lacks permission", () => { + mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100, "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's an ongoing legacy 1:1 call", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockLegacyCall(); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's an existing Jitsi widget", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + await JitsiCall.create(room); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables call buttons if there's no other members", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("starts a legacy 1:1 call when call buttons are pressed if there's 1 other member", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); // Just to verify that it doesn't try to use Jitsi + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }); + + it("creates a Jitsi widget when call buttons are pressed", async () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob, carol]); + + renderHeader(); + + const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined); + fireEvent.click(screen.getByRole("button", { name: "Voice call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice); + + placeCallSpy.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Video call" })); + await act(() => Promise.resolve()); // Allow effects to settle + expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video); + }); + + it("disables call buttons if the user lacks permission", () => { + mockEnabledSettings(["showCallButtonsInComposer"]); + mockRoomMembers([alice, bob, carol]); + mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); + + renderHeader(); + expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true"); + }); +}); diff --git a/yarn.lock b/yarn.lock index b05ebda552..e0043f4409 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27,6 +27,11 @@ dependencies: tunnel "^0.0.6" +"@adobe/css-tools@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.0.1.tgz#b38b444ad3aa5fedbb15f2f746dcd934226a12dd" + integrity sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g== + "@ampproject/remapping@^2.1.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -1259,6 +1264,13 @@ dependencies: jest-get-type "^28.0.2" +"@jest/expect-utils@^29.0.3": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.0.3.tgz#f5bb86f5565bf2dacfca31ccbd887684936045b2" + integrity sha512-i1xUkau7K/63MpdwiRqaxgZOjxYs4f0WMTGJnYwUKubsNRZSeQbLorS7+I4uXVF9KQ5r61BUPAUMZ7Lf66l64Q== + dependencies: + jest-get-type "^29.0.0" + "@jest/fake-timers@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" @@ -1318,6 +1330,13 @@ dependencies: "@sinclair/typebox" "^0.24.1" +"@jest/schemas@^29.0.0": + version "29.0.0" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.0.0.tgz#5f47f5994dd4ef067fb7b4188ceac45f77fe952a" + integrity sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA== + dependencies: + "@sinclair/typebox" "^0.24.1" + "@jest/source-map@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" @@ -1423,6 +1442,18 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" +"@jest/types@^29.0.3": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.0.3.tgz#0be78fdddb1a35aeb2041074e55b860561c8ef63" + integrity sha512-coBJmOQvurXjN1Hh5PzF7cmsod0zLIOXpP8KD161mqNlroMhLcwpODiEzi7ZsRl5Z/AIuxpeNm8DCl43F4kz8A== + dependencies: + "@jest/schemas" "^29.0.0" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -1922,6 +1953,21 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/jest-dom@^5.16.5": + version "5.16.5" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz#3912846af19a29b2dbf32a6ae9c31ef52580074e" + integrity sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA== + dependencies: + "@adobe/css-tools" "^4.0.1" + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + "@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" @@ -2090,6 +2136,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@*": + version "29.0.3" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.0.3.tgz#b61a5ed100850686b8d3c5e28e3a1926b2001b59" + integrity sha512-F6ukyCTwbfsEX5F2YmVYmM5TcTHy1q9P5rWlRbrk56KyMh3v9xRGUO3aa8+SkvMi0SHXtASJv1283enXimC0Og== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/jest@^26.0.20": version "26.0.24" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.24.tgz#943d11976b16739185913a1936e0de0c4a7d595a" @@ -2259,6 +2313,13 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/testing-library__jest-dom@^5.9.1": + version "5.14.5" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.5.tgz#d113709c90b3c75fdb127ec338dad7d5f86c974f" + integrity sha512-SBwbxYoyPIvxHbeHxTZX2Pe/74F/tX2/D3mMvzabdeJ25bBojfW0TyB8BHrbq/9zaaKICJZjLP+8r6AeZMFCuQ== + dependencies: + "@types/jest" "*" + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -3154,6 +3215,14 @@ chalk@^2.0.0: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.1.0: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" @@ -3547,6 +3616,11 @@ css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + csscolorparser@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b" @@ -3815,6 +3889,11 @@ diff-sequences@^28.1.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6" integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== +diff-sequences@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.0.0.tgz#bae49972ef3933556bcb0800b72e8579d19d9e4f" + integrity sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA== + dijkstrajs@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" @@ -3846,7 +3925,7 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.9: +dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: version "0.5.14" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56" integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg== @@ -4524,6 +4603,17 @@ expect@^28.1.0: jest-message-util "^28.1.3" jest-util "^28.1.3" +expect@^29.0.0: + version "29.0.3" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.0.3.tgz#6be65ddb945202f143c4e07c083f4f39f3bd326f" + integrity sha512-t8l5DTws3212VbmPL+tBFXhjRHLmctHB0oQbL8eUc6S7NzZtYUhycrFO9mkxA0ZUC6FAWdNi7JchJSkODtcu1Q== + dependencies: + "@jest/expect-utils" "^29.0.3" + jest-get-type "^29.0.0" + jest-matcher-utils "^29.0.3" + jest-message-util "^29.0.3" + jest-util "^29.0.3" + ext@^1.1.2: version "1.6.0" resolved "https://registry.yarnpkg.com/ext/-/ext-1.6.0.tgz#3871d50641e874cc172e2b53f919842d19db4c52" @@ -5860,6 +5950,16 @@ jest-diff@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" +jest-diff@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.0.3.tgz#41cc02409ad1458ae1bf7684129a3da2856341ac" + integrity sha512-+X/AIF5G/vX9fWK+Db9bi9BQas7M9oBME7egU7psbn4jlszLFCu0dW63UgeE6cs/GANq4fLaT+8sGHQQ0eCUfg== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.0.0" + jest-get-type "^29.0.0" + pretty-format "^29.0.3" + jest-docblock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" @@ -5926,6 +6026,11 @@ jest-get-type@^28.0.2: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-28.0.2.tgz#34622e628e4fdcd793d46db8a242227901fcf203" integrity sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA== +jest-get-type@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.0.0.tgz#843f6c50a1b778f7325df1129a0fd7aa713aef80" + integrity sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw== + jest-haste-map@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" @@ -6018,6 +6123,16 @@ jest-matcher-utils@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" +jest-matcher-utils@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.0.3.tgz#b8305fd3f9e27cdbc210b21fc7dbba92d4e54560" + integrity sha512-RsR1+cZ6p1hDV4GSCQTg+9qjeotQCgkaleIKLK7dm+U4V/H2bWedU3RAtLm8+mANzZ7eDV33dMar4pejd7047w== + dependencies: + chalk "^4.0.0" + jest-diff "^29.0.3" + jest-get-type "^29.0.0" + pretty-format "^29.0.3" + jest-message-util@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-27.5.1.tgz#bdda72806da10d9ed6425e12afff38cd1458b6cf" @@ -6048,6 +6163,21 @@ jest-message-util@^28.1.3: slash "^3.0.0" stack-utils "^2.0.3" +jest-message-util@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.0.3.tgz#f0254e1ffad21890c78355726202cc91d0a40ea8" + integrity sha512-7T8JiUTtDfppojosORAflABfLsLKMLkBHSWkjNQrjIltGoDzNGn7wEPOSfjqYAGTYME65esQzMJxGDjuLBKdOg== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.0.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.0.3" + slash "^3.0.0" + stack-utils "^2.0.3" + jest-mock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" @@ -6243,6 +6373,18 @@ jest-util@^28.1.3: graceful-fs "^4.2.9" picomatch "^2.2.3" +jest-util@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.0.3.tgz#06d1d77f9a1bea380f121897d78695902959fbc0" + integrity sha512-Q0xaG3YRG8QiTC4R6fHjHQPaPpz9pJBEi0AeOE4mQh/FuWOijFjGXMMOfQEaU9i3z76cNR7FobZZUQnL6IyfdQ== + dependencies: + "@jest/types" "^29.0.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" @@ -6636,7 +6778,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== -lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -7579,6 +7721,15 @@ pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.0.0, pretty-format@^29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.0.3.tgz#23d5f8cabc9cbf209a77d49409d093d61166a811" + integrity sha512-cHudsvQr1K5vNVLbvYF/nv3Qy/F/BcEKxGuIeMiVMRHxPOO1RxXooP8g/ZrwAp7Dx+KdMZoOc7NxLHhMrP2f9Q== + dependencies: + "@jest/schemas" "^29.0.0" + ansi-styles "^5.0.0" + react-is "^18.0.0" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"