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 cdf861559..29a4e32e8 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,6 +19,7 @@ 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) {
@@ -25,7 +27,7 @@ export default function WebUploadItem(props: Props) {
doPublishResume({ ...params, file_path: newFile });
if (!params.guid) {
// Can remove this if-block after January 2022.
- doUpdateUploadRemove(params);
+ doUpdateUploadRemove('', params);
}
} else {
doOpenModal(MODALS.CONFIRM, {
@@ -52,13 +54,19 @@ export default function WebUploadItem(props: Props) {
uploader.abort(); // XHR
}
}
- doUpdateUploadRemove(params);
+
+ // The second parameter (params) can be removed after January 2022.
+ doUpdateUploadRemove(params.guid, params);
closeModal();
},
});
}
function resolveProgressStr() {
+ if (locked) {
+ return __('File being uploaded in another tab or window.');
+ }
+
if (!uploader) {
return __('Stopped.');
}
@@ -85,7 +93,7 @@ export default function WebUploadItem(props: Props) {
}
function getRetryButton() {
- if (!resumable) {
+ if (!resumable || locked) {
return null;
}
@@ -113,7 +121,9 @@ export default function WebUploadItem(props: Props) {
}
function getCancelButton() {
- return ;
+ if (!locked) {
+ return ;
+ }
}
function getFileSelector() {
diff --git a/ui/component/webUploadList/view.jsx b/ui/component/webUploadList/view.jsx
index 89b4ad85e..58c2dba3a 100644
--- a/ui/component/webUploadList/view.jsx
+++ b/ui/component/webUploadList/view.jsx
@@ -7,7 +7,7 @@ type Props = {
currentUploads: { [key: string]: FileUploadItem },
uploadCount: number,
doPublishResume: (any) => void,
- doUpdateUploadRemove: (any) => void,
+ doUpdateUploadRemove: (string, any) => void,
doOpenModal: (string, {}) => void,
};
diff --git a/ui/constants/storage.js b/ui/constants/storage.js
new file mode 100644
index 000000000..7b21fea0a
--- /dev/null
+++ b/ui/constants/storage.js
@@ -0,0 +1,3 @@
+// Local Storage keys
+export const TUS_LOCKED_UPLOADS = 'tusLockedUploads';
+export const TUS_REMOVED_UPLOADS = 'tusRemovedUploads';
diff --git a/ui/redux/actions/publish.js b/ui/redux/actions/publish.js
index 471fe1269..591abac2c 100644
--- a/ui/redux/actions/publish.js
+++ b/ui/redux/actions/publish.js
@@ -702,21 +702,28 @@ export function doUpdateUploadAdd(
};
}
-export const doUpdateUploadProgress = (props: {
- params: { [key: string]: any },
- progress?: string,
- status?: string,
-}) => (dispatch: Dispatch) =>
+export const doUpdateUploadProgress = (props: { guid: string, progress?: string, status?: string }) => (
+ dispatch: Dispatch
+) =>
dispatch({
type: ACTIONS.UPDATE_UPLOAD_PROGRESS,
data: props,
});
-export function doUpdateUploadRemove(params: { [key: string]: any }) {
+/**
+ * doUpdateUploadRemove
+ *
+ * @param guid
+ * @param params Optional. Retain to allow removal of old keys, which are
+ * derived from `name#channel` instead of using a guid.
+ * Can be removed after January 2022.
+ * @returns {(function(Dispatch, GetState): void)|*}
+ */
+export function doUpdateUploadRemove(guid: string, params?: { [key: string]: any }) {
return (dispatch: Dispatch, getState: GetState) => {
dispatch({
type: ACTIONS.UPDATE_UPLOAD_REMOVE,
- data: { params },
+ data: { guid, params },
});
};
}
diff --git a/ui/redux/reducers/publish.js b/ui/redux/reducers/publish.js
index 2d961c1db..8422cfe80 100644
--- a/ui/redux/reducers/publish.js
+++ b/ui/redux/reducers/publish.js
@@ -2,6 +2,7 @@
import { handleActions } from 'util/redux-utils';
import { buildURI } from 'util/lbryURI';
import { serializeFileObj } from 'util/file';
+import { tusLockAndNotify, tusUnlockAndNotify, tusRemoveAndNotify, tusClearRemovedUploads } from 'util/tus';
import * as ACTIONS from 'constants/action_types';
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
import { CHANNEL_ANONYMOUS } from 'constants/claim';
@@ -154,10 +155,14 @@ export const publishReducer = handleActions(
return { ...state, currentUploads };
},
[ACTIONS.UPDATE_UPLOAD_PROGRESS]: (state: PublishState, action) => {
- const { params, progress, status } = action.data;
- const key = params.guid || getOldKeyFromParam(params);
+ const { guid, progress, status } = action.data;
+ const key = guid;
const currentUploads = Object.assign({}, state.currentUploads);
+ if (guid === 'force--update') {
+ return { ...state, currentUploads };
+ }
+
if (!currentUploads[key]) {
return state;
}
@@ -166,10 +171,16 @@ export const publishReducer = handleActions(
currentUploads[key].progress = progress;
delete currentUploads[key].status;
- if (currentUploads[key].uploader.url && !currentUploads[key].params.uploadUrl) {
- // TUS has finally obtained an upload url from the server. Stash that to check later when resuming.
- // Ignoring immutable-update requirement (probably doesn't matter to the GUI).
- currentUploads[key].params.uploadUrl = currentUploads[key].uploader.url;
+ if (currentUploads[key].uploader.url) {
+ // TUS has finally obtained an upload url from the server...
+ if (!currentUploads[key].params.uploadUrl) {
+ // ... Stash that to check later when resuming.
+ // Ignoring immutable-update requirement (probably doesn't matter to the GUI).
+ currentUploads[key].params.uploadUrl = currentUploads[key].uploader.url;
+ }
+
+ // ... lock this tab as the active uploader.
+ tusLockAndNotify(key);
}
} else if (status) {
currentUploads[key].status = status;
@@ -181,11 +192,19 @@ export const publishReducer = handleActions(
return { ...state, currentUploads };
},
[ACTIONS.UPDATE_UPLOAD_REMOVE]: (state: PublishState, action) => {
- const { params } = action.data;
- const key = params.guid || getOldKeyFromParam(params);
- const currentUploads = Object.assign({}, state.currentUploads);
- delete currentUploads[key];
- return { ...state, currentUploads };
+ const { guid, params } = action.data;
+ const key = guid || getOldKeyFromParam(params);
+
+ if (state.currentUploads[key]) {
+ const currentUploads = Object.assign({}, state.currentUploads);
+ delete currentUploads[key];
+ tusUnlockAndNotify(key);
+ tusRemoveAndNotify(key);
+
+ return { ...state, currentUploads };
+ }
+
+ return state;
},
[ACTIONS.REHYDRATE]: (state: PublishState, action) => {
if (action && action.payload && action.payload.publish) {
@@ -193,14 +212,20 @@ export const publishReducer = handleActions(
// 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;
- }
- });
+ const uploadKeys = Object.keys(newPublish.currentUploads);
+ if (uploadKeys.length > 0) {
+ // Clear uploader and corrupted params
+ uploadKeys.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;
+ }
+ });
+ } else {
+ tusClearRemovedUploads();
+ }
}
return newPublish;
diff --git a/ui/util/storage.js b/ui/util/storage.js
new file mode 100644
index 000000000..54bbf386e
--- /dev/null
+++ b/ui/util/storage.js
@@ -0,0 +1,15 @@
+export function isLocalStorageAvailable() {
+ try {
+ return Boolean(window.localStorage);
+ } catch (e) {
+ return false;
+ }
+}
+
+export function isSessionStorageAvailable() {
+ try {
+ return Boolean(window.sessionStorage);
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/ui/util/tus.js b/ui/util/tus.js
new file mode 100644
index 000000000..557bc553c
--- /dev/null
+++ b/ui/util/tus.js
@@ -0,0 +1,129 @@
+// @flow
+
+/**
+ * This serves a bridge between tabs using localStorage to indicate whether an
+ * upload is currently in progress (locked) or removed.
+ *
+ * An alternative is to sync the redux's 'publish::currentUploads' through the
+ * wallet's sync process, but let's not pollute the wallet for now.
+ */
+
+import { v4 as uuid } from 'uuid';
+import { TUS_LOCKED_UPLOADS, TUS_REMOVED_UPLOADS } from 'constants/storage';
+import { isLocalStorageAvailable } from 'util/storage';
+import { doUpdateUploadRemove, doUpdateUploadProgress } from 'redux/actions/publish';
+
+const localStorageAvailable = isLocalStorageAvailable();
+
+let gTabId: string = '';
+
+function getTabId() {
+ if (!gTabId) {
+ // We want to maximize bootup speed, so only initialize
+ // the tab ID on first use instead when declared.
+ gTabId = uuid();
+ }
+ return gTabId;
+}
+
+// ****************************************************************************
+// Locked
+// ****************************************************************************
+
+function getLockedUploads() {
+ if (localStorageAvailable) {
+ const storedValue = window.localStorage.getItem(TUS_LOCKED_UPLOADS);
+ return storedValue ? JSON.parse(storedValue) : {};
+ }
+ return {};
+}
+
+export function tusIsSessionLocked(guid: string) {
+ const lockedUploads = getLockedUploads();
+ return lockedUploads[guid] && lockedUploads[guid] !== getTabId();
+}
+
+export function tusLockAndNotify(guid: string) {
+ const lockedUploads = getLockedUploads();
+ if (!lockedUploads[guid] && localStorageAvailable) {
+ lockedUploads[guid] = getTabId();
+ window.localStorage.setItem(TUS_LOCKED_UPLOADS, JSON.stringify(lockedUploads));
+ }
+}
+
+/**
+ * tusUnlockAndNotify
+ *
+ * @param guid The upload session to unlock and notify other tabs of.
+ * Passing 'undefined' will clear all sessions locked by this tab.
+ */
+export function tusUnlockAndNotify(guid?: string) {
+ if (!localStorageAvailable) return;
+
+ const lockedUploads = getLockedUploads();
+
+ if (guid) {
+ delete lockedUploads[guid];
+ } else {
+ const ourTabId = getTabId();
+ const lockedUploadsEntries = Object.entries(lockedUploads);
+ lockedUploadsEntries.forEach(([lockedGuid, tabId]) => {
+ if (tabId === ourTabId) {
+ delete lockedUploads[lockedGuid];
+ }
+ });
+ }
+
+ if (Object.keys(lockedUploads).length > 0) {
+ window.localStorage.setItem(TUS_LOCKED_UPLOADS, JSON.stringify(lockedUploads));
+ } else {
+ window.localStorage.removeItem(TUS_LOCKED_UPLOADS);
+ }
+}
+
+// ****************************************************************************
+// Removed
+// ****************************************************************************
+
+function getRemovedUploads() {
+ if (localStorageAvailable) {
+ const storedValue = window.localStorage.getItem(TUS_REMOVED_UPLOADS);
+ return storedValue ? storedValue.split(',') : [];
+ }
+ return [];
+}
+
+export function tusRemoveAndNotify(guid: string) {
+ if (!localStorageAvailable) return;
+ const removedUploads = getRemovedUploads();
+ removedUploads.push(guid);
+ window.localStorage.setItem(TUS_REMOVED_UPLOADS, removedUploads.join(','));
+}
+
+export function tusClearRemovedUploads() {
+ if (!localStorageAvailable) return;
+ window.localStorage.removeItem(TUS_REMOVED_UPLOADS);
+}
+
+// ****************************************************************************
+// Respond to changes from other tabs.
+// ****************************************************************************
+
+export function tusHandleTabUpdates(storageKey: string) {
+ switch (storageKey) {
+ case TUS_LOCKED_UPLOADS:
+ // The locked IDs are in localStorage, but related GUI is unaware.
+ // Send a redux update to force an update.
+ window.store.dispatch(doUpdateUploadProgress({ guid: 'force--update' }));
+ break;
+
+ case TUS_REMOVED_UPLOADS:
+ // The other tab's store has removed this upload, so it's safe to do the
+ // same without affecting rehydration.
+ if (localStorageAvailable) {
+ const removedUploads = getRemovedUploads();
+ removedUploads.forEach((guid) => window.store.dispatch(doUpdateUploadRemove(guid)));
+ }
+ break;
+ }
+}
diff --git a/web/setup/publish-v1.js b/web/setup/publish-v1.js
index 9a9fe85fe..66195cbf0 100644
--- a/web/setup/publish-v1.js
+++ b/web/setup/publish-v1.js
@@ -49,18 +49,18 @@ export function makeUploadRequest(
xhr.responseType = 'json';
xhr.upload.onprogress = (e) => {
const percentage = ((e.loaded / e.total) * 100).toFixed(2);
- window.store.dispatch(doUpdateUploadProgress({ params, progress: percentage }));
+ window.store.dispatch(doUpdateUploadProgress({ guid, progress: percentage }));
};
xhr.onload = () => {
- window.store.dispatch(doUpdateUploadRemove(params));
+ window.store.dispatch(doUpdateUploadRemove(guid));
resolve(xhr);
};
xhr.onerror = () => {
- window.store.dispatch(doUpdateUploadProgress({ params, status: 'error' }));
+ window.store.dispatch(doUpdateUploadProgress({ guid, status: 'error' }));
reject(new Error(__('There was a problem with your upload. Please try again.')));
};
xhr.onabort = () => {
- window.store.dispatch(doUpdateUploadRemove(params));
+ window.store.dispatch(doUpdateUploadRemove(guid));
};
if (!isPreview) {
diff --git a/web/setup/publish-v2.js b/web/setup/publish-v2.js
index ba096af93..41eabfb33 100644
--- a/web/setup/publish-v2.js
+++ b/web/setup/publish-v2.js
@@ -69,7 +69,7 @@ export function makeResumableUploadRequest(
filetype: file instanceof File ? file.type : undefined,
},
onShouldRetry: (err, retryAttempt, options) => {
- window.store.dispatch(doUpdateUploadProgress({ params, status: 'retry' }));
+ window.store.dispatch(doUpdateUploadProgress({ guid, status: 'retry' }));
const status = err.originalResponse ? err.originalResponse.getStatus() : 0;
return !inStatusCategory(status, 400);
},
@@ -78,17 +78,17 @@ export function makeResumableUploadRequest(
const errMsg = typeof err === 'string' ? err : err.message;
if (status === STATUS_CONFLICT || status === STATUS_LOCKED || errMsg === 'file currently locked') {
- window.store.dispatch(doUpdateUploadProgress({ params, status: 'conflict' }));
+ window.store.dispatch(doUpdateUploadProgress({ guid, status: 'conflict' }));
// prettier-ignore
reject(new Error(`${status}: concurrent upload detected. Uploading the same file from multiple tabs or windows is not allowed.`));
} else {
- window.store.dispatch(doUpdateUploadProgress({ params, status: 'error' }));
+ window.store.dispatch(doUpdateUploadProgress({ guid, status: 'error' }));
reject(new Error(err));
}
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
- window.store.dispatch(doUpdateUploadProgress({ params, progress: percentage }));
+ window.store.dispatch(doUpdateUploadProgress({ guid, progress: percentage }));
},
onSuccess: () => {
let retries = 1;
@@ -101,7 +101,7 @@ export function makeResumableUploadRequest(
xhr.setRequestHeader(X_LBRY_AUTH_TOKEN, token);
xhr.responseType = 'json';
xhr.onload = () => {
- window.store.dispatch(doUpdateUploadRemove(params));
+ window.store.dispatch(doUpdateUploadRemove(guid));
resolve(xhr);
};
xhr.onerror = () => {
@@ -110,12 +110,12 @@ export function makeResumableUploadRequest(
analytics.error('notify: first attempt failed (status=0). Retrying after 10s...');
setTimeout(() => makeNotifyRequest(), 10000); // Auto-retry after 10s delay.
} else {
- window.store.dispatch(doUpdateUploadProgress({ params, status: 'error' }));
+ window.store.dispatch(doUpdateUploadProgress({ guid, status: 'error' }));
reject(new Error(`There was a problem in the processing. Please retry. (${xhr.status})`));
}
};
xhr.onabort = () => {
- window.store.dispatch(doUpdateUploadRemove(params));
+ window.store.dispatch(doUpdateUploadRemove(guid));
};
xhr.send(jsonPayload);