From 157b50c58e8d55d69a0257bf93c29c3868d44211 Mon Sep 17 00:00:00 2001 From: infinite-persistence <64950861+infinite-persistence@users.noreply.github.com> Date: Tue, 7 Dec 2021 06:48:09 -0800 Subject: [PATCH] Upload: tab sync and various fixes (#428) * Upload: fix redux key clash ## Issue `params` is the "final" value that will be passed to the SDK and `channel` is not a valid argument (it should be `channel_name`). Also, it seems like we only pass the channel ID now and skip the channel name entirely. For the anonymous case, a clash will still happen when since the channel part is hardcoded to `anonymous`. ## Approach Generate a guid in `params` and use that as the key to handle all the cases above. We couldn't use the `uploadUrl` because v1 doesn't have it. The old formula is retained to allow users to retry or cancel their existing uploads one last time (otherwise it will persist forever). The next upload will be using the new key. * Upload: add tab-locking ## Issue - The previous code does detect uploads from multiple tabs, but it was done by handling the CONFLICT error message from the backend. At certain corner-cases, this does not work well. A better way is to not allow resumption while the same file is being uploading from another tab. - When an upload from 1 tab finishes, the GUI on the other tab does not remove the completed item. User either have to refresh or click Cancel. Clicking Cancel results in the 404 backend error. This should be avoided. ## Approach - Added tab synchronization and locking by passing the "locked" and "removed" information through `localStorage`. ## Other considered approaches - Wallet sync -- but decided not to pollute the wallet. - 3rd-party redux tab syncing -- but decided it's not worth adding another module for 1 usage. * Upload: check if locked before confirming delete ## Reproduce Have 2 tabs + paused upload Open "cancel" dialog in one of the tabs. Continue upload in other tab Confirm cancellation in first tab Upload disappears from both tabs, but based on network traffic the upload keeps happening. (If upload finishes the claim seems to get created) --- flow-typed/publish.js | 3 +- static/app-strings.json | 2 + ui/component/app/view.jsx | 22 ++- .../internal/web-upload-item.jsx | 41 ++++-- ui/component/webUploadList/view.jsx | 2 +- ui/constants/storage.js | 3 + ui/redux/actions/publish.js | 21 ++- ui/redux/reducers/publish.js | 68 ++++++--- ui/util/storage.js | 15 ++ ui/util/tus.js | 129 ++++++++++++++++++ web/setup/publish-v1.js | 12 +- web/setup/publish-v2.js | 19 ++- web/setup/publish.js | 7 + 13 files changed, 286 insertions(+), 58 deletions(-) create mode 100644 ui/constants/storage.js create mode 100644 ui/util/storage.js create mode 100644 ui/util/tus.js diff --git a/flow-typed/publish.js b/flow-typed/publish.js index 72042d33c..88957ddd6 100644 --- a/flow-typed/publish.js +++ b/flow-typed/publish.js @@ -62,7 +62,8 @@ declare type FileUploadSdkParams = { remote_url?: string, thumbnail_url?: string, title?: string, - // Temporary values + // Temporary values; remove when passing to SDK + guid: string, uploadUrl?: string, }; diff --git a/static/app-strings.json b/static/app-strings.json index 46815230d..e79a29143 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1307,6 +1307,8 @@ "Uploading...": "Uploading...", "Creating...": "Creating...", "Stopped. Duplicate session detected.": "Stopped. Duplicate session detected.", + "File being uploaded in another tab or window.": "File being uploaded in another tab or window.", + "There are pending uploads.": "There are pending uploads.", "Use a URL": "Use a URL", "Edit Cover Image": "Edit Cover Image", "Cover Image": "Cover Image", diff --git a/ui/component/app/view.jsx b/ui/component/app/view.jsx index 3d3f065c1..0ebd85cfe 100644 --- a/ui/component/app/view.jsx +++ b/ui/component/app/view.jsx @@ -2,6 +2,7 @@ import * as PAGES from 'constants/pages'; import React, { useEffect, useRef, useState, useLayoutEffect } from 'react'; import { lazyImport } from 'util/lazyImport'; +import { tusUnlockAndNotify, tusHandleTabUpdates } from 'util/tus'; import classnames from 'classnames'; import analytics from 'analytics'; import { setSearchUserId } from 'redux/actions/search'; @@ -231,12 +232,29 @@ function App(props: Props) { useEffect(() => { if (!uploadCount) return; + + const handleUnload = (event) => tusUnlockAndNotify(); const handleBeforeUnload = (event) => { event.preventDefault(); - event.returnValue = 'magic'; // without setting this to something it doesn't work + event.returnValue = __('There are pending uploads.'); // without setting this to something it doesn't work }; + + window.addEventListener('unload', handleUnload); window.addEventListener('beforeunload', handleBeforeUnload); - return () => window.removeEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('unload', handleUnload); + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [uploadCount]); + + useEffect(() => { + if (!uploadCount) return; + + const onStorageUpdate = (e) => tusHandleTabUpdates(e.key); + window.addEventListener('storage', onStorageUpdate); + + return () => window.removeEventListener('storage', onStorageUpdate); }, [uploadCount]); // allows user to pause miniplayer using the spacebar without the page scrolling down diff --git a/ui/component/webUploadList/internal/web-upload-item.jsx b/ui/component/webUploadList/internal/web-upload-item.jsx index 24d7494c3..de9550950 100644 --- a/ui/component/webUploadList/internal/web-upload-item.jsx +++ b/ui/component/webUploadList/internal/web-upload-item.jsx @@ -5,11 +5,12 @@ import Button from 'component/button'; import FileThumbnail from 'component/fileThumbnail'; import * as MODALS from 'constants/modal_types'; import { serializeFileObj } from 'util/file'; +import { tusIsSessionLocked } from 'util/tus'; type Props = { uploadItem: FileUploadItem, doPublishResume: (any) => void, - doUpdateUploadRemove: (any) => void, + doUpdateUploadRemove: (string, any) => void, doOpenModal: (string, {}) => void, }; @@ -18,11 +19,16 @@ export default function WebUploadItem(props: Props) { const { params, file, fileFingerprint, progress, status, resumable, uploader } = uploadItem; const [showFileSelector, setShowFileSelector] = useState(false); + const locked = tusIsSessionLocked(params.guid); function handleFileChange(newFile: WebFile, clearName = true) { if (serializeFileObj(newFile) === fileFingerprint) { setShowFileSelector(false); doPublishResume({ ...params, file_path: newFile }); + if (!params.guid) { + // Can remove this if-block after January 2022. + doUpdateUploadRemove('', params); + } } else { doOpenModal(MODALS.CONFIRM, { title: __('Invalid file'), @@ -40,21 +46,34 @@ export default function WebUploadItem(props: Props) { subtitle: __('Cancel and remove the selected upload?'), body: params.name ?

{`lbry://${params.name}`}

: undefined, onConfirm: (closeModal) => { - if (uploader) { - if (resumable) { - // $FlowFixMe - couldn't resolve to TusUploader manually. - uploader.abort(true); // TUS - } else { - uploader.abort(); // XHR + if (tusIsSessionLocked(params.guid)) { + // Corner-case: it's possible for the upload to resume in another tab + // after the modal has appeared. Make a final lock-check here. + // We can invoke a toast here, but just do nothing for now. + // The upload status should make things obvious. + } else { + if (uploader) { + if (resumable) { + // $FlowFixMe - couldn't resolve to TusUploader manually. + uploader.abort(true); // TUS + } else { + uploader.abort(); // XHR + } } + + // The second parameter (params) can be removed after January 2022. + doUpdateUploadRemove(params.guid, params); } - doUpdateUploadRemove(params); closeModal(); }, }); } function resolveProgressStr() { + if (locked) { + return __('File being uploaded in another tab or window.'); + } + if (!uploader) { return __('Stopped.'); } @@ -81,7 +100,7 @@ export default function WebUploadItem(props: Props) { } function getRetryButton() { - if (!resumable) { + if (!resumable || locked) { return null; } @@ -109,7 +128,9 @@ export default function WebUploadItem(props: Props) { } function getCancelButton() { - return