Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: optimistic file upload #717

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
16 changes: 12 additions & 4 deletions common/file-utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ const getCookie = (name) => {
if (match) return match[2];
};

export const upload = async ({ file, context, bucketName, routes, excludeFromLibrary }) => {
export const upload = async ({
fileId = null,
file,
context,
bucketName,
routes,
excludeFromLibrary,
}) => {
let formData = new FormData();
const HEIC2ANY = require("heic2any");

Expand All @@ -65,9 +72,9 @@ export const upload = async ({ file, context, bucketName, routes, excludeFromLib
quality: 1,
}); //TODO(martina): figure out how to cancel an await if upload has been cancelled

formData.append("data", converted);
formData.append(fileId, converted);
} else {
formData.append("data", file);
formData.append(fileId, file);
}

if (Store.checkCancelled(`${file.lastModified}-${file.name}`)) {
Expand Down Expand Up @@ -121,8 +128,9 @@ export const upload = async ({ file, context, bucketName, routes, excludeFromLib
try {
return resolve(JSON.parse(event.target.response));
} catch (e) {
return resolve({
return reject({
error: "SERVER_UPLOAD_ERROR",
failedFile: file,
});
}
};
Expand Down
140 changes: 140 additions & 0 deletions components/core/Application.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import ApplicationHeader from "~/components/core/ApplicationHeader";
import ApplicationLayout from "~/components/core/ApplicationLayout";
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";

import { v4 as uuid } from "uuid";
import { GlobalModal } from "~/components/system/components/GlobalModal";
import { OnboardingModal } from "~/components/core/OnboardingModal";
import { SearchModal } from "~/components/core/SearchModal";
Expand Down Expand Up @@ -106,6 +107,7 @@ export default class ApplicationPage extends React.Component {
isMobile: this.props.isMobile,
loaded: false,
activeUsers: null,
optimisticFiles: [],
};

async componentDidMount() {
Expand Down Expand Up @@ -224,6 +226,7 @@ export default class ApplicationPage extends React.Component {
return;
}
}

this.setState(
{
viewer: { ...this.state.viewer, ...newViewerState },
Expand Down Expand Up @@ -347,6 +350,8 @@ export default class ApplicationPage extends React.Component {
return;
}

files = await this._handleOptimisticUpload({ files, slate });

const resolvedFiles = [];
for (let i = 0; i < files.length; i++) {
if (Store.checkCancelled(`${files[i].lastModified}-${files[i].name}`)) {
Expand All @@ -360,12 +365,54 @@ export default class ApplicationPage extends React.Component {
let response;
try {
response = await FileUtilities.upload({
fileId: files[i]?.id,
file: files[i],
context: this,
routes: this.props.resources,
});
} catch (e) {
console.log(e);

let optimisticFiles = this.state.optimisticFiles;
let updatedOptimisticFiles = optimisticFiles.filter((item) => {
if (item.id === e.failedFile.id) {
return false;
}

return true;
});

this.setState({ optimisticFiles: updatedOptimisticFiles });

if (slate && slate.id) {
let slates = this.state.viewer.slates;
for (let item of slates) {
if (item.id === slate.id) {
item.objects = item.objects.filter((child) => {
if (child.id === e.failedFile.id) {
return false;
}

return true;
});
}
}

this._handleUpdateViewer({ slates });

return;
}

let library = this.state.viewer.library;
library = library.filter((child) => {
if (child.id === e.failedFile.id) {
return false;
}

return true;
});

this._handleUpdateViewer({ library });
}

if (!response || response.error) {
Expand Down Expand Up @@ -424,6 +471,99 @@ export default class ApplicationPage extends React.Component {
this._handleRegisterLoadingFinished({ keys });
};

_handleOptimisticUpload = async ({ files, slate }) => {
let optimisticFiles = [];

for (let i = 0; i < files.length; i++) {
let id = uuid();
let dataURL = await this._handleLoadDataURL(files[i]);
let data = {
id,
filename: files[i].name,
data: {
name: files[i].name,
type: files[i].type,
size: files[i].size,
},
decorator: "OPTIMISTIC-FILE",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instaed of using decorator = "OPTIMISTIC-FILE" maybe we can do something like file.optimistic = true

Decorator might be used for other things so we'd like to reserve it for that ideally. Plus it's easier to check for (if (file.optimistic)) and b/c with decorator.startsWith("OPTIMISTIC") you have to worry about keeping capitalization consistent

dataURL,
};

optimisticFiles.push(data);

files[i].id = id;
}

this.setState({ optimisticFiles });

if (slate && slate.id) {
const slates = this.state.viewer.slates;

for (let item of slates) {
if (item.id === slate.id) {
item.objects = [...item.objects, ...optimisticFiles];
break;
}
}

this._handleUpdateViewer({ slates });
return files;
}

let update = [...optimisticFiles, ...this.state.viewer?.library];
let library = this.props.viewer?.library;
library = update;
this._handleUpdateViewer({ library });

return files;
};

_handleLoadDataURL = (file) =>
new Promise((resolve, reject) => {
if (file.type.startsWith("application/pdf")) {
resolve(URL.createObjectURL(file));
return;
}

const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};

reader.onerror = () => {
reject({ error: true });
reader.abort();
};

reader.readAsDataURL(file);
});

_handleSuccessfulUpload = ({ succeeded }) => {
let optimisticFiles = this.state.optimisticFiles;
// let optimisticFilesNames = this.state.optimisticFiles.map(item => item.name);
let library = this.state.viewer.library;
let update = succeeded.map((item) => {
let data = item.json?.data;

let itemToUpdateIndex = library[0].children.findIndex((item) => item.id === data.id);
Copy link
Collaborator

@martinalong martinalong Apr 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something that changed in the migration. Pleas make sure you take a look at the doc https://www.notion.so/slatesystem/Files-Database-Migration-84ccad66574d487fbad4a4a2a5857276 for changes and edit your code to fit the new data structure

It's no longer library[0].children, it's now just library

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be the source of some of the other errors I mentioned (like uploads not resolving properly etc)

if (itemToUpdateIndex > -1) {
let updatedItem = { ...library[0].children[itemToUpdateIndex], ...data };

let optimisticFileIndex = optimisticFiles.findIndex((item) => item.id === data.id);
optimisticFiles.splice(optimisticFileIndex, 1);

return updatedItem;
}
});

this.setState({ optimisticFiles });

update = [...update, ...library[0].children];
library[0].children = update;

this._handleUpdateViewer({ library });
};

_handleRegisterFileLoading = ({ fileLoading }) => {
if (this.state.fileLoading) {
return this.setState({
Expand Down
38 changes: 34 additions & 4 deletions components/core/CarouselSidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,10 @@ class CarouselSidebar extends React.Component {
};

_handleDownload = () => {
if (this.props.data.decorator?.startsWith("OPTIMISTIC")) {
return;
}

if (this.props.data.data.type === "application/unity") {
this.setState({ isDownloading: true }, async () => {
const response = await UserBehaviors.downloadZip(this.props.data);
Expand All @@ -440,6 +444,10 @@ class CarouselSidebar extends React.Component {
};

_handleCreateSlate = async () => {
if (this.props.data.decorator?.startsWith("OPTIMISTIC")) {
return;
}

if (this.props.external) return;
this.props.onClose();
this.props.onAction({
Expand All @@ -450,7 +458,12 @@ class CarouselSidebar extends React.Component {
};

_handleDelete = () => {
if (this.props.external || !this.props.isOwner) return;
if (
this.props.external ||
!this.props.isOwner ||
this.props.data.decorator.startsWith("OPTIMISTIC")
)
return;
const message =
"Are you sure you want to delete this? It will be removed from your collections as well";
if (!window.confirm(message)) {
Expand All @@ -477,6 +490,10 @@ class CarouselSidebar extends React.Component {
};

_handleAdd = async (slate) => {
if (this.props.data.decorator?.startsWith("OPTIMISTIC")) {
return;
}

let inPublicSlates = this.state.inPublicSlates;
if (this.state.selected[slate.id]) {
if (slate.isPublic) {
Expand Down Expand Up @@ -521,6 +538,10 @@ class CarouselSidebar extends React.Component {
};

_handleToggleVisibility = async (e) => {
if (this.props.data.decorator?.startsWith("OPTIMISTIC")) {
return;
}

if (this.props.external || !this.props.isOwner) return;
const isVisible = this.state.isPublic || this.state.inPublicSlates > 0;
let selected = cloneDeep(this.state.selected);
Expand Down Expand Up @@ -574,7 +595,7 @@ class CarouselSidebar extends React.Component {
const isUnityGame = type === "application/unity";

const elements = [];
if (editingAllowed && !isUnityGame) {
if (editingAllowed && !isUnityGame && !file.decorator?.startsWith("OPTIMISTIC")) {
elements.push(
<div key="sidebar-media-object-info" style={{ marginTop: 8 }}>
<Input
Expand Down Expand Up @@ -719,7 +740,11 @@ class CarouselSidebar extends React.Component {
</div>
);

if (!this.props.external && (!this.props.isOwner || this.props.isRepost)) {
if (
!this.props.external &&
(!this.props.isOwner || this.props.isRepost) &&
!file.decorator?.startsWith("OPTIMISTIC")
) {
actions.push(
<div key="save-copy" css={STYLES_ACTION} onClick={() => this._handleSaveCopy(file)}>
<SVG.Save height="24px" />
Expand All @@ -734,7 +759,12 @@ class CarouselSidebar extends React.Component {
);
}

if (this.props.carouselType === "SLATE" && !this.props.external && this.props.isOwner) {
if (
this.props.carouselType === "SLATE" &&
!this.props.external &&
this.props.isOwner &&
!file.decorator?.startsWith("OPTIMISTIC")
) {
actions.push(
<div key="remove" css={STYLES_ACTION} onClick={this._handleRemove}>
<SVG.DismissCircle height="24px" />
Expand Down
Loading