Support resume-able upload via tus

## Issue
38 Handle resumable file upload

## Notes
Since we can't serialize a File object, we'll need to the user to re-select the file to resume.
This commit is contained in:
infinite-persistence 2021-10-12 17:21:19 +08:00
parent fa48b4a99b
commit bef7ff4a2d
No known key found for this signature in database
GPG key ID: B9C3252EDC3D0AA0
14 changed files with 448 additions and 111 deletions

View file

@ -27,7 +27,7 @@ export {
doResetSync,
doSyncEncryptAndDecrypt,
} from 'redux/actions/sync';
export { doUpdateUploadProgress } from './redux/actions/web';
export { doUpdateUploadAdd, doUpdateUploadProgress, doUpdateUploadRemove } from './redux/actions/web';
// reducers
export { authReducer } from './redux/reducers/auth';

View file

@ -1,12 +1,30 @@
// @flow
import * as ACTIONS from 'constants/action_types';
export const doUpdateUploadProgress = (
progress: string,
export function doUpdateUploadAdd(file: File, params: { [key: string]: any }, tusUploader: any) {
return (dispatch: Dispatch, getState: GetState) => {
dispatch({
type: ACTIONS.UPDATE_UPLOAD_ADD,
data: { file, params, tusUploader },
});
};
}
export const doUpdateUploadProgress = (props: {
params: { [key: string]: any },
xhr: any
) => (dispatch: Dispatch) =>
progress?: string,
status?: string,
}) => (dispatch: Dispatch) =>
dispatch({
type: ACTIONS.UPDATE_UPLOAD_PROGRESS,
data: { progress, params, xhr },
data: props,
});
export function doUpdateUploadRemove(params: { [key: string]: any }) {
return (dispatch: Dispatch, getState: GetState) => {
dispatch({
type: ACTIONS.UPDATE_UPLOAD_REMOVE,
data: { params },
});
};
}

View file

@ -1,34 +1,12 @@
// @flow
import * as ACTIONS from 'constants/action_types';
import { serializeFileObj } from 'util/file';
/*
test mock:
currentUploads: {
'test#upload': {
progress: 50,
params: {
name: 'steve',
thumbnail_url: 'https://dev2.spee.ch/4/KMNtoSZ009fawGz59VG8PrID.jpeg',
},
},
},
*/
export type Params = {
channel?: string,
name: string,
thumbnail_url: ?string,
title: ?string,
};
export type UploadItem = {
progess: string,
params: Params,
xhr?: any,
};
const CURRENT_UPLOADS = 'current_uploads';
const getKeyFromParam = (params) => `${params.name}#${params.channel || 'anonymous'}`;
export type TvState = {
currentUploads: { [key: string]: UploadItem },
currentUploads: { [key: string]: FileUploadItem },
};
const reducers = {};
@ -37,21 +15,62 @@ const defaultState: TvState = {
currentUploads: {},
};
reducers[ACTIONS.UPDATE_UPLOAD_PROGRESS] = (state: TvState, action) => {
const { progress, params, xhr } = action.data;
const key = params.channel ? `${params.name}#${params.channel}` : `${params.name}#anonymous`;
let currentUploads;
if (!progress) {
currentUploads = Object.assign({}, state.currentUploads);
Object.keys(currentUploads).forEach(k => {
if (k === key) {
delete currentUploads[key];
}
try {
const uploads = JSON.parse(window.localStorage.getItem(CURRENT_UPLOADS));
if (uploads) {
defaultState.currentUploads = uploads;
Object.keys(defaultState.currentUploads).forEach((key) => {
delete defaultState.currentUploads[key].tusUploader;
});
} else {
currentUploads = Object.assign({}, state.currentUploads);
currentUploads[key] = { progress, params, xhr };
}
} catch (e) {
console.log(e);
}
reducers[ACTIONS.UPDATE_UPLOAD_ADD] = (state: TvState, action) => {
const { file, params, tusUploader } = action.data;
const key = getKeyFromParam(params);
const currentUploads = Object.assign({}, state.currentUploads);
currentUploads[key] = {
file,
fileFingerprint: serializeFileObj(file), // TODO: get hash instead?
progress: '0',
params,
tusUploader,
};
window.localStorage.setItem(CURRENT_UPLOADS, JSON.stringify(currentUploads));
return { ...state, currentUploads };
};
reducers[ACTIONS.UPDATE_UPLOAD_PROGRESS] = (state: TvState, action) => {
const { params, progress, status } = action.data;
const key = getKeyFromParam(params);
const currentUploads = Object.assign({}, state.currentUploads);
if (progress) {
currentUploads[key].progress = progress;
delete currentUploads[key].status;
} else if (status) {
currentUploads[key].status = status;
if (status === 'error') {
delete currentUploads[key].tusUploader;
}
}
window.localStorage.setItem(CURRENT_UPLOADS, JSON.stringify(currentUploads));
return { ...state, currentUploads };
};
reducers[ACTIONS.UPDATE_UPLOAD_REMOVE] = (state: TvState, action) => {
const { params } = action.data;
const key = getKeyFromParam(params);
const currentUploads = Object.assign({}, state.currentUploads);
delete currentUploads[key];
window.localStorage.setItem(CURRENT_UPLOADS, JSON.stringify(currentUploads));
return { ...state, currentUploads };
};

9
flow-typed/File.js vendored
View file

@ -76,3 +76,12 @@ declare type DeletePurchasedUri = {
uri: string,
},
};
declare type FileUploadItem = {
params: UpdatePublishFormData,
file: File,
fileFingerprint: string,
progress: string,
status?: string,
tusUploader?: any,
};

View file

@ -68,6 +68,7 @@
"rss": "^1.2.2",
"source-map-explorer": "^2.5.2",
"tempy": "^0.6.0",
"tus-js-client": "^2.3.0",
"videojs-contrib-ads": "^6.9.0",
"videojs-ima": "^1.11.0",
"videojs-ima-player": "^0.5.6",

View file

@ -1291,6 +1291,16 @@
"Select a file to upload": "Select a file to upload",
"Select file to upload": "Select file to upload",
"Url copied.": "Url copied.",
"Failed to initiate upload (%err%)": "Failed to initiate upload (%err%)",
"Invalid file": "Invalid file",
"It appears to be a different or modified file.": "It appears to be a different or modified file.",
"Please select the same file from the initial upload.": "Please select the same file from the initial upload.",
"Cancel upload": "Cancel upload",
"Cancel and remove the selected upload?": "Cancel and remove the selected upload?",
"Select the file to resume upload...": "Select the file to resume upload...",
"Stopped.": "Stopped.",
"Resume": "Resume",
"Retrying...": "Retrying...",
"Uploading...": "Uploading...",
"Creating...": "Creating...",
"Use a URL": "Use a URL",

View file

@ -1,13 +1,18 @@
import { connect } from 'react-redux';
import { selectCurrentUploads, selectUploadCount } from 'lbryinc';
import { selectCurrentUploads, selectUploadCount, doUpdateUploadRemove } from 'lbryinc';
import { doOpenModal } from 'redux/actions/app';
import { doPublishResume } from 'redux/actions/publish';
import WebUploadList from './view';
const select = state => ({
const select = (state) => ({
currentUploads: selectCurrentUploads(state),
uploadCount: selectUploadCount(state),
});
export default connect(
select,
null
)(WebUploadList);
const perform = {
doPublishResume,
doUpdateUploadRemove,
doOpenModal,
};
export default connect(select, perform)(WebUploadList);

View file

@ -1,40 +1,138 @@
// @flow
import React from 'react';
import React, { useState } from 'react';
import FileSelector from 'component/common/file-selector';
import Button from 'component/button';
import FileThumbnail from 'component/fileThumbnail';
import * as MODALS from 'constants/modal_types';
import { serializeFileObj } from 'util/file';
type Props = {
params: UpdatePublishFormData,
progress: string,
xhr?: () => void,
uploadItem: FileUploadItem,
doPublishResume: (any) => void,
doUpdateUploadRemove: (any) => void,
doOpenModal: (string, {}) => void,
};
export default function WebUploadItem(props: Props) {
const { params, progress, xhr } = props;
const { uploadItem, doPublishResume, doUpdateUploadRemove, doOpenModal } = props;
const { params, file, fileFingerprint, progress, status, tusUploader } = uploadItem;
const [showFileSelector, setShowFileSelector] = useState(false);
function handleFileChange(newFile: WebFile, clearName = true) {
if (serializeFileObj(newFile) === fileFingerprint) {
setShowFileSelector(false);
doPublishResume({ ...params, file_path: newFile });
} else {
doOpenModal(MODALS.CONFIRM, {
title: __('Invalid file'),
subtitle: __('It appears to be a different or modified file.'),
body: <p className="help--warning">{__('Please select the same file from the initial upload.')}</p>,
onConfirm: (closeModal) => closeModal(),
hideCancel: true,
});
}
}
function handleCancel() {
doOpenModal(MODALS.CONFIRM, {
title: __('Cancel upload'),
subtitle: __('Cancel and remove the selected upload?'),
body: params.name ? <p className="empty">{`lbry://${params.name}`}</p> : undefined,
onConfirm: (closeModal) => {
if (tusUploader) {
tusUploader.abort(true);
}
doUpdateUploadRemove(params);
closeModal();
},
});
}
function resolveProgressStr() {
if (!tusUploader) {
return __('Stopped.');
} else if (status) {
switch (status) {
case 'retry':
return __('Retrying...');
case 'error':
return __('Failed.');
default:
return status;
}
} else {
const progressInt = parseInt(progress);
return progressInt === 100 ? __('Processing...') : __('Uploading...');
}
}
function getRetryButton() {
if (!tusUploader) {
const isFileActive = file instanceof File;
return (
<Button
label={isFileActive ? __('Resume') : __('Retry')}
button="link"
onClick={() => {
if (isFileActive) {
doPublishResume({ ...params, file_path: file });
} else {
setShowFileSelector(true);
}
}}
disabled={showFileSelector}
/>
);
} else {
return null;
}
}
function getCancelButton() {
return <Button label={__('Cancel')} button="link" onClick={handleCancel} />;
}
function getFileSelector() {
return (
<div className="claim-preview--padded">
<FileSelector
label={__('File')}
onFileChosen={handleFileChange}
// https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files
accept={'video/mp4,video/x-m4v,video/*,audio/*'}
placeholder={__('Select the file to resume upload...')}
/>
</div>
);
}
function getProgressBar() {
return (
<>
<div className="claim-upload__progress--label">lbry://{params.name}</div>
<div className={'claim-upload__progress--outer card--inline'}>
<div className={'claim-upload__progress--inner'} style={{ width: `${progress}%` }}>
{resolveProgressStr()}
</div>
</div>
</>
);
}
return (
<li className={'claim-preview claim-preview--padded claim-preview--inactive card--inline'}>
<li className={'web-upload-item claim-preview claim-preview--padded claim-preview--inactive card--inline'}>
<FileThumbnail thumbnail={params.thumbnail_url} />
<div className={'claim-preview-metadata'}>
<div className="claim-preview-info">
<div className="claim-preview__title">{params.title}</div>
{xhr && (
<div className="card__actions--inline">
<Button
button="link"
onClick={() => {
xhr.abort();
}}
label={__('Cancel')}
/>
</div>
)}
</div>
<h2>lbry://{params.name}</h2>
<div className={'claim-upload__progress--outer card--inline'}>
<div className={'claim-upload__progress--inner'} style={{ width: `${progress}%` }}>
Uploading...
<div className="card__actions--inline">
{getRetryButton()}
{getCancelButton()}
</div>
</div>
{showFileSelector && getFileSelector()}
{!showFileSelector && getProgressBar()}
</div>
</li>
);

View file

@ -3,19 +3,16 @@ import * as React from 'react';
import Card from 'component/common/card';
import WebUploadItem from './internal/web-upload-item';
export type UploadItem = {
progess: string,
params: UpdatePublishFormData,
xhr?: { abort: () => void },
};
type Props = {
currentUploads: { [key: string]: UploadItem },
currentUploads: { [key: string]: FileUploadItem },
uploadCount: number,
doPublishResume: (any) => void,
doUpdateUploadRemove: (any) => void,
doOpenModal: (string, {}) => void,
};
export default function WebUploadList(props: Props) {
const { currentUploads, uploadCount } = props;
const { currentUploads, uploadCount, doPublishResume, doUpdateUploadRemove, doOpenModal } = props;
return (
!!uploadCount && (
@ -25,8 +22,16 @@ export default function WebUploadList(props: Props) {
body={
<section>
{/* $FlowFixMe */}
{Object.values(currentUploads).map(({ progress, params, xhr }) => (
<WebUploadItem key={`upload${params.name}`} progress={progress} params={params} xhr={xhr} />
{Object.values(currentUploads).map((uploadItem) => (
<WebUploadItem
// $FlowFixMe
key={`upload${uploadItem.params.name}`}
// $FlowFixMe
uploadItem={uploadItem}
doPublishResume={doPublishResume}
doUpdateUploadRemove={doUpdateUploadRemove}
doOpenModal={doOpenModal}
/>
))}
</section>
}

View file

@ -492,7 +492,9 @@ export const FETCH_SUB_COUNT_FAILED = 'FETCH_SUB_COUNT_FAILED';
export const FETCH_SUB_COUNT_COMPLETED = 'FETCH_SUB_COUNT_COMPLETED';
// Lbry.tv
export const UPDATE_UPLOAD_ADD = 'UPDATE_UPLOAD_ADD';
export const UPDATE_UPLOAD_PROGRESS = 'UPDATE_UPLOAD_PROGRESS';
export const UPDATE_UPLOAD_REMOVE = 'UPDATE_UPLOAD_REMOVE';
// User
export const GENERATE_AUTH_TOKEN_FAILURE = 'GENERATE_AUTH_TOKEN_FAILURE';

View file

@ -363,6 +363,11 @@
}
}
.claim-upload__progress--label {
font-size: var(--font-small);
color: var(--color-text-subtitle);
}
.claim-upload__progress--outer {
width: 100%;
}
@ -857,3 +862,17 @@
margin-top: var(--spacing-s);
}
}
.web-upload-item.claim-preview {
@media (max-width: $breakpoint-small) {
display: block;
.media__thumb {
margin-bottom: var(--spacing-s);
}
.claim-preview-metadata {
display: block;
}
}
}

8
ui/util/file.js Normal file
View file

@ -0,0 +1,8 @@
// @flow
export function serializeFileObj(file: File) {
// $FlowFixMe - these are non standard, but try to include anyway.
const aux = `${String(file.lastModifiedDate)}#${String(file.webkitRelativePath)}`;
return `${file.name}#${file.type}#${file.size}#${file.lastModified}#${aux}`;
}

View file

@ -4,9 +4,14 @@
of a multipart/form-data POST request with:
- 'file' binary
- 'json_payload' collection of publish params to be passed to the server's sdk.
v2 no longer uses 'multipart/form-data'. It uses TUS to support resummable upload.
*/
import * as tus from 'tus-js-client';
import { X_LBRY_AUTH_TOKEN } from '../../ui/constants/token';
import { doUpdateUploadProgress } from 'lbryinc';
import { doUpdateUploadAdd, doUpdateUploadProgress, doUpdateUploadRemove } from 'lbryinc';
const UPLOAD_CHUNK_SIZE_BYTE = 100000000;
// A modified version of Lbry.apiCall that allows
// to perform calling methods at arbitrary urls
@ -26,6 +31,11 @@ export default function apiPublishCallViaWeb(
return apiCall(method, params, resolve, reject);
}
if (!tus.isSupported) {
reject(new Error(__('Uploading is not supported with this browser.')));
return;
}
const counter = new Date().getTime();
let fileField = filePath;
@ -38,13 +48,10 @@ export default function apiPublishCallViaWeb(
// Putting a dummy value here, the server is going to process the POSTed file
// and set the file_path itself
const body = new FormData();
if (fileField) {
body.append('file', fileField);
params.file_path = '__POST_FILE__';
delete params['remote_url'];
} else if (remoteUrl) {
body.append('remote_url', remoteUrl);
delete params['remote_url'];
}
@ -54,36 +61,78 @@ export default function apiPublishCallViaWeb(
params,
id: counter,
});
// no fileData? do the livestream remote publish
body.append('json_payload', jsonPayload);
function makeRequest(connectionString, method, token, body, params) {
function makeRequest(connectionString, token, params, file: File | string) {
const metadata = {
filename: file instanceof File ? file.name : file,
filetype: file instanceof File ? file.type : undefined,
};
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open(method, connectionString);
xhr.setRequestHeader(X_LBRY_AUTH_TOKEN, token);
xhr.responseType = 'json';
xhr.upload.onprogress = (e) => {
let percentComplete = Math.ceil((e.loaded / e.total) * 100);
window.store.dispatch(doUpdateUploadProgress(percentComplete, params, xhr));
};
xhr.onload = () => {
window.store.dispatch(doUpdateUploadProgress(undefined, params));
resolve(xhr);
};
xhr.onerror = () => {
window.store.dispatch(doUpdateUploadProgress(undefined, params));
reject(new Error(__('There was a problem with your upload. Please try again.')));
};
const uploader = new tus.Upload(fileField, {
endpoint: connectionString,
chunkSize: UPLOAD_CHUNK_SIZE_BYTE,
retryDelays: [0, 3000, 3000],
parallelUploads: 1,
removeFingerprintOnSuccess: true,
headers: { [X_LBRY_AUTH_TOKEN]: token },
metadata: metadata,
onShouldRetry: (err, retryAttempt, options) => {
window.store.dispatch(doUpdateUploadProgress({ params, status: 'retry' }));
const FORBIDDEN_ERROR = 403;
const status = err.originalResponse ? err.originalResponse.getStatus() : 0;
return status !== FORBIDDEN_ERROR;
},
onError: (error) => {
window.store.dispatch(doUpdateUploadProgress({ params, status: 'error' }));
reject(new Error(error));
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
window.store.dispatch(doUpdateUploadProgress({ params, progress: percentage }));
},
onSuccess: () => {
// Notify lbrynet server
const xhr = new XMLHttpRequest();
xhr.open('POST', `${uploader.url}/notify`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('Tus-Resumable', '1.0.0');
xhr.setRequestHeader(X_LBRY_AUTH_TOKEN, token);
xhr.responseType = 'json';
xhr.onload = () => {
window.store.dispatch(doUpdateUploadRemove(params));
resolve(xhr);
};
xhr.onerror = () => {
reject(new Error(__('There was a problem with your upload. Please try again.')));
};
xhr.onabort = () => {
window.store.dispatch(doUpdateUploadRemove(params));
};
xhr.onabort = () => {
window.store.dispatch(doUpdateUploadProgress(undefined, params));
};
xhr.send(body);
xhr.send(jsonPayload);
},
});
uploader
.findPreviousUploads()
.then((previousUploads) => {
if (previousUploads.length > 0) {
uploader.resumeFromPreviousUpload(previousUploads[0]);
}
window.store.dispatch(doUpdateUploadAdd(fileField, params, uploader));
uploader.start();
})
.catch((err) => {
reject(new Error(__('Failed to initiate upload (%err%)', { err })));
});
});
}
return makeRequest(connectionString, 'POST', token, body, params)
return makeRequest(connectionString, token, params, fileField)
.then((xhr) => {
let error;
if (xhr && xhr.response) {

View file

@ -3869,6 +3869,11 @@ buffer-fill@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
buffer-from@^0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-0.1.2.tgz#15f4b9bcef012044df31142c14333caf6e0260d0"
integrity sha512-RiWIenusJsmI2KcvqQABB83tLxCByE3upSP8QU3rJDMVFGPWLvPQJt/O1Su9moRWeH7d+Q2HYb68f6+v+tw2vg==
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@ -4634,6 +4639,14 @@ colors@~1.1.2:
resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
integrity sha1-FopHAXVran9RoSzgyXv6KMCE7WM=
combine-errors@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/combine-errors/-/combine-errors-3.0.3.tgz#f4df6740083e5703a3181110c2b10551f003da86"
integrity sha1-9N9nQAg+VwOjGBEQwrEFUfAD2oY=
dependencies:
custom-error-instance "2.1.1"
lodash.uniqby "4.5.0"
combined-stream@^1.0.6, combined-stream@~1.0.5, combined-stream@~1.0.6:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@ -5337,6 +5350,11 @@ currently-unhandled@^0.4.1:
dependencies:
array-find-index "^1.0.1"
custom-error-instance@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/custom-error-instance/-/custom-error-instance-2.1.1.tgz#3cf6391487a6629a6247eb0ca0ce00081b7e361a"
integrity sha1-PPY5FIemYppiR+sMoM4ACBt+Nho=
cyclist@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
@ -9824,7 +9842,7 @@ jest@20.0.4:
dependencies:
jest-cli "^20.0.4"
js-base64@^2.1.9:
js-base64@^2.1.9, js-base64@^2.6.1:
version "2.6.4"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4"
integrity sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==
@ -10375,11 +10393,48 @@ lodash-es@^4.17.14, lodash-es@^4.2.1:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
lodash._baseiteratee@~4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz#34a9b5543572727c3db2e78edae3c0e9e66bd102"
integrity sha1-NKm1VDVycnw9sueO2uPA6eZr0QI=
dependencies:
lodash._stringtopath "~4.8.0"
lodash._basetostring@~4.12.0:
version "4.12.0"
resolved "https://registry.yarnpkg.com/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz#9327c9dc5158866b7fa4b9d42f4638e5766dd9df"
integrity sha1-kyfJ3FFYhmt/pLnUL0Y45XZt2d8=
lodash._baseuniq@~4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8"
integrity sha1-DrtE5FaBSveQXGIS+iybLVG4Qeg=
dependencies:
lodash._createset "~4.0.0"
lodash._root "~3.0.0"
lodash._createset@~4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26"
integrity sha1-D0ZZ+7CddRlPqeK4imZE02PJ/iY=
lodash._reinterpolate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
lodash._root@~3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692"
integrity sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI=
lodash._stringtopath@~4.8.0:
version "4.8.0"
resolved "https://registry.yarnpkg.com/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz#941bcf0e64266e5fc1d66fed0a6959544c576824"
integrity sha1-lBvPDmQmbl/B1m/tCmlZVExXaCQ=
dependencies:
lodash._basetostring "~4.12.0"
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
@ -10449,6 +10504,11 @@ lodash.templatesettings@^4.0.0:
dependencies:
lodash._reinterpolate "^3.0.0"
lodash.throttle@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=
lodash.toarray@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561"
@ -10457,6 +10517,14 @@ lodash.uniq@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
lodash.uniqby@4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz#a3a17bbf62eeb6240f491846e97c1c4e2a5e1e21"
integrity sha1-o6F7v2LutiQPSRhG6XwcTipeHiE=
dependencies:
lodash._baseiteratee "~4.7.0"
lodash._baseuniq "~4.6.0"
lodash.unset@^4.5.2:
version "4.5.2"
resolved "https://registry.yarnpkg.com/lodash.unset/-/lodash.unset-4.5.2.tgz#370d1d3e85b72a7e1b0cdf2d272121306f23e4ed"
@ -12980,6 +13048,14 @@ prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0,
object-assign "^4.1.1"
react-is "^16.8.1"
proper-lockfile@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-2.0.1.tgz#159fb06193d32003f4b3691dd2ec1a634aa80d1d"
integrity sha1-FZ+wYZPTIAP0s2kd0uwaY0qoDR0=
dependencies:
graceful-fs "^4.1.2"
retry "^0.10.0"
property-information@^5.3.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69"
@ -14245,6 +14321,11 @@ ret@~0.1.10:
version "0.1.15"
resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
retry@^0.10.0:
version "0.10.1"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4"
integrity sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q=
retry@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
@ -15896,6 +15977,19 @@ tunnel@^0.0.6:
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
tus-js-client@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/tus-js-client/-/tus-js-client-2.3.0.tgz#5d76145476cea46a4e7c045a0054637cddf8dc39"
integrity sha512-I4cSwm6N5qxqCmBqenvutwSHe9ntf81lLrtf6BmLpG2v4wTl89atCQKqGgqvkodE6Lx+iKIjMbaXmfvStTg01g==
dependencies:
buffer-from "^0.1.1"
combine-errors "^3.0.3"
is-stream "^2.0.0"
js-base64 "^2.6.1"
lodash.throttle "^4.1.1"
proper-lockfile "^2.0.1"
url-parse "^1.4.3"
tween-functions@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/tween-functions/-/tween-functions-1.2.0.tgz#1ae3a50e7c60bb3def774eac707acbca73bbc3ff"