New group call experience: Room header call buttons (#9311)

* Make useEventEmitterState more efficient

By not invoking the initializing function on every render

* Make useWidgets more efficient

By not calling WidgetStore on every render

* Add new group call experience Labs flag

* Add viewingCall field to RoomViewStore state

Currently has no effect, but in the future this will signal to RoomView to show the call or call lobby.

* Add element_call.use_exclusively config flag

As documented in element-web, this will tell the app to use Element Call exclusively for calls, disabling Jitsi and legacy 1:1 calls.

* Make placeCall return a promise

So that the UI can know when placeCall completes

* Update start call buttons to new group call designs

Since RoomView doesn't do anything with viewingCall yet, these buttons won't have any effect when starting native group calls, but the logic is at least all there and ready to be hooked up.

* Allow calls to be detected if the new group call experience is enabled

* Test the RoomHeader changes

* Iterate code
This commit is contained in:
Robin 2022-09-25 10:57:25 -04:00 committed by GitHub
parent 12e3ba8e5a
commit d077ea1990
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1005 additions and 123 deletions

View file

@ -138,6 +138,7 @@
"@percy/cypress": "^3.1.1", "@percy/cypress": "^3.1.1",
"@sentry/types": "^6.10.0", "@sentry/types": "^6.10.0",
"@sinonjs/fake-timers": "^9.1.2", "@sinonjs/fake-timers": "^9.1.2",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.5", "@testing-library/react": "^12.1.5",
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
"@types/commonmark": "^0.27.4", "@types/commonmark": "^0.27.4",

View file

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

View file

@ -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<void> {
// We might be using managed hybrid widgets // We might be using managed hybrid widgets
if (isManagedHybridWidgetEnabled()) { if (isManagedHybridWidgetEnabled()) {
addManagedHybridWidget(roomId); await addManagedHybridWidget(roomId);
return; return;
} }
@ -870,9 +870,9 @@ export default class LegacyCallHandler extends EventEmitter {
} else if (members.length === 2) { } else if (members.length === 2) {
logger.info(`Place ${type} call in ${roomId}`); logger.info(`Place ${type} call in ${roomId}`);
this.placeMatrixCall(roomId, type, transferee); await this.placeMatrixCall(roomId, type, transferee);
} else { // > 2 } else { // > 2
this.placeJitsiCall(roomId, type); await this.placeJitsiCall(roomId, type);
} }
} }

View file

@ -32,6 +32,7 @@ export const DEFAULTS: IConfigOptions = {
}, },
element_call: { element_call: {
url: "https://call.element.io", url: "https://call.element.io",
use_exclusively: false,
}, },
// @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

@ -30,7 +30,7 @@ import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
import { EventType } from 'matrix-js-sdk/src/@types/event'; import { EventType } from 'matrix-js-sdk/src/@types/event';
import { RoomState, RoomStateEvent } from 'matrix-js-sdk/src/models/room-state'; import { RoomState, RoomStateEvent } from 'matrix-js-sdk/src/models/room-state';
import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; 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 { throttle } from "lodash";
import { MatrixError } from 'matrix-js-sdk/src/http-api'; import { MatrixError } from 'matrix-js-sdk/src/http-api';
import { ClientEvent } from "matrix-js-sdk/src/client"; import { ClientEvent } from "matrix-js-sdk/src/client";
@ -149,7 +149,7 @@ interface IRoomProps extends MatrixClientProps {
enum MainSplitContentType { enum MainSplitContentType {
Timeline, Timeline,
MaximisedWidget, MaximisedWidget,
Video, // immersive voip Call,
} }
export interface IRoomState { export interface IRoomState {
room?: Room; room?: Room;
@ -299,7 +299,6 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
e2eStatus={E2EStatus.Normal} e2eStatus={E2EStatus.Normal}
onAppsClick={null} onAppsClick={null}
appsShown={false} appsShown={false}
onCallPlaced={null}
excludedRightPanelPhaseButtons={[]} excludedRightPanelPhaseButtons={[]}
showButtons={false} showButtons={false}
enableRoomOptionsMenu={false} enableRoomOptionsMenu={false}
@ -350,7 +349,6 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement
e2eStatus={E2EStatus.Normal} e2eStatus={E2EStatus.Normal}
onAppsClick={null} onAppsClick={null}
appsShown={false} appsShown={false}
onCallPlaced={null}
excludedRightPanelPhaseButtons={[]} excludedRightPanelPhaseButtons={[]}
showButtons={false} showButtons={false}
enableRoomOptionsMenu={false} enableRoomOptionsMenu={false}
@ -517,7 +515,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private getMainSplitContentType = (room: Room) => { private getMainSplitContentType = (room: Room) => {
if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) { if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) {
return MainSplitContentType.Video; return MainSplitContentType.Call;
} }
if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) { if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
return MainSplitContentType.MaximisedWidget; return MainSplitContentType.MaximisedWidget;
@ -1660,10 +1658,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return ret; return ret;
} }
private onCallPlaced = (type: CallType): void => {
LegacyCallHandler.instance.placeCall(this.state.room?.roomId, type);
};
private onAppsClick = () => { private onAppsClick = () => {
dis.dispatch({ dis.dispatch({
action: "appsDrawer", action: "appsDrawer",
@ -2330,7 +2324,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const mainClasses = classNames("mx_RoomView", { const mainClasses = classNames("mx_RoomView", {
mx_RoomView_inCall: Boolean(activeCall), 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'); const showChatEffects = SettingsStore.getValue('showChatEffects');
@ -2371,7 +2365,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
{ previewBar } { previewBar }
</>; </>;
break; break;
case MainSplitContentType.Video: { case MainSplitContentType.Call: {
mainSplitContentClassName = "mx_MainSplit_video"; mainSplitContentClassName = "mx_MainSplit_video";
mainSplitBody = <> mainSplitBody = <>
<VideoRoomView room={this.state.room} resizing={this.state.resizing} /> <VideoRoomView room={this.state.room} resizing={this.state.resizing} />
@ -2382,7 +2376,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName); const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName);
let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline]; let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline];
let onCallPlaced = this.onCallPlaced;
let onAppsClick = this.onAppsClick; let onAppsClick = this.onAppsClick;
let onForgetClick = this.onForgetClick; let onForgetClick = this.onForgetClick;
let onSearchClick = this.onSearchClick; let onSearchClick = this.onSearchClick;
@ -2399,13 +2392,12 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
onForgetClick = null; onForgetClick = null;
onSearchClick = null; onSearchClick = null;
break; break;
case MainSplitContentType.Video: case MainSplitContentType.Call:
excludedRightPanelPhaseButtons = [ excludedRightPanelPhaseButtons = [
RightPanelPhases.ThreadPanel, RightPanelPhases.ThreadPanel,
RightPanelPhases.PinnedMessages, RightPanelPhases.PinnedMessages,
RightPanelPhases.NotificationPanel, RightPanelPhases.NotificationPanel,
]; ];
onCallPlaced = null;
onAppsClick = null; onAppsClick = null;
onForgetClick = null; onForgetClick = null;
onSearchClick = null; onSearchClick = null;
@ -2432,7 +2424,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
e2eStatus={this.state.e2eStatus} e2eStatus={this.state.e2eStatus}
onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null} onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null}
appsShown={this.state.showApps} appsShown={this.state.showApps}
onCallPlaced={onCallPlaced}
excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons}
showButtons={!this.viewsLocalRoom} showButtons={!this.viewsLocalRoom}
enableRoomOptionsMenu={!this.viewsLocalRoom} enableRoomOptionsMenu={!this.viewsLocalRoom}

View file

@ -76,7 +76,7 @@ const Button: React.FC<IButtonProps> = ({ children, className, onClick }) => {
}; };
export const useWidgets = (room: Room) => { export const useWidgets = (room: Room) => {
const [apps, setApps] = useState<IApp[]>(WidgetStore.instance.getApps(room.roomId)); const [apps, setApps] = useState<IApp[]>(() => WidgetStore.instance.getApps(room.roomId));
const updateApps = useCallback(() => { const updateApps = useCallback(() => {
// Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings // Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings

View file

@ -15,12 +15,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { FC, useState, useMemo, useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { throttle } from 'lodash'; 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 { 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 { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
@ -30,13 +32,14 @@ import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons';
import E2EIcon from './E2EIcon'; import E2EIcon from './E2EIcon';
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import RoomTopic from "../elements/RoomTopic"; import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import { E2EStatus } from '../../../utils/ShieldUtils'; import { E2EStatus } from '../../../utils/ShieldUtils';
import { IOOBData } from '../../../stores/ThreepidInviteStore'; import { IOOBData } from '../../../stores/ThreepidInviteStore';
import { SearchScope } from './SearchBar'; import { SearchScope } from './SearchBar';
import { ContextMenuTooltipButton } from '../../structures/ContextMenu'; import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import RoomContextMenu from "../context_menus/RoomContextMenu"; import RoomContextMenu from "../context_menus/RoomContextMenu";
import { contextMenuBelow } from './RoomTile'; import { contextMenuBelow } from './RoomTile';
import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore'; import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore';
@ -48,6 +51,272 @@ import { BetaPill } from "../beta/BetaCard";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; 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<VoiceCallButtonProps> = ({ 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 <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
onClick={onClick}
title={_t("Voice call")}
tooltip={tooltip ?? _t("Voice call")}
disabled={disabled || busy}
/>;
};
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<VideoCallButtonProps> = ({ 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<ViewRoomPayload>({
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 = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption label={_t("Video call (Jitsi)")} onClick={onJitsiClick} />
<IconizedContextMenuOption label={_t("Video call (Element Call)")} onClick={onElementClick} />
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return <>
<AccessibleTooltipButton
inputRef={buttonRef}
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={onClick}
title={_t("Video call")}
tooltip={tooltip ?? _t("Video call")}
disabled={disabled || busy}
/>
{ 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<CallButtonsProps> = ({ room }) => {
const [busy, setBusy] = useState(false);
const showButtons = useSettingValue<boolean>("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 =>
<VoiceCallButton room={room} busy={busy} setBusy={setBusy} behavior={behavior} />;
const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element =>
<VideoCallButton room={room} busy={busy} setBusy={setBusy} behavior={behavior} />;
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 { export interface ISearchInfo {
searchTerm: string; searchTerm: string;
@ -55,15 +324,14 @@ export interface ISearchInfo {
searchCount: number; searchCount: number;
} }
interface IProps { export interface IProps {
room: Room; room: Room;
oobData?: IOOBData; oobData?: IOOBData;
inRoom: boolean; inRoom: boolean;
onSearchClick: () => void; onSearchClick: (() => void) | null;
onInviteClick: () => void; onInviteClick: (() => void) | null;
onForgetClick: () => void; onForgetClick: (() => void) | null;
onCallPlaced: (type: CallType) => void; onAppsClick: (() => void) | null;
onAppsClick: () => void;
e2eStatus: E2EStatus; e2eStatus: E2EStatus;
appsShown: boolean; appsShown: boolean;
searchInfo: ISearchInfo; searchInfo: ISearchInfo;
@ -89,7 +357,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
static contextType = RoomContext; static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>; public context!: React.ContextType<typeof RoomContext>;
constructor(props, context) { constructor(props: IProps, context: IState) {
super(props, context); super(props, context);
const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room);
notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate); notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate);
@ -141,30 +409,14 @@ export default class RoomHeader extends React.Component<IProps, IState> {
}; };
private onContextMenuCloseClick = () => { private onContextMenuCloseClick = () => {
this.setState({ contextMenuPosition: null }); this.setState({ contextMenuPosition: undefined });
}; };
private renderButtons(): JSX.Element[] { private renderButtons(): JSX.Element[] {
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
if (this.props.inRoom && if (this.props.inRoom && !this.context.tombstone) {
this.props.onCallPlaced && buttons.push(<CallButtons key="calls" room={this.props.room} />);
!this.context.tombstone &&
SettingsStore.getValue("showCallButtonsInComposer")
) {
const voiceCallButton = <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
onClick={() => this.props.onCallPlaced(CallType.Voice)}
title={_t("Voice call")}
key="voice"
/>;
const videoCallButton = <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={() => this.props.onCallPlaced(CallType.Video)}
title={_t("Video call")}
key="video"
/>;
buttons.push(voiceCallButton, videoCallButton);
} }
if (this.props.onForgetClick) { if (this.props.onForgetClick) {
@ -212,8 +464,8 @@ export default class RoomHeader extends React.Component<IProps, IState> {
return buttons; return buttons;
} }
private renderName(oobName) { private renderName(oobName: string) {
let contextMenu: JSX.Element; let contextMenu: JSX.Element | null = null;
if (this.state.contextMenuPosition && this.props.room) { if (this.state.contextMenuPosition && this.props.room) {
contextMenu = ( contextMenu = (
<RoomContextMenu <RoomContextMenu
@ -267,7 +519,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
} }
public render() { public render() {
let searchStatus = null; let searchStatus: JSX.Element | null = null;
// don't display the search count until the search completes and // don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount. // gives us a valid (possibly zero) searchCount.
@ -291,7 +543,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
className="mx_RoomHeader_topic" className="mx_RoomHeader_topic"
/>; />;
let roomAvatar; let roomAvatar: JSX.Element | null = null;
if (this.props.room) { if (this.props.room) {
roomAvatar = <DecoratedRoomAvatar roomAvatar = <DecoratedRoomAvatar
room={this.props.room} room={this.props.room}
@ -301,7 +553,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
/>; />;
} }
let buttons; let buttons: JSX.Element | null = null;
if (this.props.showButtons) { if (this.props.showButtons) {
buttons = <React.Fragment> buttons = <React.Fragment>
<div className="mx_RoomHeader_buttons"> <div className="mx_RoomHeader_buttons">

View file

@ -47,6 +47,7 @@ export interface ViewRoomPayload extends Pick<ActionPayload, "action"> {
forceTimeline?: boolean; // Whether to override default behaviour to end up at a timeline 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 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 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 deferred_action?: ActionPayload; // Action to fire after MatrixChat handles this ViewRoom action

View file

@ -87,7 +87,7 @@ export function useEventEmitterState<T>(
eventName: string | symbol, eventName: string | symbol,
fn: Mapper<T>, fn: Mapper<T>,
): T { ): T {
const [value, setValue] = useState<T>(fn()); const [value, setValue] = useState<T>(fn);
const handler = useCallback((...args: any[]) => { const handler = useCallback((...args: any[]) => {
setValue(fn(...args)); setValue(fn(...args));
}, [fn]); }, [fn]);

View file

@ -865,6 +865,7 @@
"Spaces": "Spaces", "Spaces": "Spaces",
"Widgets": "Widgets", "Widgets": "Widgets",
"Rooms": "Rooms", "Rooms": "Rooms",
"Voice & Video": "Voice & Video",
"Moderation": "Moderation", "Moderation": "Moderation",
"Analytics": "Analytics", "Analytics": "Analytics",
"Message Previews": "Message Previews", "Message Previews": "Message Previews",
@ -910,6 +911,7 @@
"Send read receipts": "Send read receipts", "Send read receipts": "Send read receipts",
"Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)", "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", "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)", "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)", "Favourite Messages (under active development)": "Favourite Messages (under active development)",
"Voice broadcast (under active development)": "Voice broadcast (under active development)", "Voice broadcast (under active development)": "Voice broadcast (under active development)",
@ -1591,7 +1593,6 @@
"No Microphones detected": "No Microphones detected", "No Microphones detected": "No Microphones detected",
"Camera": "Camera", "Camera": "Camera",
"No Webcams detected": "No Webcams detected", "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", "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers",
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> 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.": "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> 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.", "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> 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.": "<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members to the new version of the room.</i> 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", "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", "Room %(name)s": "Room %(name)s",
"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 (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", "Forget room": "Forget room",
"Hide Widgets": "Hide Widgets", "Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets", "Show Widgets": "Show Widgets",

View file

@ -635,11 +635,13 @@ export class ElementCall extends Call {
} }
public static get(room: Room): ElementCall | null { 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 ( if (
SettingsStore.getValue("feature_video_rooms") SettingsStore.getValue("feature_group_calls") || (
&& SettingsStore.getValue("feature_element_call_video_rooms") SettingsStore.getValue("feature_video_rooms")
&& room.isCallRoom() && SettingsStore.getValue("feature_element_call_video_rooms")
&& room.isCallRoom()
)
) { ) {
const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType => const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType =>
room.currentState.getStateEvents(eventType), room.currentState.getStateEvents(eventType),

View file

@ -92,6 +92,7 @@ export enum LabGroup {
Spaces, Spaces,
Widgets, Widgets,
Rooms, Rooms,
VoiceAndVideo,
Moderation, Moderation,
Analytics, Analytics,
MessagePreviews, MessagePreviews,
@ -111,6 +112,7 @@ export const labGroupNames: Record<LabGroup, string> = {
[LabGroup.Spaces]: _td("Spaces"), [LabGroup.Spaces]: _td("Spaces"),
[LabGroup.Widgets]: _td("Widgets"), [LabGroup.Widgets]: _td("Widgets"),
[LabGroup.Rooms]: _td("Rooms"), [LabGroup.Rooms]: _td("Rooms"),
[LabGroup.VoiceAndVideo]: _td("Voice & Video"),
[LabGroup.Moderation]: _td("Moderation"), [LabGroup.Moderation]: _td("Moderation"),
[LabGroup.Analytics]: _td("Analytics"), [LabGroup.Analytics]: _td("Analytics"),
[LabGroup.MessagePreviews]: _td("Message Previews"), [LabGroup.MessagePreviews]: _td("Message Previews"),
@ -191,7 +193,7 @@ export type ISetting = IBaseSetting | IFeature;
export const SETTINGS: {[setting: string]: ISetting} = { export const SETTINGS: {[setting: string]: ISetting} = {
"feature_video_rooms": { "feature_video_rooms": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Rooms, labsGroup: LabGroup.VoiceAndVideo,
displayName: _td("Video rooms"), displayName: _td("Video rooms"),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
@ -426,11 +428,18 @@ export const SETTINGS: {[setting: string]: ISetting} = {
"feature_element_call_video_rooms": { "feature_element_call_video_rooms": {
isFeature: true, isFeature: true,
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
labsGroup: LabGroup.Rooms, labsGroup: LabGroup.VoiceAndVideo,
displayName: _td("Element Call video rooms"), displayName: _td("Element Call video rooms"),
controller: new ReloadOnChangeController(), controller: new ReloadOnChangeController(),
default: false, 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": { "feature_location_share_live": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,

View file

@ -53,38 +53,75 @@ import { UPDATE_EVENT } from "./AsyncStore";
const NUM_JOIN_RETRY = 5; const NUM_JOIN_RETRY = 5;
const INITIAL_STATE = { interface State {
// Whether we're joining the currently viewed room (see isJoining()) /**
* 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, joining: false,
// Any error that has occurred during joining joinError: null,
joinError: null as Error, roomId: null,
// The room ID of the room currently being viewed subscribingRoomId: null,
roomId: null as string, initialEventId: null,
// The room ID being subscribed to (in Sliding Sync) initialEventPixelOffset: null,
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
isInitialEventHighlighted: false, isInitialEventHighlighted: false,
// whether to scroll `event_id` into view
initialEventScrollIntoView: true, initialEventScrollIntoView: true,
roomAlias: null,
// 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
roomLoading: false, roomLoading: false,
// Any error that has occurred during loading roomLoadError: null,
roomLoadError: null as MatrixError, replyingToEvent: null,
replyingToEvent: null as MatrixEvent,
shouldPeek: false, shouldPeek: false,
viaServers: [],
viaServers: [] as string[],
wasContextSwitch: false, wasContextSwitch: false,
viewingCall: false,
}; };
type Listener = (isActive: boolean) => void; type Listener = (isActive: boolean) => void;
@ -98,7 +135,7 @@ export class RoomViewStore extends EventEmitter {
// the app. We need to eagerly create the instance. // the app. We need to eagerly create the instance.
public static readonly instance = new RoomViewStore(defaultDispatcher); public static readonly instance = new RoomViewStore(defaultDispatcher);
private state = INITIAL_STATE; // initialize state private state: State = INITIAL_STATE; // initialize state
private dis: MatrixDispatcher; private dis: MatrixDispatcher;
private dispatchToken: string; private dispatchToken: string;
@ -120,7 +157,7 @@ export class RoomViewStore extends EventEmitter {
this.emit(roomId, isActive); this.emit(roomId, isActive);
} }
private setState(newState: Partial<typeof INITIAL_STATE>): void { private setState(newState: Partial<State>): void {
// If values haven't changed, there's nothing to do. // If values haven't changed, there's nothing to do.
// This only tries a shallow comparison, so unchanged objects will slip // This only tries a shallow comparison, so unchanged objects will slip
// through, but that's probably okay for now. // through, but that's probably okay for now.
@ -172,6 +209,7 @@ export class RoomViewStore extends EventEmitter {
roomAlias: null, roomAlias: null,
viaServers: [], viaServers: [],
wasContextSwitch: false, wasContextSwitch: false,
viewingCall: false,
}); });
break; break;
case Action.ViewRoomError: case Action.ViewRoomError:
@ -286,6 +324,7 @@ export class RoomViewStore extends EventEmitter {
roomLoadError: null, roomLoadError: null,
viaServers: payload.via_servers, viaServers: payload.via_servers,
wasContextSwitch: payload.context_switch, 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 // 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. // 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; return;
} }
const newState = { const newState: Partial<State> = {
roomId: payload.room_id, roomId: payload.room_id,
roomAlias: payload.room_alias, roomAlias: payload.room_alias ?? null,
initialEventId: payload.event_id, initialEventId: payload.event_id ?? null,
isInitialEventHighlighted: payload.highlighted, isInitialEventHighlighted: payload.highlighted ?? false,
initialEventScrollIntoView: payload.scroll_into_view ?? true, initialEventScrollIntoView: payload.scroll_into_view ?? true,
roomLoading: false, roomLoading: false,
roomLoadError: null, roomLoadError: null,
@ -317,8 +356,12 @@ export class RoomViewStore extends EventEmitter {
joining: payload.joining || false, joining: payload.joining || false,
// Reset replyingToEvent because we don't want cross-room because bad UX // Reset replyingToEvent because we don't want cross-room because bad UX
replyingToEvent: null, replyingToEvent: null,
viaServers: payload.via_servers, viaServers: payload.via_servers ?? [],
wasContextSwitch: payload.context_switch, 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 // 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, roomId: null,
initialEventId: null, initialEventId: null,
initialEventPixelOffset: null, initialEventPixelOffset: null,
isInitialEventHighlighted: null, isInitialEventHighlighted: false,
initialEventScrollIntoView: true, initialEventScrollIntoView: true,
roomAlias: payload.room_alias, roomAlias: payload.room_alias,
roomLoading: true, roomLoading: true,
roomLoadError: null, roomLoadError: null,
viaServers: payload.via_servers, viaServers: payload.via_servers,
wasContextSwitch: payload.context_switch, wasContextSwitch: payload.context_switch,
viewingCall: payload.view_call ?? false,
}); });
try { try {
const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias);
@ -577,4 +621,8 @@ export class RoomViewStore extends EventEmitter {
public getWasContextSwitch(): boolean { public getWasContextSwitch(): boolean {
return this.state.wasContextSwitch; return this.state.wasContextSwitch;
} }
public isViewingCall(): boolean {
return this.state.viewingCall;
}
} }

View file

@ -111,7 +111,7 @@ class WidgetEchoStore extends EventEmitter {
} }
} }
let singletonWidgetEchoStore = null; let singletonWidgetEchoStore: WidgetEchoStore | null = null;
if (!singletonWidgetEchoStore) { if (!singletonWidgetEchoStore) {
singletonWidgetEchoStore = new WidgetEchoStore(); singletonWidgetEchoStore = new WidgetEchoStore();
} }

View file

@ -17,23 +17,48 @@ limitations under the License.
import React from 'react'; import React from 'react';
// eslint-disable-next-line deprecate/import // eslint-disable-next-line deprecate/import
import { mount, ReactWrapper } from 'enzyme'; 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 { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
import DMRoomMap from '../../../../src/utils/DMRoomMap'; 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 { SearchScope } from '../../../../src/components/views/rooms/SearchBar';
import { E2EStatus } from '../../../../src/utils/ShieldUtils'; import { E2EStatus } from '../../../../src/utils/ShieldUtils';
import { mkEvent } from '../../../test-utils'; import { mkEvent } from '../../../test-utils';
import { IRoomState } from "../../../../src/components/structures/RoomView"; import { IRoomState } from "../../../../src/components/structures/RoomView";
import RoomContext from '../../../../src/contexts/RoomContext'; 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', () => { it('shows the room avatar in a room with only ourselves', () => {
// When we render a non-DM room with 1 person in it // When we render a non-DM room with 1 person in it
const room = createRoom({ name: "X Room", isDm: false, userIds: [] }); 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 // Then the room's avatar is the initial of its name
const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); 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 // When we render a non-DM room with 2 people in it
const room = createRoom( const room = createRoom(
{ name: "Y Room", isDm: false, userIds: ["other"] }); { 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 // Then the room's avatar is the initial of its name
const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); const initial = findSpan(rendered, ".mx_BaseAvatar_initial");
@ -62,7 +87,7 @@ describe('RoomHeader', () => {
it('shows the room avatar in a room with >2 people', () => { it('shows the room avatar in a room with >2 people', () => {
// When we render a non-DM room with 3 people in it // When we render a non-DM room with 3 people in it
const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] }); 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 // Then the room's avatar is the initial of its name
const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); const initial = findSpan(rendered, ".mx_BaseAvatar_initial");
@ -76,7 +101,7 @@ describe('RoomHeader', () => {
it('shows the room avatar in a DM with only ourselves', () => { it('shows the room avatar in a DM with only ourselves', () => {
// When we render a non-DM room with 1 person in it // When we render a non-DM room with 1 person in it
const room = createRoom({ name: "Z Room", isDm: true, userIds: [] }); 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 // Then the room's avatar is the initial of its name
const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); 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 // When we render a DM room with only 2 people in it
const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] }); 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 // Then we use the other user's avatar as our room's image avatar
const image = findImg(rendered, ".mx_BaseAvatar_image"); const image = findImg(rendered, ".mx_BaseAvatar_image");
@ -106,8 +131,9 @@ describe('RoomHeader', () => {
it('shows the room avatar in a DM with >2 people', () => { it('shows the room avatar in a DM with >2 people', () => {
// When we render a DM room with 3 people in it // When we render a DM room with 3 people in it
const room = createRoom({ const room = createRoom({
name: "Z Room", isDm: true, userIds: ["other1", "other2"] }); name: "Z Room", isDm: true, userIds: ["other1", "other2"],
const rendered = render(room); });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name // Then the room's avatar is the initial of its name
const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); const initial = findSpan(rendered, ".mx_BaseAvatar_initial");
@ -119,8 +145,8 @@ describe('RoomHeader', () => {
}); });
it("renders call buttons normally", () => { it("renders call buttons normally", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] });
const wrapper = render(room); const wrapper = mountHeader(room);
expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(1); expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(1);
expect(wrapper.find('[aria-label="Video 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", () => { it("hides call buttons when the room is tombstoned", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = render(room, {}, { const wrapper = mountHeader(room, {}, {
tombstone: mkEvent({ tombstone: mkEvent({
event: true, event: true,
type: "m.room.tombstone", type: "m.room.tombstone",
@ -146,25 +172,25 @@ describe('RoomHeader', () => {
it("should render buttons if not passing showButtons (default true)", () => { it("should render buttons if not passing showButtons (default true)", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] }); const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = render(room); const wrapper = mountHeader(room);
expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(1); expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(1);
}); });
it("should not render buttons if passing showButtons = false", () => { it("should not render buttons if passing showButtons = false", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] }); 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); expect(wrapper.find(".mx_RoomHeader_buttons")).toHaveLength(0);
}); });
it("should render the room options context menu if not passing enableRoomOptionsMenu (default true)", () => { it("should render the room options context menu if not passing enableRoomOptionsMenu (default true)", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] }); 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); expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(1);
}); });
it("should not render the room options context menu if passing enableRoomOptionsMenu = false", () => { it("should not render the room options context menu if passing enableRoomOptionsMenu = false", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] }); 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); expect(wrapper.find(".mx_RoomHeader_name.mx_AccessibleButton")).toHaveLength(0);
}); });
}); });
@ -176,7 +202,7 @@ interface IRoomCreationInfo {
} }
function createRoom(info: IRoomCreationInfo) { function createRoom(info: IRoomCreationInfo) {
TestUtils.stubClient(); stubClient();
const client: MatrixClient = MatrixClientPeg.get(); const client: MatrixClient = MatrixClientPeg.get();
const roomId = '!1234567890:domain'; const roomId = '!1234567890:domain';
@ -210,15 +236,15 @@ function createRoom(info: IRoomCreationInfo) {
return room; return room;
} }
function render(room: Room, propsOverride = {}, roomContext?: Partial<IRoomState>): ReactWrapper { function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial<IRoomState>): ReactWrapper {
const props = { const props = {
room, room,
inRoom: true, inRoom: true,
onSearchClick: () => {}, onSearchClick: () => { },
onInviteClick: null, onInviteClick: null,
onForgetClick: () => {}, onForgetClick: () => { },
onCallPlaced: (_type) => { }, onCallPlaced: (_type) => { },
onAppsClick: () => {}, onAppsClick: () => { },
e2eStatus: E2EStatus.Normal, e2eStatus: E2EStatus.Normal,
appsShown: true, appsShown: true,
searchInfo: { searchInfo: {
@ -307,3 +333,395 @@ function findImg(wrapper: ReactWrapper, selector: string): ReactWrapper {
expect(els).toHaveLength(1); expect(els).toHaveLength(1);
return els.at(0); return els.at(0);
} }
describe("RoomHeader (React Testing Library)", () => {
let client: Mocked<MatrixClient>;
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<RoomHeaderProps> = {}, roomContext: Partial<IRoomState> = {}) => {
render(
<RoomContext.Provider value={{ ...roomContext, room } as IRoomState}>
<RoomHeader
room={room}
inRoom={true}
onSearchClick={() => { }}
onInviteClick={null}
onForgetClick={() => { }}
onAppsClick={() => { }}
e2eStatus={E2EStatus.Normal}
appsShown={true}
searchInfo={{
searchTerm: "",
searchScope: SearchScope.Room,
searchCount: 0,
}}
{...props}
/>
</RoomContext.Provider>,
);
};
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");
});
});

155
yarn.lock
View file

@ -27,6 +27,11 @@
dependencies: dependencies:
tunnel "^0.0.6" 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": "@ampproject/remapping@^2.1.0":
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d"
@ -1259,6 +1264,13 @@
dependencies: dependencies:
jest-get-type "^28.0.2" 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": "@jest/fake-timers@^27.5.1":
version "27.5.1" version "27.5.1"
resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74"
@ -1318,6 +1330,13 @@
dependencies: dependencies:
"@sinclair/typebox" "^0.24.1" "@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": "@jest/source-map@^27.5.1":
version "27.5.1" version "27.5.1"
resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf"
@ -1423,6 +1442,18 @@
"@types/yargs" "^17.0.8" "@types/yargs" "^17.0.8"
chalk "^4.0.0" 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": "@jridgewell/gen-mapping@^0.1.0":
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996"
@ -1922,6 +1953,21 @@
lz-string "^1.4.4" lz-string "^1.4.4"
pretty-format "^27.0.2" 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": "@testing-library/react@^12.1.5":
version "12.1.5" version "12.1.5"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b"
@ -2090,6 +2136,14 @@
dependencies: dependencies:
"@types/istanbul-lib-report" "*" "@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": "@types/jest@^26.0.20":
version "26.0.24" version "26.0.24"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.24.tgz#943d11976b16739185913a1936e0de0c4a7d595a" 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" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== 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@*": "@types/yargs-parser@*":
version "21.0.0" version "21.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" 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" escape-string-regexp "^1.0.5"
supports-color "^5.3.0" 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: chalk@^4.0.0, chalk@^4.1.0:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" 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" resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== 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: csscolorparser@~1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b" 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" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6"
integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== 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: dijkstrajs@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257" resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.2.tgz#2e48c0d3b825462afe75ab4ad5e829c8ece36257"
@ -3846,7 +3925,7 @@ doctrine@^3.0.0:
dependencies: dependencies:
esutils "^2.0.2" 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" version "0.5.14"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56"
integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg== integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==
@ -4524,6 +4603,17 @@ expect@^28.1.0:
jest-message-util "^28.1.3" jest-message-util "^28.1.3"
jest-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: ext@^1.1.2:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/ext/-/ext-1.6.0.tgz#3871d50641e874cc172e2b53f919842d19db4c52" 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" jest-get-type "^28.0.2"
pretty-format "^28.1.3" 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: jest-docblock@^27.5.1:
version "27.5.1" version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" 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" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-28.0.2.tgz#34622e628e4fdcd793d46db8a242227901fcf203"
integrity sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA== 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: jest-haste-map@^26.6.2:
version "26.6.2" version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" 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" jest-get-type "^28.0.2"
pretty-format "^28.1.3" 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: jest-message-util@^27.5.1:
version "27.5.1" version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-27.5.1.tgz#bdda72806da10d9ed6425e12afff38cd1458b6cf" 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" slash "^3.0.0"
stack-utils "^2.0.3" 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: jest-mock@^27.5.1:
version "27.5.1" version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" 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" graceful-fs "^4.2.9"
picomatch "^2.2.3" 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: jest-validate@^27.5.1:
version "27.5.1" version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" 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" resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== 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" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -7579,6 +7721,15 @@ pretty-format@^28.1.3:
ansi-styles "^5.0.0" ansi-styles "^5.0.0"
react-is "^18.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: process-nextick-args@~2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"