Support resume-able upload via tus (#186)

* Publish button: use spinner instead of "Publishing..."

Looks better, plus the preview could take a while sometimes.

* Refactor `doPublish`. No functional change

This is to allow `doPublish` to accept a custom payload as an input (for resuming uploads), instead of always resolving it from the redux data.

* Add doPublishResume

* 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.

* Exclude "modified date" for Firefox/Android

## Issue
It appears that the modification date of the Android file changes when selected, so that file was deemed "different" when trying to resume upload.

## Change
Exclude modification date for now. Let's assume a smart user.

* Move 'currentUploads' to 'publish' reducer

`publish` is currently rehydrated, so we can ride on that and don't need to store the `currentUploads` in `localStorage` for persistence. This would allow us to store Markdown Post data too, as `localStorage` has a 5MB limit per app.

We could have also made `webReducer` rehydrate, but in this repo, there is no need to split it to another reducer. It also makes more sense to be part of publish anyway (at least to me).

This change is mostly moving items between files, with the exception of
1. An additional REHYDRATE in the publish reducer to clean up the tusUploader.
2. Not clearing `currentUploads` in CLEAR_PUBLISH.

* Restore v1 code for livestream replay, etc.

v2 (tus) does not handle `remote_url`, so the app still needs v1 for that. Since we'll still have v1 code, use v1 for previews as well.
This commit is contained in:
infinite-persistence 2021-11-11 02:16:16 +08:00 committed by GitHub
parent b508fe8679
commit cb6a044584
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 814 additions and 325 deletions

View file

@ -8,6 +8,7 @@ const config = {
WEB_SERVER_PORT: process.env.WEB_SERVER_PORT,
LBRY_WEB_API: process.env.LBRY_WEB_API, //api.na-backend.odysee.com',
LBRY_WEB_PUBLISH_API: process.env.LBRY_WEB_PUBLISH_API,
LBRY_WEB_PUBLISH_API_V2: process.env.LBRY_WEB_PUBLISH_API_V2,
LBRY_API_URL: process.env.LBRY_API_URL, //api.lbry.com',
LBRY_WEB_STREAMING_API: process.env.LBRY_WEB_STREAMING_API, //cdn.lbryplayer.xyz',
LBRY_WEB_BUFFER_API: process.env.LBRY_WEB_BUFFER_API,

View file

@ -83,9 +83,6 @@ export const SYNC_APPLY_FAILED = 'SYNC_APPLY_FAILED';
export const SYNC_APPLY_BAD_PASSWORD = 'SYNC_APPLY_BAD_PASSWORD';
export const SYNC_RESET = 'SYNC_RESET';
// Lbry.tv
export const UPDATE_UPLOAD_PROGRESS = 'UPDATE_UPLOAD_PROGRESS';
// User
export const GENERATE_AUTH_TOKEN_FAILURE = 'GENERATE_AUTH_TOKEN_FAILURE';
export const GENERATE_AUTH_TOKEN_STARTED = 'GENERATE_AUTH_TOKEN_STARTED';

View file

@ -27,7 +27,6 @@ export {
doResetSync,
doSyncEncryptAndDecrypt,
} from 'redux/actions/sync';
export { doUpdateUploadProgress } from './redux/actions/web';
// reducers
export { authReducer } from './redux/reducers/auth';
@ -37,7 +36,6 @@ export { filteredReducer } from './redux/reducers/filtered';
// export { homepageReducer } from './redux/reducers/homepage';
export { statsReducer } from './redux/reducers/stats';
export { syncReducer } from './redux/reducers/sync';
export { webReducer } from './redux/reducers/web';
// selectors
export { selectAuthToken, selectIsAuthenticating } from './redux/selectors/auth';
@ -70,4 +68,3 @@ export {
selectSyncApplyErrorMessage,
selectSyncApplyPasswordError,
} from './redux/selectors/sync';
export { selectCurrentUploads, selectUploadCount } from './redux/selectors/web';

View file

@ -1,12 +0,0 @@
// @flow
import * as ACTIONS from 'constants/action_types';
export const doUpdateUploadProgress = (
progress: string,
params: { [key: string]: any },
xhr: any
) => (dispatch: Dispatch) =>
dispatch({
type: ACTIONS.UPDATE_UPLOAD_PROGRESS,
data: { progress, params, xhr },
});

View file

@ -1,62 +0,0 @@
// @flow
import * as ACTIONS from 'constants/action_types';
/*
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,
};
export type TvState = {
currentUploads: { [key: string]: UploadItem },
};
const reducers = {};
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];
}
});
} else {
currentUploads = Object.assign({}, state.currentUploads);
currentUploads[key] = { progress, params, xhr };
}
return { ...state, currentUploads };
};
export function webReducer(state: TvState = defaultState, action: any) {
const handler = reducers[action.type];
if (handler) return handler(state, action);
return state;
}

View file

@ -1,10 +0,0 @@
import { createSelector } from 'reselect';
const selectState = (state) => state.web || {};
export const selectCurrentUploads = (state) => selectState(state).currentUploads;
export const selectUploadCount = createSelector(
selectCurrentUploads,
(currentUploads) => currentUploads && Object.keys(currentUploads).length
);

21
flow-typed/publish.js vendored
View file

@ -52,3 +52,24 @@ declare type PublishParams = {
nsfw: boolean,
tags: Array<Tag>,
};
declare type TusUploader = any;
declare type FileUploadSdkParams = {
file_path: string,
name: ?string,
preview?: boolean,
remote_url?: string,
thumbnail_url?: string,
title?: string,
};
declare type FileUploadItem = {
params: FileUploadSdkParams,
file: File,
fileFingerprint: string,
progress: string,
status?: string,
uploader?: TusUploader | XMLHttpRequest,
resumable: boolean,
};

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",
@ -1430,7 +1440,6 @@
"Log in to %SITE_NAME%": "Log in to %SITE_NAME%",
"Log in": "Log in",
"Not Yet": "Not Yet",
"Preparing...": "Preparing...",
"Confirm Upload": "Confirm Upload",
"Confirm Edit": "Confirm Edit",
"Create Livestream": "Create Livestream",

View file

@ -1,6 +1,5 @@
import { hot } from 'react-hot-loader/root';
import { connect } from 'react-redux';
import { selectUploadCount } from 'lbryinc';
import { selectGetSyncErrorMessage, selectSyncFatalError } from 'redux/selectors/sync';
import { doFetchAccessToken, doUserSetReferrer } from 'redux/actions/user';
import { selectUser, selectAccessToken, selectUserVerifiedEmail } from 'redux/selectors/user';
@ -22,6 +21,7 @@ import {
selectActiveChannelClaim,
selectIsReloadRequired,
} from 'redux/selectors/app';
import { selectUploadCount } from 'redux/selectors/publish';
import { doGetWalletSyncPreference, doSetLanguage } from 'redux/actions/settings';
import { doSyncLoop } from 'redux/actions/sync';
import {

View file

@ -325,7 +325,7 @@ function PublishForm(props: Props) {
submitLabel = __('Uploading...');
}
} else if (previewing) {
submitLabel = __('Preparing...');
submitLabel = <Spinner type="small" />;
} else {
if (isStillEditing) {
submitLabel = __('Save');

View file

@ -1,13 +1,18 @@
import { connect } from 'react-redux';
import { selectCurrentUploads, selectUploadCount } from 'lbryinc';
import { doOpenModal } from 'redux/actions/app';
import { doPublishResume, doUpdateUploadRemove } from 'redux/actions/publish';
import { selectCurrentUploads, selectUploadCount } from 'redux/selectors/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,155 @@
// @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, resumable, uploader } = 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 (uploader) {
if (resumable) {
// $FlowFixMe - couldn't resolve to TusUploader manually.
uploader.abort(true); // TUS
} else {
uploader.abort(); // XHR
}
}
doUpdateUploadRemove(params);
closeModal();
},
});
}
function resolveProgressStr() {
if (!uploader) {
return __('Stopped.');
}
if (resumable) {
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...');
}
} else {
return __('Uploading...');
}
}
function getRetryButton() {
if (!resumable) {
return null;
}
if (uploader) {
// Should still be uploading. Don't show.
return null;
} else {
// Refreshed or connection broken.
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}
/>
);
}
}
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...
{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

@ -331,6 +331,9 @@ export const PUBLISH_FAIL = 'PUBLISH_FAIL';
export const CLEAR_PUBLISH_ERROR = 'CLEAR_PUBLISH_ERROR';
export const REMOVE_PENDING_PUBLISH = 'REMOVE_PENDING_PUBLISH';
export const DO_PREPARE_EDIT = 'DO_PREPARE_EDIT';
export const UPDATE_UPLOAD_ADD = 'UPDATE_UPLOAD_ADD';
export const UPDATE_UPLOAD_PROGRESS = 'UPDATE_UPLOAD_PROGRESS';
export const UPDATE_UPLOAD_REMOVE = 'UPDATE_UPLOAD_REMOVE';
// Media
export const MEDIA_PLAY = 'MEDIA_PLAY';
@ -491,9 +494,6 @@ export const FETCH_SUB_COUNT_STARTED = 'FETCH_SUB_COUNT_STARTED';
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_PROGRESS = 'UPDATE_UPLOAD_PROGRESS';
// User
export const GENERATE_AUTH_TOKEN_FAILURE = 'GENERATE_AUTH_TOKEN_FAILURE';
export const GENERATE_AUTH_TOKEN_STARTED = 'GENERATE_AUTH_TOKEN_STARTED';

View file

@ -35,7 +35,7 @@ import {
doAuthTokenRefresh,
} from 'util/saved-passwords';
import { X_LBRY_AUTH_TOKEN } from 'constants/token';
import { LBRY_WEB_API, DEFAULT_LANGUAGE, LBRY_API_URL, LBRY_WEB_PUBLISH_API } from 'config';
import { LBRY_WEB_API, DEFAULT_LANGUAGE, LBRY_API_URL } from 'config';
// Import 3rd-party styles before ours for the current way we are code-splitting.
import 'scss/third-party.scss';
@ -69,7 +69,6 @@ sdkAPIHost = LBRY_WEB_API;
export const SDK_API_PATH = `${sdkAPIHost}/api/v1`;
const proxyURL = `${SDK_API_PATH}/proxy`;
const publishURL = LBRY_WEB_PUBLISH_API; // || `${SDK_API_PATH}/proxy`;
Lbry.setDaemonConnectionString(proxyURL);
@ -79,7 +78,6 @@ Lbry.setOverride(
new Promise((resolve, reject) => {
apiPublishCallViaWeb(
apiCall,
publishURL,
Lbry.getApiRequestHeaders() && Object.keys(Lbry.getApiRequestHeaders()).includes(X_LBRY_AUTH_TOKEN)
? Lbry.getApiRequestHeaders()[X_LBRY_AUTH_TOKEN]
: '',

View file

@ -5,9 +5,9 @@ import {
selectMyClaimsPageItemCount,
selectFetchingMyClaimsPageError,
} from 'redux/selectors/claims';
import { selectUploadCount } from 'redux/selectors/publish';
import { doFetchClaimListMine, doCheckPendingClaims } from 'redux/actions/claims';
import { doClearPublish } from 'redux/actions/publish';
import { selectUploadCount } from 'lbryinc';
import FileListPublished from './view';
import { withRouter } from 'react-router';
import { MY_CLAIMS_PAGE_SIZE, PAGE_PARAM, PAGE_SIZE_PARAM } from 'constants/claim';

View file

@ -1,6 +1,6 @@
import { combineReducers } from 'redux';
import { connectRouter } from 'connected-react-router';
import { costInfoReducer, blacklistReducer, filteredReducer, statsReducer, webReducer } from 'lbryinc';
import { costInfoReducer, blacklistReducer, filteredReducer, statsReducer } from 'lbryinc';
import { claimsReducer } from 'redux/reducers/claims';
import { fileInfoReducer } from 'redux/reducers/file_info';
import { walletReducer } from 'redux/reducers/wallet';
@ -50,6 +50,5 @@ export default (history) =>
user: userReducer,
wallet: walletReducer,
sync: syncReducer,
web: webReducer,
collections: collectionsReducer,
});

View file

@ -6,6 +6,7 @@ import { batchActions } from 'util/batch-actions';
import { doCheckPendingClaims } from 'redux/actions/claims';
import {
makeSelectClaimForUri,
selectMyActiveClaims,
selectMyClaims,
selectMyChannelClaims,
// selectMyClaimsWithoutChannels,
@ -47,6 +48,147 @@ function resolveClaimTypeForAnalytics(claim) {
}
export const NO_FILE = '---';
function resolvePublishPayload(publishData, myClaimForUri, myChannels, preview) {
const {
name,
bid,
filePath,
description,
language,
releaseTimeEdited,
// license,
licenseUrl,
useLBRYUploader,
licenseType,
otherLicenseDescription,
thumbnail,
channel,
title,
contentIsFree,
fee,
// uri,
tags,
// locations,
optimize,
isLivestreamPublish,
remoteFileUrl,
} = publishData;
// Handle scenario where we have a claim that has the same name as a channel we are publishing with.
const myClaimForUriEditing = myClaimForUri && myClaimForUri.name === name ? myClaimForUri : null;
let publishingLicense;
switch (licenseType) {
case COPYRIGHT:
case OTHER:
publishingLicense = otherLicenseDescription;
break;
default:
publishingLicense = licenseType;
}
// get the claim id from the channel name, we will use that instead
const namedChannelClaim = myChannels ? myChannels.find((myChannel) => myChannel.name === channel) : null;
const channelId = namedChannelClaim ? namedChannelClaim.claim_id : '';
const publishPayload: {
name: ?string,
bid: string,
description?: string,
channel_id?: string,
file_path?: string,
license_url?: string,
license?: string,
thumbnail_url?: string,
release_time?: number,
fee_currency?: string,
fee_amount?: string,
languages?: Array<string>,
tags: Array<string>,
locations?: Array<any>,
blocking: boolean,
optimize_file?: boolean,
preview?: boolean,
remote_url?: string,
} = {
name,
title,
description,
locations: [],
bid: creditsToString(bid),
languages: [language],
tags: tags && tags.map((tag) => tag.name),
thumbnail_url: thumbnail,
blocking: true,
preview: false,
};
// Temporary solution to keep the same publish flow with the new tags api
// Eventually we will allow users to enter their own tags on publish
// `nsfw` will probably be removed
if (remoteFileUrl) {
publishPayload.remote_url = remoteFileUrl;
}
if (publishingLicense) {
publishPayload.license = publishingLicense;
}
if (licenseUrl) {
publishPayload.license_url = licenseUrl;
}
if (thumbnail) {
publishPayload.thumbnail_url = thumbnail;
}
if (useLBRYUploader) {
publishPayload.tags.push('lbry-first');
}
// Set release time to curret date. On edits, keep original release/transaction time as release_time
if (releaseTimeEdited) {
publishPayload.release_time = releaseTimeEdited;
} else if (myClaimForUriEditing && myClaimForUriEditing.value.release_time) {
publishPayload.release_time = Number(myClaimForUri.value.release_time);
} else if (myClaimForUriEditing && myClaimForUriEditing.timestamp) {
publishPayload.release_time = Number(myClaimForUriEditing.timestamp);
} else {
publishPayload.release_time = Number(Math.round(Date.now() / 1000));
}
if (channelId) {
publishPayload.channel_id = channelId;
}
if (myClaimForUriEditing && myClaimForUriEditing.value && myClaimForUriEditing.value.locations) {
publishPayload.locations = myClaimForUriEditing.value.locations;
}
if (!contentIsFree && fee && fee.currency && Number(fee.amount) > 0) {
publishPayload.fee_currency = fee.currency;
publishPayload.fee_amount = creditsToString(fee.amount);
}
if (optimize) {
publishPayload.optimize_file = true;
}
// Only pass file on new uploads, not metadata only edits.
// The sdk will figure it out
if (filePath && !isLivestreamPublish) {
publishPayload.file_path = filePath;
}
if (preview) {
publishPayload.preview = true;
publishPayload.optimize_file = false;
}
return publishPayload;
}
export const doPublishDesktop = (filePath: string, preview?: boolean) => (dispatch: Dispatch, getState: () => {}) => {
const publishPreview = (previewResponse) => {
dispatch(
@ -148,6 +290,62 @@ export const doPublishDesktop = (filePath: string, preview?: boolean) => (dispat
dispatch(doPublish(publishSuccess, publishFail));
};
export const doPublishResume = (publishPayload: any) => (dispatch: Dispatch, getState: () => {}) => {
const publishSuccess = (successResponse, lbryFirstError) => {
const state = getState();
const myClaimIds: Set<string> = selectMyActiveClaims(state);
const pendingClaim = successResponse.outputs[0];
const { permanent_url: url } = pendingClaim;
analytics.apiLogPublish(pendingClaim);
// We have to fake a temp claim until the new pending one is returned by claim_list_mine
// We can't rely on claim_list_mine because there might be some delay before the new claims are returned
// Doing this allows us to show the pending claim immediately, it will get overwritten by the real one
const isEdit = myClaimIds.has(pendingClaim.claim_id);
const actions = [];
actions.push({
type: ACTIONS.PUBLISH_SUCCESS,
data: {
type: resolveClaimTypeForAnalytics(pendingClaim),
},
});
actions.push({
type: ACTIONS.UPDATE_PENDING_CLAIMS,
data: {
claims: [pendingClaim],
},
});
dispatch(batchActions(...actions));
dispatch(
doOpenModal(MODALS.PUBLISH, {
uri: url,
isEdit,
lbryFirstError,
})
);
dispatch(doCheckPendingClaims());
};
const publishFail = (error) => {
const actions = [];
actions.push({
type: ACTIONS.PUBLISH_FAIL,
});
actions.push(doError(error.message));
dispatch(batchActions(...actions));
};
dispatch(doPublish(publishSuccess, publishFail, false, publishPayload));
};
export const doResetThumbnailStatus = () => (dispatch: Dispatch) => {
dispatch({
type: ACTIONS.UPDATE_PUBLISH_FORM,
@ -373,7 +571,7 @@ export const doPrepareEdit = (claim: StreamClaim, uri: string, fileInfo: FileLis
dispatch({ type: ACTIONS.DO_PREPARE_EDIT, data: publishData });
};
export const doPublish = (success: Function, fail: Function, preview: Function) => (
export const doPublish = (success: Function, fail: Function, preview: Function, payload: any) => (
dispatch: Dispatch,
getState: () => {}
) => {
@ -388,139 +586,9 @@ export const doPublish = (success: Function, fail: Function, preview: Function)
// get redux publish form
const publishData = selectPublishFormValues(state);
// destructure the data values
const {
name,
bid,
filePath,
description,
language,
releaseTimeEdited,
// license,
licenseUrl,
useLBRYUploader,
licenseType,
otherLicenseDescription,
thumbnail,
channel,
title,
contentIsFree,
fee,
// uri,
tags,
// locations,
optimize,
isLivestreamPublish,
remoteFileUrl,
} = publishData;
// Handle scenario where we have a claim that has the same name as a channel we are publishing with.
const myClaimForUriEditing = myClaimForUri && myClaimForUri.name === name ? myClaimForUri : null;
let publishingLicense;
switch (licenseType) {
case COPYRIGHT:
case OTHER:
publishingLicense = otherLicenseDescription;
break;
default:
publishingLicense = licenseType;
}
// get the claim id from the channel name, we will use that instead
const namedChannelClaim = myChannels ? myChannels.find((myChannel) => myChannel.name === channel) : null;
const channelId = namedChannelClaim ? namedChannelClaim.claim_id : '';
const publishPayload: {
name: ?string,
bid: string,
description?: string,
channel_id?: string,
file_path?: string,
license_url?: string,
license?: string,
thumbnail_url?: string,
release_time?: number,
fee_currency?: string,
fee_amount?: string,
languages?: Array<string>,
tags: Array<string>,
locations?: Array<any>,
blocking: boolean,
optimize_file?: boolean,
preview?: boolean,
remote_url?: string,
} = {
name,
title,
description,
locations: [],
bid: creditsToString(bid),
languages: [language],
tags: tags && tags.map((tag) => tag.name),
thumbnail_url: thumbnail,
blocking: true,
preview: false,
};
// Temporary solution to keep the same publish flow with the new tags api
// Eventually we will allow users to enter their own tags on publish
// `nsfw` will probably be removed
if (remoteFileUrl) {
publishPayload.remote_url = remoteFileUrl;
}
if (publishingLicense) {
publishPayload.license = publishingLicense;
}
if (licenseUrl) {
publishPayload.license_url = licenseUrl;
}
if (thumbnail) {
publishPayload.thumbnail_url = thumbnail;
}
if (useLBRYUploader) {
publishPayload.tags.push('lbry-first');
}
// Set release time to curret date. On edits, keep original release/transaction time as release_time
if (releaseTimeEdited) {
publishPayload.release_time = releaseTimeEdited;
} else if (myClaimForUriEditing && myClaimForUriEditing.value.release_time) {
publishPayload.release_time = Number(myClaimForUri.value.release_time);
} else if (myClaimForUriEditing && myClaimForUriEditing.timestamp) {
publishPayload.release_time = Number(myClaimForUriEditing.timestamp);
} else {
publishPayload.release_time = Number(Math.round(Date.now() / 1000));
}
if (channelId) {
publishPayload.channel_id = channelId;
}
if (myClaimForUriEditing && myClaimForUriEditing.value && myClaimForUriEditing.value.locations) {
publishPayload.locations = myClaimForUriEditing.value.locations;
}
if (!contentIsFree && fee && fee.currency && Number(fee.amount) > 0) {
publishPayload.fee_currency = fee.currency;
publishPayload.fee_amount = creditsToString(fee.amount);
}
if (optimize) {
publishPayload.optimize_file = true;
}
// Only pass file on new uploads, not metadata only edits.
// The sdk will figure it out
if (filePath && !isLivestreamPublish) publishPayload.file_path = filePath;
const publishPayload = payload || resolvePublishPayload(publishData, myClaimForUri, myChannels, preview);
if (preview) {
publishPayload.preview = true;
publishPayload.optimize_file = false;
return Lbry.publish(publishPayload).then((previewResponse: PublishResponse) => {
return preview(previewResponse);
}, fail);
@ -620,3 +688,35 @@ export const doCheckReflectingFiles = () => (dispatch: Dispatch, getState: GetSt
}, 5000);
}
};
export function doUpdateUploadAdd(
file: File | string,
params: { [key: string]: any },
uploader: TusUploader | XMLHttpRequest
) {
return (dispatch: Dispatch, getState: GetState) => {
dispatch({
type: ACTIONS.UPDATE_UPLOAD_ADD,
data: { file, params, uploader },
});
};
}
export const doUpdateUploadProgress = (props: {
params: { [key: string]: any },
progress?: string,
status?: string,
}) => (dispatch: Dispatch) =>
dispatch({
type: ACTIONS.UPDATE_UPLOAD_PROGRESS,
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,10 +1,13 @@
// @flow
import { handleActions } from 'util/redux-utils';
import { buildURI } from 'util/lbryURI';
import { serializeFileObj } from 'util/file';
import * as ACTIONS from 'constants/action_types';
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
import { CHANNEL_ANONYMOUS } from 'constants/claim';
const getKeyFromParam = (params) => `${params.name}#${params.channel || 'anonymous'}`;
type PublishState = {
editingURI: ?string,
fileText: ?string,
@ -38,6 +41,7 @@ type PublishState = {
tags: Array<string>,
optimize: boolean,
useLBRYUploader: boolean,
currentUploads: { [key: string]: FileUploadItem },
};
const defaultState: PublishState = {
@ -78,6 +82,7 @@ const defaultState: PublishState = {
publishError: undefined,
optimize: false,
useLBRYUploader: false,
currentUploads: {},
};
export const publishReducer = handleActions(
@ -96,6 +101,7 @@ export const publishReducer = handleActions(
bid: state.bid,
optimize: state.optimize,
language: state.language,
currentUploads: state.currentUploads,
}),
[ACTIONS.PUBLISH_START]: (state: PublishState): PublishState => ({
...state,
@ -127,8 +133,76 @@ export const publishReducer = handleActions(
...publishData,
editingURI: uri,
uri: shortUri,
currentUploads: state.currentUploads,
};
},
[ACTIONS.UPDATE_UPLOAD_ADD]: (state: PublishState, action) => {
const { file, params, uploader } = action.data;
const key = getKeyFromParam(params);
const currentUploads = Object.assign({}, state.currentUploads);
currentUploads[key] = {
file,
fileFingerprint: file ? serializeFileObj(file) : undefined, // TODO: get hash instead?
progress: '0',
params,
uploader,
resumable: !(uploader instanceof XMLHttpRequest),
};
return { ...state, currentUploads };
},
[ACTIONS.UPDATE_UPLOAD_PROGRESS]: (state: PublishState, action) => {
const { params, progress, status } = action.data;
const key = getKeyFromParam(params);
const currentUploads = Object.assign({}, state.currentUploads);
if (!currentUploads[key]) {
return state;
}
if (progress) {
currentUploads[key].progress = progress;
delete currentUploads[key].status;
} else if (status) {
currentUploads[key].status = status;
if (status === 'error') {
delete currentUploads[key].uploader;
}
}
return { ...state, currentUploads };
},
[ACTIONS.UPDATE_UPLOAD_REMOVE]: (state: PublishState, action) => {
const { params } = action.data;
const key = getKeyFromParam(params);
const currentUploads = Object.assign({}, state.currentUploads);
delete currentUploads[key];
return { ...state, currentUploads };
},
[ACTIONS.REHYDRATE]: (state: PublishState, action) => {
if (action && action.payload && action.payload.publish) {
const newPublish = { ...action.payload.publish };
// Cleanup for 'publish::currentUploads'
if (newPublish.currentUploads) {
Object.keys(newPublish.currentUploads).forEach((key) => {
const params = newPublish.currentUploads[key].params;
if (!params || Object.keys(params).length === 0) {
delete newPublish.currentUploads[key];
} else {
delete newPublish.currentUploads[key].uploader;
}
});
}
return newPublish;
}
return state;
},
},
defaultState
);

View file

@ -119,3 +119,10 @@ export const selectTakeOverAmount = createSelector(
return null;
}
);
export const selectCurrentUploads = (state) => selectState(state).currentUploads;
export const selectUploadCount = createSelector(
selectCurrentUploads,
(currentUploads) => currentUploads && Object.keys(currentUploads).length
);

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;
}
}
}

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

@ -0,0 +1,5 @@
// @flow
export function serializeFileObj(file: File) {
return `${file.name}#${file.type}#${file.size}`;
}

70
web/setup/publish-v1.js Normal file
View file

@ -0,0 +1,70 @@
// @flow
// https://api.na-backend.odysee.com/api/v1/proxy currently expects publish to
// consist of a multipart/form-data POST request with:
// - 'file' binary
// - 'json_payload' publish params to be passed to the server's sdk.
import { X_LBRY_AUTH_TOKEN } from '../../ui/constants/token';
import { doUpdateUploadAdd, doUpdateUploadProgress, doUpdateUploadRemove } from '../../ui/redux/actions/publish';
import { LBRY_WEB_PUBLISH_API } from 'config';
const ENDPOINT = LBRY_WEB_PUBLISH_API;
const ENDPOINT_METHOD = 'publish';
export function makeUploadRequest(
token: string,
params: FileUploadSdkParams,
file: File | string,
isPreview?: boolean
) {
const { remote_url: remoteUrl } = params;
const body = new FormData();
if (file) {
body.append('file', file);
delete params['remote_url'];
} else if (remoteUrl) {
body.append('remote_url', remoteUrl);
delete params['remote_url'];
}
const jsonPayload = JSON.stringify({
jsonrpc: '2.0',
method: ENDPOINT_METHOD,
params,
id: new Date().getTime(),
});
// no fileData? do the livestream remote publish
body.append('json_payload', jsonPayload);
return new Promise<any>((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open('POST', ENDPOINT);
xhr.setRequestHeader(X_LBRY_AUTH_TOKEN, token);
xhr.responseType = 'json';
xhr.upload.onprogress = (e) => {
const percentage = ((e.loaded / e.total) * 100).toFixed(2);
window.store.dispatch(doUpdateUploadProgress({ params, progress: percentage }));
};
xhr.onload = () => {
window.store.dispatch(doUpdateUploadRemove(params));
resolve(xhr);
};
xhr.onerror = () => {
window.store.dispatch(doUpdateUploadProgress({ params, status: 'error' }));
reject(new Error(__('There was a problem with your upload. Please try again.')));
};
xhr.onabort = () => {
window.store.dispatch(doUpdateUploadRemove(params));
};
if (!isPreview) {
window.store.dispatch(doUpdateUploadAdd(file, params, xhr));
}
xhr.send(body);
});
}

100
web/setup/publish-v2.js Normal file
View file

@ -0,0 +1,100 @@
// @flow
import * as tus from 'tus-js-client';
import { X_LBRY_AUTH_TOKEN } from '../../ui/constants/token';
import { doUpdateUploadAdd, doUpdateUploadProgress, doUpdateUploadRemove } from '../../ui/redux/actions/publish';
import { LBRY_WEB_PUBLISH_API_V2 } from 'config';
const RESUMABLE_ENDPOINT = LBRY_WEB_PUBLISH_API_V2;
const RESUMABLE_ENDPOINT_METHOD = 'publish';
const UPLOAD_CHUNK_SIZE_BYTE = 100000000;
export function makeResumableUploadRequest(
token: string,
params: FileUploadSdkParams,
file: File | string,
isPreview?: boolean
) {
return new Promise<any>((resolve, reject) => {
if (!RESUMABLE_ENDPOINT) {
reject(new Error('Publish: endpoint undefined'));
}
// @if NODE_ENV!='production'
if (params.remote_url) {
reject(new Error('Publish: v2 does not support remote_url'));
}
// @endif
const jsonPayload = JSON.stringify({
jsonrpc: '2.0',
method: RESUMABLE_ENDPOINT_METHOD,
params,
id: new Date().getTime(),
});
const uploader = new tus.Upload(file, {
endpoint: RESUMABLE_ENDPOINT,
chunkSize: UPLOAD_CHUNK_SIZE_BYTE,
retryDelays: [0, 3000, 3000],
parallelUploads: 1,
removeFingerprintOnSuccess: true,
headers: { [X_LBRY_AUTH_TOKEN]: token },
metadata: {
filename: file instanceof File ? file.name : file,
filetype: file instanceof File ? file.type : undefined,
},
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.send(jsonPayload);
},
});
uploader
.findPreviousUploads()
.then((previousUploads) => {
if (previousUploads.length > 0) {
uploader.resumeFromPreviousUpload(previousUploads[0]);
}
if (!isPreview) {
window.store.dispatch(doUpdateUploadAdd(file, params, uploader));
}
uploader.start();
})
.catch((err) => {
reject(new Error(__('Failed to initiate upload (%err%)', { err })));
});
});
}

View file

@ -1,22 +1,16 @@
// @flow
/*
https://api.na-backend.odysee.com/api/v1/proxy currently expects publish to consist
of a multipart/form-data POST request with:
- 'file' binary
- 'json_payload' collection of publish params to be passed to the server's sdk.
*/
import { X_LBRY_AUTH_TOKEN } from '../../ui/constants/token';
import { doUpdateUploadProgress } from 'lbryinc';
import * as tus from 'tus-js-client';
import { makeUploadRequest } from './publish-v1';
import { makeResumableUploadRequest } from './publish-v2';
// A modified version of Lbry.apiCall that allows
// to perform calling methods at arbitrary urls
// and pass form file fields
export default function apiPublishCallViaWeb(
apiCall: (any, any, any, any) => any,
connectionString: string,
token: string,
method: string,
params: { file_path: string, preview: boolean, remote_url?: string }, // new param for remoteUrl
params: FileUploadSdkParams,
resolve: Function,
reject: Function
) {
@ -26,7 +20,6 @@ export default function apiPublishCallViaWeb(
return apiCall(method, params, resolve, reject);
}
const counter = new Date().getTime();
let fileField = filePath;
if (preview) {
@ -37,53 +30,16 @@ 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'];
}
const jsonPayload = JSON.stringify({
jsonrpc: '2.0',
method,
params,
id: counter,
});
// no fileData? do the livestream remote publish
body.append('json_payload', jsonPayload);
const useV1 = remoteUrl || preview || !tus.isSupported;
function makeRequest(connectionString, method, token, body, params) {
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.')));
};
// Note: both function signature (params) should match.
const makeRequest = useV1 ? makeUploadRequest : makeResumableUploadRequest;
xhr.onabort = () => {
window.store.dispatch(doUpdateUploadProgress(undefined, params));
};
xhr.send(body);
});
}
return makeRequest(connectionString, 'POST', token, body, params)
return makeRequest(token, params, fileField, preview)
.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"