Merge pull request #24 from daily-demos/dailyjs/cloud-recording

Recording
This commit is contained in:
Jon Taylor 2021-07-15 11:40:42 +01:00 committed by GitHub
commit c716c455a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 651 additions and 27 deletions

View File

@ -0,0 +1,4 @@
{
"presets": ["next/babel"],
"plugins": ["inline-react-svg"]
}

View File

@ -0,0 +1,40 @@
# Recording
![Recording](./image.png)
### Live example
**[See it in action here ➡️](https://dailyjs-recording.vercel.app)**
---
## What does this demo do?
- Use [startRecording](https://docs.daily.co/reference#%EF%B8%8F-startrecording) to create a video and audio recording of your call. You can read more about Daily call recording (and the different modes and types) [here](https://docs.daily.co/reference#recordings)
- Supports both `cloud` and `local` recording modes (specified when creating the room or managed using the Daily dashboard)
- Coming soon: support different recording layouts / composites
- Coming soon: use the Daily REST API to retrieve a list of cloud recordings for the currently active room
**To turn on recording, you need to be on the Scale plan. There is also a per minute recording fee for cloud recording.**
Please note: this demo is not currently mobile optimised
### Getting started
```
# set both DAILY_API_KEY and DAILY_DOMAIN
mv env.example .env.local
yarn
yarn workspace @dailyjs/recording dev
```
### How does this demo work?
This example introduces a new [RecordingProvider](./contexts/RecordingProvider.js) context that listens for the various [recording events](https://docs.daily.co/reference#recording-started), counts down to begin a recording and stops a currently active recording. We also introduce a new recording modal and tray button.
Remember to follow the best practises detailed in [the documentation](https://docs.daily.co/reference#recordings) to avoid lengthy or stuck recordings.
## Deploy your own on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/daily-co/clone-flow?repository-url=https%3A%2F%2Fgithub.com%2Fdaily-demos%2Fexamples.git&env=DAILY_DOMAIN%2CDAILY_API_KEY&envDescription=Your%20Daily%20domain%20and%20API%20key%20can%20be%20found%20on%20your%20account%20dashboard&envLink=https%3A%2F%2Fdashboard.daily.co&project-name=daily-examples&repo-name=daily-examples)

View File

@ -0,0 +1,13 @@
import React from 'react';
import App from '@dailyjs/basic-call/components/App';
import { RecordingProvider } from '../../contexts/RecordingProvider';
// Extend our basic call app component with the recording context
export const AppWithRecording = () => (
<RecordingProvider>
<App />
</RecordingProvider>
);
export default AppWithRecording;

View File

@ -0,0 +1 @@
export { AppWithRecording as default } from './App';

View File

@ -0,0 +1,124 @@
import React, { useEffect } from 'react';
import { Button } from '@dailyjs/shared/components/Button';
import { CardBody } from '@dailyjs/shared/components/Card';
import Modal from '@dailyjs/shared/components/Modal';
import Well from '@dailyjs/shared/components/Well';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { enable } from 'debug';
import {
RECORDING_COUNTDOWN_1,
RECORDING_COUNTDOWN_2,
RECORDING_COUNTDOWN_3,
RECORDING_ERROR,
RECORDING_IDLE,
RECORDING_RECORDING,
RECORDING_SAVED,
RECORDING_TYPE_CLOUD,
RECORDING_UPLOADING,
useRecording,
} from '../../contexts/RecordingProvider';
export const RECORDING_MODAL = 'recording';
export const RecordingModal = () => {
const { currentModals, closeModal } = useUIState();
const { enableRecording } = useCallState();
const {
recordingStartedDate,
recordingState,
startRecordingWithCountdown,
stopRecording,
} = useRecording();
useEffect(() => {
if (recordingState === RECORDING_RECORDING) {
closeModal(RECORDING_MODAL);
}
}, [recordingState, closeModal]);
const disabled =
enableRecording &&
[RECORDING_IDLE, RECORDING_RECORDING].includes(recordingState);
function renderButtonLabel() {
if (!enableRecording) {
return 'Recording disabled';
}
switch (recordingState) {
case RECORDING_COUNTDOWN_3:
return '3...';
case RECORDING_COUNTDOWN_2:
return '2...';
case RECORDING_COUNTDOWN_1:
return '1...';
case RECORDING_RECORDING:
return 'Stop recording';
case RECORDING_UPLOADING:
case RECORDING_SAVED:
return 'Stopping recording...';
default:
return 'Start recording';
}
}
function handleRecordingClick() {
if (recordingState === RECORDING_IDLE) {
startRecordingWithCountdown();
} else {
stopRecording();
}
}
return (
<Modal
title="Recording"
isOpen={currentModals[RECORDING_MODAL]}
onClose={() => closeModal(RECORDING_MODAL)}
actions={[
<Button fullWidth variant="outline">
Close
</Button>,
<Button
fullWidth
disabled={!disabled}
onClick={() => handleRecordingClick()}
>
{renderButtonLabel()}
</Button>,
]}
>
<CardBody>
{!enableRecording ? (
<Well variant="error">
Recording is not enabled for this room (or your browser does not
support it.) Please enabled recording when creating the room or via
the Daily dashboard.
</Well>
) : (
<p>
Recording type enabled: <strong>{enableRecording}</strong>
</p>
)}
{recordingStartedDate && (
<p>Recording started: {recordingStartedDate.toString()}</p>
)}
{enableRecording === RECORDING_TYPE_CLOUD && (
<>
<hr />
<p>
Cloud recordings can be accessed via the Daily dashboard under the
&quot;Recordings&quot; section.
</p>
</>
)}
</CardBody>
</Modal>
);
};
export default RecordingModal;

View File

@ -0,0 +1,3 @@
export { RecordingModal as default } from './RecordingModal';
export { RecordingModal } from './RecordingModal';
export { RECORDING_MODAL } from './RecordingModal';

View File

@ -0,0 +1,47 @@
import React, { useEffect } from 'react';
import { TrayButton } from '@dailyjs/shared/components/Tray';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import { ReactComponent as IconRecord } from '@dailyjs/shared/icons/record-md.svg';
import {
RECORDING_ERROR,
RECORDING_RECORDING,
RECORDING_SAVED,
RECORDING_UPLOADING,
useRecording,
} from '../../contexts/RecordingProvider';
import { RECORDING_MODAL } from '../RecordingModal';
export const Tray = () => {
const { openModal } = useUIState();
const { recordingState } = useRecording();
useEffect(() => {
console.log(`⏺️ Recording state: ${recordingState}`);
if (recordingState === RECORDING_ERROR) {
// show error modal here
}
}, [recordingState]);
const isRecording = [
RECORDING_RECORDING,
RECORDING_UPLOADING,
RECORDING_SAVED,
].includes(recordingState);
return (
<>
<TrayButton
label={isRecording ? 'Recording' : 'Record'}
orange={isRecording}
onClick={() => openModal(RECORDING_MODAL)}
>
<IconRecord />
</TrayButton>
</>
);
};
export default Tray;

View File

@ -0,0 +1 @@
export { Tray as default } from './Tray';

View File

@ -0,0 +1,304 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { useCallState } from '@dailyjs/shared/contexts/CallProvider';
import { useParticipants } from '@dailyjs/shared/contexts/ParticipantsProvider';
import { useUIState } from '@dailyjs/shared/contexts/UIStateProvider';
import {
CALL_STATE_REDIRECTING,
CALL_STATE_JOINED,
} from '@dailyjs/shared/contexts/useCallMachine';
import PropTypes from 'prop-types';
import { useDeepCompareEffect } from 'use-deep-compare';
export const RECORDING_ERROR = 'error';
export const RECORDING_SAVED = 'saved';
export const RECORDING_RECORDING = 'recording';
export const RECORDING_UPLOADING = 'uploading';
export const RECORDING_COUNTDOWN_1 = 'starting1';
export const RECORDING_COUNTDOWN_2 = 'starting2';
export const RECORDING_COUNTDOWN_3 = 'starting3';
export const RECORDING_IDLE = 'idle';
export const RECORDING_TYPE_CLOUD = 'cloud';
export const RECORDING_TYPE_LOCAL = 'local';
const RecordingContext = createContext({
isRecordingLocally: false,
recordingStartedDate: null,
recordingState: RECORDING_IDLE,
startRecording: null,
stopRecording: null,
});
export const RecordingProvider = ({ children }) => {
const { callObject, enableRecording, startCloudRecording, state } =
useCallState();
const { participants } = useParticipants();
const [recordingStartedDate, setRecordingStartedDate] = useState(null);
const [recordingState, setRecordingState] = useState(RECORDING_IDLE);
const [isRecordingLocally, setIsRecordingLocally] = useState(false);
const [hasRecordingStarted, setHasRecordingStarted] = useState(false);
const { setCustomCapsule } = useUIState();
const handleOnUnload = useCallback(
() => 'Unsaved recording in progress. Do you really want to leave?',
[]
);
useEffect(() => {
if (
!enableRecording ||
!isRecordingLocally ||
recordingState !== RECORDING_RECORDING ||
state === CALL_STATE_REDIRECTING
)
return false;
const prev = window.onbeforeunload;
window.onbeforeunload = handleOnUnload;
return () => {
window.onbeforeunload = prev;
};
}, [
enableRecording,
handleOnUnload,
recordingState,
isRecordingLocally,
state,
]);
useEffect(() => {
if (!callObject || !enableRecording) return false;
const handleAppMessage = (ev) => {
switch (ev?.data?.event) {
case 'recording-starting':
setRecordingState(RECORDING_COUNTDOWN_3);
break;
default:
break;
}
};
const handleRecordingUploadCompleted = () => {
setRecordingState(RECORDING_SAVED);
};
callObject.on('app-message', handleAppMessage);
callObject.on('recording-upload-completed', handleRecordingUploadCompleted);
return () => {
callObject.off('app-message', handleAppMessage);
callObject.off(
'recording-upload-completed',
handleRecordingUploadCompleted
);
};
}, [callObject, enableRecording]);
/**
* Automatically start cloud recording, if startCloudRecording is set.
*/
useEffect(() => {
if (
hasRecordingStarted ||
!callObject ||
!startCloudRecording ||
enableRecording !== 'cloud' ||
state !== CALL_STATE_JOINED
)
return false;
// Small timeout, in case other participants are already in-call.
const timeout = setTimeout(() => {
const isSomebodyRecording = participants.some((p) => p.isRecording);
if (!isSomebodyRecording) {
callObject.startRecording();
setIsRecordingLocally(true);
setHasRecordingStarted(true);
} else {
setHasRecordingStarted(true);
}
}, 500);
return () => {
clearTimeout(timeout);
};
}, [
callObject,
enableRecording,
hasRecordingStarted,
participants,
startCloudRecording,
state,
]);
/**
* Handle participant updates to sync recording state.
*/
useDeepCompareEffect(() => {
if (isRecordingLocally || recordingState === RECORDING_SAVED) return;
if (participants.some(({ isRecording }) => isRecording)) {
setRecordingState(RECORDING_RECORDING);
} else {
setRecordingState(RECORDING_IDLE);
}
}, [isRecordingLocally, participants, recordingState]);
/**
* Handle recording started.
*/
const handleRecordingStarted = useCallback(
(event) => {
if (recordingState === RECORDING_RECORDING) return;
if (event.local) {
// Recording started locally, either through UI or programmatically
setIsRecordingLocally(true);
if (!recordingStartedDate) setRecordingStartedDate(new Date());
}
setRecordingState(RECORDING_RECORDING);
},
[recordingState, recordingStartedDate]
);
useEffect(() => {
if (!callObject || !enableRecording) return false;
callObject.on('recording-started', handleRecordingStarted);
return () => callObject.off('recording-started', handleRecordingStarted);
}, [callObject, enableRecording, handleRecordingStarted]);
/**
* Handle recording stopped.
*/
useEffect(() => {
if (!callObject || !enableRecording) return false;
const handleRecordingStopped = () => {
if (isRecordingLocally) return;
setRecordingState(RECORDING_IDLE);
setRecordingStartedDate(null);
};
callObject.on('recording-stopped', handleRecordingStopped);
return () => callObject.off('recording-stopped', handleRecordingStopped);
}, [callObject, enableRecording, isRecordingLocally]);
/**
* Handle recording error.
*/
const handleRecordingError = useCallback(() => {
if (isRecordingLocally) setRecordingState(RECORDING_ERROR);
setIsRecordingLocally(false);
}, [isRecordingLocally]);
useEffect(() => {
if (!callObject || !enableRecording) return false;
callObject.on('recording-error', handleRecordingError);
return () => callObject.off('recording-error', handleRecordingError);
}, [callObject, enableRecording, handleRecordingError]);
const startRecording = useCallback(() => {
if (!callObject || !isRecordingLocally) return;
callObject.startRecording();
}, [callObject, isRecordingLocally]);
useEffect(() => {
let timeout;
switch (recordingState) {
case RECORDING_COUNTDOWN_3:
timeout = setTimeout(() => {
setRecordingState(RECORDING_COUNTDOWN_2);
}, 1000);
break;
case RECORDING_COUNTDOWN_2:
timeout = setTimeout(() => {
setRecordingState(RECORDING_COUNTDOWN_1);
}, 1000);
break;
case RECORDING_COUNTDOWN_1:
startRecording();
break;
case RECORDING_ERROR:
case RECORDING_SAVED:
timeout = setTimeout(() => {
setRecordingState(RECORDING_IDLE);
setIsRecordingLocally(false);
}, 5000);
break;
default:
break;
}
return () => {
clearTimeout(timeout);
};
}, [recordingState, startRecording]);
// Show a custom capsule when recording in progress
useEffect(() => {
if (recordingState !== RECORDING_RECORDING) {
setCustomCapsule(null);
} else {
setCustomCapsule({ variant: 'recording', label: 'Recording' });
}
}, [recordingState, setCustomCapsule]);
const startRecordingWithCountdown = useCallback(() => {
if (!callObject || !enableRecording) return;
setIsRecordingLocally(true);
setRecordingState(RECORDING_COUNTDOWN_3);
callObject?.sendAppMessage({
event: 'recording-starting',
});
}, [callObject, enableRecording]);
const stopRecording = useCallback(() => {
if (!callObject || !enableRecording || !isRecordingLocally) return;
if (recordingState === RECORDING_RECORDING) {
switch (enableRecording) {
case RECORDING_TYPE_LOCAL:
setRecordingState(RECORDING_SAVED);
setIsRecordingLocally(false);
break;
case RECORDING_TYPE_CLOUD:
setRecordingState(RECORDING_UPLOADING);
break;
default:
break;
}
} else if (recordingState === RECORDING_IDLE) {
return;
} else {
setIsRecordingLocally(false);
setRecordingState(RECORDING_IDLE);
}
setRecordingStartedDate(null);
callObject.stopRecording();
}, [callObject, enableRecording, isRecordingLocally, recordingState]);
return (
<RecordingContext.Provider
value={{
isRecordingLocally,
recordingStartedDate,
recordingState,
startRecordingWithCountdown,
stopRecording,
}}
>
{children}
</RecordingContext.Provider>
);
};
RecordingProvider.propTypes = {
children: PropTypes.node,
};
export const useRecording = () => useContext(RecordingContext);

View File

@ -0,0 +1,8 @@
# Domain excluding 'https://' and 'daily.co' e.g. 'somedomain'
DAILY_DOMAIN=
# Obtained from https://dashboard.daily.co/developers
DAILY_API_KEY=
# Daily REST API endpoint
DAILY_REST_DOMAIN=https://api.daily.co/v1

BIN
dailyjs/recording/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

View File

@ -0,0 +1,13 @@
const withPlugins = require('next-compose-plugins');
const withTM = require('next-transpile-modules')([
'@dailyjs/shared',
'@dailyjs/basic-call',
]);
const packageJson = require('./package.json');
module.exports = withPlugins([withTM], {
env: {
PROJECT_TITLE: packageJson.description,
},
});

View File

@ -0,0 +1,25 @@
{
"name": "@dailyjs/recording",
"description": "Basic Call + Recording",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dailyjs/shared": "*",
"@dailyjs/basic-call": "*",
"next": "^11.0.0",
"pluralize": "^8.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"babel-plugin-module-resolver": "^4.1.0",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^8.0.0"
}
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import App from '@dailyjs/basic-call/pages/_app';
import AppWithRecording from '../components/App';
import { RecordingModal } from '../components/RecordingModal';
import Tray from '../components/Tray';
App.modals = [RecordingModal];
App.customAppComponent = <AppWithRecording />;
App.customTrayComponent = <Tray />;
export default App;

1
dailyjs/recording/pages/api Symbolic link
View File

@ -0,0 +1 @@
../../basic-call/pages/api

View File

@ -0,0 +1,16 @@
import Index from '@dailyjs/basic-call/pages';
import getDemoProps from '@dailyjs/shared/lib/demoProps';
export async function getStaticProps() {
const defaultProps = getDemoProps();
return {
props: {
...defaultProps,
forceFetchToken: true,
forceOwner: true,
},
};
}
export default Index;

1
dailyjs/recording/public Symbolic link
View File

@ -0,0 +1 @@
../basic-call/public

View File

@ -12,6 +12,7 @@ import React, {
useEffect,
useState,
} from 'react';
import Bowser from 'bowser';
import PropTypes from 'prop-types';
import {
ACCESS_STATE_LOBBY,
@ -31,6 +32,8 @@ export const CallProvider = ({
}) => {
const [videoQuality, setVideoQuality] = useState(VIDEO_QUALITY_AUTO);
const [preJoinNonAuthorized, setPreJoinNonAuthorized] = useState(false);
const [enableRecording, setEnableRecording] = useState(null);
const [startCloudRecording, setStartCloudRecording] = useState(false);
// Daily CallMachine hook (primarily handles status of the call)
const { daily, leave, state, setRedirectOnLeave } = useCallMachine({
@ -40,6 +43,32 @@ export const CallProvider = ({
subscribeToTracksAutomatically,
});
// Feature detection taken from daily room object and client browser support
useEffect(() => {
if (!daily) return;
const updateRoomConfigState = async () => {
const roomConfig = await daily.room();
if (!('config' in roomConfig)) return;
const browser = Bowser.parse(window.navigator.userAgent);
const supportsRecording =
browser.platform.type === 'desktop' && browser.engine.name === 'Blink';
// recording and screen sharing is hidden in owner_only_broadcast for non-owners
if (supportsRecording) {
const recordingType =
roomConfig?.tokenConfig?.enable_recording ??
roomConfig?.config?.enable_recording;
if (['local', 'cloud'].includes(recordingType)) {
setEnableRecording(recordingType);
setStartCloudRecording(
roomConfig?.tokenConfig?.start_cloud_recording ?? false
);
}
}
};
updateRoomConfigState();
}, [state, daily]);
// Convience wrapper for adding a fake participant to the call
const addFakeParticipant = useCallback(() => {
daily.addFakeParticipant();
@ -72,9 +101,11 @@ export const CallProvider = ({
preJoinNonAuthorized,
leave,
videoQuality,
enableRecording,
setVideoQuality,
setBandwidth,
setRedirectOnLeave,
startCloudRecording,
subscribeToTracksAutomatically,
}}
>

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23Z" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="4" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@ -5,6 +5,7 @@
"main": "index.js",
"dependencies": {
"@daily-co/daily-js": "^0.15.0",
"bowser": "^2.11.0",
"classnames": "^2.3.1",
"debounce": "^1.2.1",
"nanoid": "^3.1.23",

View File

@ -264,11 +264,6 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@popperjs/core@^2.9.2":
version "2.9.2"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353"
integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==
"@rushstack/eslint-patch@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.0.6.tgz#023d72a5c4531b4ce204528971700a78a85a0c50"
@ -589,7 +584,7 @@ boolbase@^1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
bowser@^2.8.1:
bowser@^2.11.0, bowser@^2.8.1:
version "2.11.0"
resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f"
integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==
@ -2209,7 +2204,7 @@ lodash@^4.17.13, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -2902,11 +2897,6 @@ react-dom@^17.0.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
react-fast-compare@^3.0.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-is@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
@ -2917,14 +2907,6 @@ react-is@^16.8.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-popper@^2.2.5:
version "2.2.5"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.2.5.tgz#1214ef3cec86330a171671a4fbcbeeb65ee58e96"
integrity sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==
dependencies:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-refresh@0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
@ -3634,13 +3616,6 @@ vm-browserify@1.1.2, vm-browserify@^1.0.1:
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
warning@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
dependencies:
loose-envify "^1.0.0"
watchpack@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.1.1.tgz#e99630550fca07df9f90a06056987baa40a689c7"