From 02bb1eaf25e5915678dda6a6d093514308379521 Mon Sep 17 00:00:00 2001 From: ahiipsa Date: Thu, 27 Apr 2023 03:52:23 +0400 Subject: [PATCH 1/2] Added demo: voice translation --- client/package.json | 2 + client/src/Routes.tsx | 2 + client/src/custom.d.ts | 1 + client/src/routes/voice/VoicePage.tsx | 129 ++++++++++++++++++ .../routes/voice/components/RecordButton.tsx | 62 +++++++++ .../voice/components/TranslationItem.tsx | 31 +++++ client/src/routes/voice/types.ts | 8 ++ client/src/stores/Web2AuthStore.ts | 5 + client/yarn.lock | 10 ++ 9 files changed, 250 insertions(+) create mode 100644 client/src/routes/voice/VoicePage.tsx create mode 100644 client/src/routes/voice/components/RecordButton.tsx create mode 100644 client/src/routes/voice/components/TranslationItem.tsx create mode 100644 client/src/routes/voice/types.ts diff --git a/client/package.json b/client/package.json index fa085612..c74253e6 100644 --- a/client/package.json +++ b/client/package.json @@ -122,6 +122,7 @@ "@types/styled-components": "^5.1.26", "@web3modal/ethereum": "^2.1.1", "@web3modal/react": "^2.1.1", + "audio-recorder-polyfill": "^0.4.1", "axios": "^0.21.1", "babel-plugin-styled-components": "^2.0.7", "bn.js": "^5.2.1", @@ -158,6 +159,7 @@ "web3": "1.8.2", "web3-eth-abi": "^1.7.4", "web3-eth-contract": "1.8.2", + "webm-to-wav-converter": "^1.1.0", "zerg": "^2.1.2" }, "resolutions": { diff --git a/client/src/Routes.tsx b/client/src/Routes.tsx index c1ee3bab..60546bfe 100644 --- a/client/src/Routes.tsx +++ b/client/src/Routes.tsx @@ -2,6 +2,7 @@ import React, { lazy, Suspense } from 'react' import { Routes, Route } from 'react-router-dom' import { HomePage } from './routes/home/HomePage' import { HomePageLoader } from './routes/home/components/HomePageLoader' +import { VoicePage } from './routes/voice/VoicePage' const StatsPage = lazy( () => import(/* webpackChunkName: "Others" */ './routes/stats/Stats') @@ -37,6 +38,7 @@ const AppRoutes = () => { } /> } /> } /> + } /> } /> diff --git a/client/src/custom.d.ts b/client/src/custom.d.ts index bf471bdd..0e0d728a 100644 --- a/client/src/custom.d.ts +++ b/client/src/custom.d.ts @@ -13,5 +13,6 @@ declare module '*.gif' declare module 'react-video-thumbnail-image' declare module 'react-fb-image-video-grid' declare module 'grommet-icons/icons/FormClose' +declare module 'grommet-icons/icons/Microphone' declare module 'grommet-icons/icons/FormSearch' declare module 'use-lodash-debounce' diff --git a/client/src/routes/voice/VoicePage.tsx b/client/src/routes/voice/VoicePage.tsx new file mode 100644 index 00000000..171efc4a --- /dev/null +++ b/client/src/routes/voice/VoicePage.tsx @@ -0,0 +1,129 @@ +import React, { useRef, useState } from 'react' +import { Container } from '../home/Home.styles' +import { Box } from 'grommet/components/Box' +import { getWaveBlob } from 'webm-to-wav-converter' +import axios from 'axios' +import { observable } from 'mobx' +import { observer } from 'mobx-react-lite' +import { RecordStatus, Translation } from './types' +import { TranslationItem } from './components/TranslationItem' +import { RecordButton } from './components/RecordButton' +import config from '../../../config' +import AudioRecorder from 'audio-recorder-polyfill' +window.MediaRecorder = AudioRecorder + +const translationList = observable([]) + +export const VoicePage: React.FC = observer(() => { + const [mediaRecorder, setMediaRecorder] = useState(null) + const recordedChunks = useRef([]) + const [status, setStatus] = useState('stopped') + + const handleStartRecording = async () => { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }) + + const recorder = new window.MediaRecorder(stream, {}) + + recorder.addEventListener('dataavailable', (event) => { + if (event.data.size > 0) { + recordedChunks.current.push(event.data) + } + }) + + recorder.addEventListener('stop', () => { + const blob = new Blob(recordedChunks.current, { + type: recorder.mimeType, + }) + + stream.getTracks().forEach(function (track) { + if (track.readyState == 'live') { + track.stop() + } + }) + + console.log('### recorder', recorder.mimeType) + const url = URL.createObjectURL(blob) + + const dateTime = Date.now() + + const translation: Translation = { + translation: '', + audio: url, + date: dateTime, + inProgress: true, + } + + translationList.unshift(translation) + + const formData = new FormData() + + const wrapper = (blob: Blob) => { + if (recorder.mimeType.includes('audio/webm')) { + console.log('### to wav') + + return getWaveBlob(blob, false) + } + + return Promise.resolve(blob) + } + + wrapper(blob) + .then((blob) => { + formData.append('audio', blob, 'voice_record' + translation.date) + + console.log('### blob', blob) + + return axios.post<{ result: string }>( + config.backendHost + '/translations', + formData + ) + }) + .then((t) => { + const item = translationList.find((item) => { + return item.date === dateTime + }) + + item.translation = t.data.result + item.inProgress = false + + console.log('### translationList', translationList) + }) + + recordedChunks.current = [] + }) + recorder.start() + setMediaRecorder(recorder) + setStatus('recording') + } + + const handleStopRecording = () => { + if (mediaRecorder) { + mediaRecorder.stop() + setStatus('stopped') + } + } + + return ( + + +
+ +
+ + {translationList.map((item) => { + return + })} + +
+
+ ) +}) diff --git a/client/src/routes/voice/components/RecordButton.tsx b/client/src/routes/voice/components/RecordButton.tsx new file mode 100644 index 00000000..45ad6cc4 --- /dev/null +++ b/client/src/routes/voice/components/RecordButton.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import styled, { css, keyframes } from 'styled-components' +import { Button } from 'grommet/components/Button' +import { RecordStatus } from '../types' +import { Box } from 'grommet/components/Box' +import { Microphone } from 'grommet-icons/icons/Microphone' + +export const pulseAnimation = keyframes` + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px rgba(255, 0, 0, 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(255, 0, 0, 0); + } +` + +const StyledButton = styled(Button)<{ status: RecordStatus }>` + width: 60px; + height: 60px; + border-radius: 50%; + background-color: #00aee9; + + ${(props) => { + if (props.status === 'recording') { + return css` + animation-name: ${pulseAnimation}; + animation-duration: 1.5s; + animation-iteration-count: infinite; + background-color: rgb(255, 0, 0); + ` + } + + if (props.status === 'stopped') { + return css` + background-color: #00aee9; + ` + } + + return '' + }} +` + +export const RecordButton: React.FC<{ + status: RecordStatus + onClick: () => void +}> = ({ status, onClick }) => { + return ( + + + + + + ) +} diff --git a/client/src/routes/voice/components/TranslationItem.tsx b/client/src/routes/voice/components/TranslationItem.tsx new file mode 100644 index 00000000..09415b69 --- /dev/null +++ b/client/src/routes/voice/components/TranslationItem.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Box } from 'grommet/components/Box' +import { Spinner } from 'grommet/components/Spinner' +import { BaseText, SmallText } from '../../../components/Text' +import { Translation } from '../types' +import styled from 'styled-components' +import { observer } from 'mobx-react-lite' + +const StyleBox = styled(Box)` + border-radius: 12px; + padding: 16px; + border: 1px solid #758796; +` + +const dateFormat = new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', +}) + +export const TranslationItem: React.FC<{ + item: Translation +}> = observer(({ item }) => { + return ( + + {dateFormat.format(item.date)} + {item.inProgress && } + {item.translation} + + ) +}) diff --git a/client/src/routes/voice/types.ts b/client/src/routes/voice/types.ts new file mode 100644 index 00000000..931d449e --- /dev/null +++ b/client/src/routes/voice/types.ts @@ -0,0 +1,8 @@ +export type RecordStatus = 'recording' | 'stopped' + +export interface Translation { + audio: string + translation: string + date: number + inProgress: boolean +} diff --git a/client/src/stores/Web2AuthStore.ts b/client/src/stores/Web2AuthStore.ts index 61872f70..b66e32b5 100644 --- a/client/src/stores/Web2AuthStore.ts +++ b/client/src/stores/Web2AuthStore.ts @@ -30,6 +30,7 @@ export class Web2AuthStore extends BaseStore { } async restoreAuth() { + // #5 Check and restore auth const jwt = cookie.get(COOKIES.JWT) || null if (!jwt) { @@ -57,16 +58,20 @@ export class Web2AuthStore extends BaseStore { const address = this.stores.walletStore.walletAddress + // #1 Request message const { message } = await mainApi.requestNonce({ address }) + // #2 Sign message const signature = await this.getDCClient().web3.eth.personal.sign( message, address, '' ) + // #3 Auth const result = await mainApi.auth({ signature, address: address }) + // #4 Save token this.jwt = result.token cookie.set(COOKIES.JWT, result.token, { expires: 90 }) } diff --git a/client/yarn.lock b/client/yarn.lock index 0f56c882..0fb3547c 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -4385,6 +4385,11 @@ atomic-sleep@^1.0.0: resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== +audio-recorder-polyfill@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/audio-recorder-polyfill/-/audio-recorder-polyfill-0.4.1.tgz#4290eca3d8edf49eb6daba59592aacf357b6e5b7" + integrity sha512-SS4qVOzuVwlS/tjQdd0uR+9cCKBTkx4jsAdjM+rMNqoTEWf6bMnBSTfv+FO4Zn9ngxviJOxhkgRWWXsAMqM96Q== + available-typed-arrays@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" @@ -13102,6 +13107,11 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webm-to-wav-converter@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/webm-to-wav-converter/-/webm-to-wav-converter-1.1.0.tgz#01479ce3d2e0ded25d8fb34b656fe0429cc98a3b" + integrity sha512-fOtPan5a46N3Lss8mPJ8KGgJ0id+Sa7k8Glj6g7Rlt6SBWsLGjgZ3FIO0P1Y+r/GDe5Z/+fgaDGedS6ORPyksQ== + webpack-bundle-analyzer@^3.6.1: version "3.9.0" resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.9.0.tgz#f6f94db108fb574e415ad313de41a2707d33ef3c" From 0358d2aaa8b39ce917b248e6d292ab48c40f7851 Mon Sep 17 00:00:00 2001 From: ahiipsa Date: Sun, 14 May 2023 17:48:56 +0400 Subject: [PATCH 2/2] Added live translation --- client/package.json | 1 + client/src/routes/voice/VoicePage.tsx | 216 +++++++++++++++++++------- client/yarn.lock | 48 +++++- 3 files changed, 203 insertions(+), 62 deletions(-) diff --git a/client/package.json b/client/package.json index c74253e6..11fdfe29 100644 --- a/client/package.json +++ b/client/package.json @@ -150,6 +150,7 @@ "react-text-mask": "^5.5.0", "react-toastify": "^9.0.8", "react-twitter-embed": "^4.0.4", + "socket.io-client": "^4.6.1", "styled-components": "^5.3.0", "text-mask-addons": "^3.8.0", "three-dots": "^0.3.2", diff --git a/client/src/routes/voice/VoicePage.tsx b/client/src/routes/voice/VoicePage.tsx index 171efc4a..f6d3d0e7 100644 --- a/client/src/routes/voice/VoicePage.tsx +++ b/client/src/routes/voice/VoicePage.tsx @@ -1,99 +1,157 @@ -import React, { useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Container } from '../home/Home.styles' import { Box } from 'grommet/components/Box' -import { getWaveBlob } from 'webm-to-wav-converter' -import axios from 'axios' import { observable } from 'mobx' import { observer } from 'mobx-react-lite' import { RecordStatus, Translation } from './types' import { TranslationItem } from './components/TranslationItem' import { RecordButton } from './components/RecordButton' import config from '../../../config' + +import { io } from 'socket.io-client' + import AudioRecorder from 'audio-recorder-polyfill' +import styled from 'styled-components' window.MediaRecorder = AudioRecorder const translationList = observable([]) +const tList = observable([]) + +const TranslationText = styled.span<{ temp: boolean }>` + color: ${(props) => (props.temp ? '#c1c1c1' : '#1f1f1f')}; +` + +const socket = io(config.backendHost) + +socket.on('ReadyToTranslation', (data) => { + console.log('### ReadyToTranslation') +}) + +// [].reduce((acc, item) => { +// +// // перезависыавем последний елемент +// // case 1: пришел AddPartialTranscript +// // case 2: пришел AddTranscript +// if(acc[acc.length - 1].start_time === item.start_time) { +// acc.push(item) +// } +// +// return acc; +// }); export const VoicePage: React.FC = observer(() => { const [mediaRecorder, setMediaRecorder] = useState(null) const recordedChunks = useRef([]) const [status, setStatus] = useState('stopped') + const [transcription, setTranscription] = useState('') + + useEffect(() => { + socket.on('AddTranscript', (data) => { + console.log('### AddTranscript data', data) + tList.push(data) + }) + + socket.on('AddPartialTranscript', (data) => { + console.log('### AddPartialTranscript data', data) + tList.push(data) + }) + }, []) + + useEffect(() => { + return () => { + socket.disconnect() + } + }, []) const handleStartRecording = async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }) + socket.emit('StartRecognition') + const recorder = new window.MediaRecorder(stream, {}) recorder.addEventListener('dataavailable', (event) => { + socket.emit('AddAudio', event.data) if (event.data.size > 0) { recordedChunks.current.push(event.data) } }) recorder.addEventListener('stop', () => { - const blob = new Blob(recordedChunks.current, { - type: recorder.mimeType, - }) + // const blob = new Blob(recordedChunks.current, { + // type: recorder.mimeType, + // }) + // + socket.emit('EndOfStream') stream.getTracks().forEach(function (track) { if (track.readyState == 'live') { track.stop() } }) - - console.log('### recorder', recorder.mimeType) - const url = URL.createObjectURL(blob) - - const dateTime = Date.now() - - const translation: Translation = { - translation: '', - audio: url, - date: dateTime, - inProgress: true, - } - - translationList.unshift(translation) - - const formData = new FormData() - - const wrapper = (blob: Blob) => { - if (recorder.mimeType.includes('audio/webm')) { - console.log('### to wav') - - return getWaveBlob(blob, false) - } - - return Promise.resolve(blob) - } - - wrapper(blob) - .then((blob) => { - formData.append('audio', blob, 'voice_record' + translation.date) - - console.log('### blob', blob) - - return axios.post<{ result: string }>( - config.backendHost + '/translations', - formData - ) - }) - .then((t) => { - const item = translationList.find((item) => { - return item.date === dateTime - }) - - item.translation = t.data.result - item.inProgress = false - - console.log('### translationList', translationList) - }) - - recordedChunks.current = [] + // + // console.log('### recorder', recorder.mimeType) + // const url = URL.createObjectURL(blob) + // + // // const a = document.createElement('a') + // // document.body.appendChild(a) + // // a.href = url + // // a.download = `test${Date.now()}.wav` + // // a.click() + // // + // // return + // + // const dateTime = Date.now() + // + // const translation: Translation = { + // translation: '', + // audio: url, + // date: dateTime, + // inProgress: true, + // } + // + // translationList.unshift(translation) + // + // const formData = new FormData() + // + // const wrapper = (blob: Blob) => { + // if (recorder.mimeType.includes('audio/webm')) { + // console.log('### to wav') + // + // return getWaveBlob(blob, false) + // } + // + // return Promise.resolve(blob) + // } + // + // wrapper(blob) + // .then((blob) => { + // formData.append('audio', blob, 'voice_record' + translation.date) + // + // console.log('### blob', blob) + // + // return axios.post<{ result: string }>( + // config.backendHost + '/translations', + // formData + // ) + // }) + // .then((t) => { + // const item = translationList.find((item) => { + // return item.date === dateTime + // }) + // + // item.translation = t.data.result + // item.inProgress = false + // + // console.log('### translationList', translationList) + // }) + // + // recordedChunks.current = [] }) - recorder.start() + + recorder.start(2000) setMediaRecorder(recorder) setStatus('recording') } @@ -119,9 +177,47 @@ export const VoicePage: React.FC = observer(() => { /> - {translationList.map((item) => { - return - })} +
{transcription}
+ +
{tList.length}
+
+ {tList + .reduce((acc, item) => { + if (acc.length === 0) { + acc.push(item) + console.log('### first') + return acc + } + // перезависыавем последний елемент + // case 1: пришел AddPartialTranscript + // case 2: пришел AddTranscript + if ( + acc[acc.length - 1].metadata.start_time === + item.metadata.start_time + ) { + console.log('### push next') + + acc[acc.length - 1] = item + + return acc + } + + acc.push(item) + + return acc + }, []) + .map((item: any, index: number) => { + console.log('### item', item) + const key = `${index}${item.metadata.start_time}${item.metadata.end_time}` + const temp = item.message === 'AddPartialTranscript' + + return ( + + {item.metadata.transcript} + + ) + })} +
diff --git a/client/yarn.lock b/client/yarn.lock index 0fb3547c..8f26d4bc 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2558,6 +2558,11 @@ dependencies: "@sinonjs/commons" "^2.0.0" +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== + "@solana/buffer-layout@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@solana/buffer-layout/-/buffer-layout-4.0.0.tgz#75b1b11adc487234821c81dfae3119b73a5fd734" @@ -5822,7 +5827,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.6.6, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: +debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -6225,6 +6230,22 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" +engine.io-client@~6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.4.0.tgz#88cd3082609ca86d7d3c12f0e746d12db4f47c91" + integrity sha512-GyKPDyoEha+XZ7iEqam49vz6auPnNJ9ZBfy89f+rMMas8AuiMWOZ9PVzu8xb9ZC6rafUqiGHSCfu22ih66E+1g== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.0.3" + ws "~8.11.0" + xmlhttprequest-ssl "~2.0.0" + +engine.io-parser@~5.0.3: + version "5.0.6" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.0.6.tgz#7811244af173e157295dec9b2718dfe42a64ef45" + integrity sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw== + enhanced-resolve@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz#4d6e689b3725f86090927ccc86cd9f1635b89e2e" @@ -11588,6 +11609,24 @@ slice-ansi@^5.0.0: ansi-styles "^6.0.0" is-fullwidth-code-point "^4.0.0" +socket.io-client@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.6.1.tgz#80d97d5eb0feca448a0fb6d69a7b222d3d547eab" + integrity sha512-5UswCV6hpaRsNg5kkEHVcbBIXEYoVbMQaHJBXJCyEQ+CiFPV1NIOY0XOFWG4XR4GZcB8Kn6AsRs/9cy9TbqVMQ== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.2" + engine.io-client "~6.4.0" + socket.io-parser "~4.2.1" + +socket.io-parser@~4.2.1: + version "4.2.2" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.2.tgz#1dd384019e25b7a3d374877f492ab34f2ad0d206" + integrity sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + sockjs@^0.3.24: version "0.3.24" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" @@ -13421,7 +13460,7 @@ ws@^8.4.2: resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== -ws@^8.5.0: +ws@^8.5.0, ws@~8.11.0: version "8.11.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== @@ -13461,6 +13500,11 @@ xhr@^2.0.4, xhr@^2.3.3: parse-headers "^2.0.0" xtend "^4.0.0" +xmlhttprequest-ssl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67" + integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A== + xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"