s4s-editor / src /lib /project-fetcher-hoc.jsx
soiz1's picture
Update src/lib/project-fetcher-hoc.jsx
bae4d64 verified
import React from 'react';
import PropTypes from 'prop-types';
import { intlShape, injectIntl } from 'react-intl';
import bindAll from 'lodash.bindall';
import { connect } from 'react-redux';
import JSZip from 'jszip';
import { protobufToJson } from 'pmp-protobuf'
import { setProjectUnchanged } from '../reducers/project-changed';
import {
LoadingStates,
getIsCreatingNew,
getIsFetchingWithId,
getIsLoading,
getIsShowingProject,
onFetchedProjectData,
projectError,
setProjectId
} from '../reducers/project-state';
import {
activateTab,
BLOCKS_TAB_INDEX
} from '../reducers/editor-tab';
import log from './log';
import storage from './storage';
import { MISSING_PROJECT_ID } from './tw-missing-project';
import VM from 'scratch-vm';
import * as progressMonitor from '../components/loader/tw-progress-monitor';
// TW: Temporary hack for project tokens
const fetchProjectToken = projectId => {
if (projectId === '0') {
return Promise.resolve(null);
}
// Parse ?token=abcdef
const searchParams = new URLSearchParams(location.search);
if (searchParams.has('token')) {
return Promise.resolve(searchParams.get('token'));
}
// Parse #1?token=abcdef
const hashParams = new URLSearchParams(location.hash.split('?')[1]);
if (hashParams.has('token')) {
return Promise.resolve(hashParams.get('token'));
}
return fetch(`https://projects.penguinmod.com/api/v1/projects/getproject?projectID=${projectId}&requestType=metadata`)
.then(r => {
if (!r.ok) return null;
return r.json();
})
.then(dataOrNull => {
const token = dataOrNull ? dataOrNull.id : null;
return token;
})
.catch(err => {
log.error(err);
return null;
});
};
/* Higher Order Component to provide behavior for loading projects by id. If
* there's no id, the default project is loaded.
* @param {React.Component} WrappedComponent component to receive projectData prop
* @returns {React.Component} component with project loading behavior
*/
const ProjectFetcherHOC = function (WrappedComponent) {
class ProjectFetcherComponent extends React.Component {
constructor(props) {
super(props);
bindAll(this, [
'fetchProject'
]);
storage.setProjectHost(props.projectHost);
storage.setProjectToken(props.projectToken);
storage.setAssetHost(props.assetHost);
storage.setTranslatorFunction(props.intl.formatMessage);
// props.projectId might be unset, in which case we use our default;
// or it may be set by an even higher HOC, and passed to us.
// Either way, we now know what the initial projectId should be, so
// set it in the redux store.
if (
props.projectId !== '' &&
props.projectId !== null &&
typeof props.projectId !== 'undefined'
) {
this.props.setProjectId(props.projectId.toString());
}
}
componentDidUpdate(prevProps) {
if (prevProps.projectHost !== this.props.projectHost) {
storage.setProjectHost(this.props.projectHost);
}
if (prevProps.projectToken !== this.props.projectToken) {
storage.setProjectToken(this.props.projectToken);
}
if (prevProps.assetHost !== this.props.assetHost) {
storage.setAssetHost(this.props.assetHost);
}
if (this.props.isFetchingWithId && !prevProps.isFetchingWithId) {
this.fetchProject(this.props.reduxProjectId, this.props.loadingState);
}
if (this.props.isShowingProject && !prevProps.isShowingProject) {
this.props.onProjectUnchanged();
}
if (this.props.isShowingProject && (prevProps.isLoadingProject || prevProps.isCreatingNew)) {
this.props.onActivateTab(BLOCKS_TAB_INDEX);
}
}
fetchProject(projectId, loadingState) {
// tw: clear and stop the VM before fetching
// these will also happen later after the project is fetched, but fetching may take a while and
// the project shouldn't be running while fetching the new project
this.props.vm.clear();
this.props.vm.stop();
let assetPromise;
// In case running in node...
let projectUrl = typeof URLSearchParams === 'undefined' ?
null :
new URLSearchParams(location.search).get('project_url');
if (projectUrl) {
if (!projectUrl.startsWith('http:') && !projectUrl.startsWith('https:')) {
projectUrl = `https://${projectUrl}`;
}
assetPromise = progressMonitor.fetchWithProgress(projectUrl)
.then(r => {
if (this.props.vm.runtime.renderer?.setPrivateSkinAccess)
this.props.vm.runtime.renderer.setPrivateSkinAccess(false);
if (!r.ok) {
throw new Error(`Request returned status ${r.status}`);
}
return r.arrayBuffer();
})
.then(buffer => ({ data: buffer }));
} else {
// patch for default project
if (projectId === '0') {
storage.setProjectToken(projectId);
assetPromise = storage.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON);
} else {
projectUrl = `https://projects.penguinmod.com/api/v1/projects/getprojectwrapper?safe=true&projectId=${projectId}`;
assetPromise = progressMonitor.fetchWithProgress(projectUrl)
.then(async r => {
if (this.props.vm.runtime.renderer?.setPrivateSkinAccess)
this.props.vm.runtime.renderer.setPrivateSkinAccess(false);
if (!r.ok) {
throw new Error(`Request returned status ${r.status}`);
}
const project = await r.json();
const json = protobufToJson(new Uint8Array(project.project.data));
// now get the assets
let zip = new JSZip();
zip.file("project.json", JSON.stringify(json));
for (const asset of project.assets) {
zip.file(asset.id, new Uint8Array(asset.buffer.data).buffer);
}
const arrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
return arrayBuffer;
})
.then(buffer => ({ data: buffer }))
.catch(error => {
console.log(error)
})
}
}
return assetPromise
.then(projectAsset => {
// tw: If the project data appears to be HTML, then the result is probably an nginx 404 page,
// and the "missing project" project should be loaded instead.
// See: https://projects.scratch.mit.edu/9999999999999999999999
if (projectAsset && projectAsset.data) {
const firstChar = projectAsset.data[0];
if (firstChar === '<' || firstChar === '<'.charCodeAt(0)) {
return storage.load(storage.AssetType.Project, MISSING_PROJECT_ID, storage.DataFormat.JSON);
}
}
return projectAsset;
})
.then(projectAsset => {
if (projectAsset) {
this.props.onFetchedProjectData(projectAsset.data, loadingState);
} else {
// pm: Failed to grab data, use the "fetch" API as a backup
// we shouldnt be interrupted by the fetch replacement in tw-progress-monitor
// as it uses projects.scratch.mit.edu still
fetch(projectUrl).then(async res => {
if (!res.ok) {
// Treat failure to load as an error
// Throw to be caught by catch later on
throw new Error('Could not find project; ' + projectUrl);
}
const project = await res.json();
const json = protobufToJson(new Uint8Array(project.project.data));
// now get the assets
let zip = new JSZip();
zip.file("project.json", JSON.stringify(json));
for (const asset of project.assets) {
zip.file(asset.id, new Uint8Array(asset.buffer.data).buffer);
}
const arrayBuffer = await zip.generateAsync({ type: "arraybuffer" });
this.props.onFetchedProjectData(arrayBuffer, loadingState);
}).catch(err => {
throw new Error('Could not find project; ' + err);
})
}
})
.catch(err => {
this.props.onError(err);
log.error(err);
});
}
render() {
const {
/* eslint-disable no-unused-vars */
assetHost,
intl,
isLoadingProject: isLoadingProjectProp,
loadingState,
onActivateTab,
onError: onErrorProp,
onFetchedProjectData: onFetchedProjectDataProp,
onProjectUnchanged,
projectHost,
projectId,
reduxProjectId,
setProjectId: setProjectIdProp,
/* eslint-enable no-unused-vars */
isFetchingWithId: isFetchingWithIdProp,
...componentProps
} = this.props;
return (
<WrappedComponent
fetchingProject={isFetchingWithIdProp}
{...componentProps}
/>
);
}
}
ProjectFetcherComponent.propTypes = {
assetHost: PropTypes.string,
canSave: PropTypes.bool,
intl: intlShape.isRequired,
isCreatingNew: PropTypes.bool,
isFetchingWithId: PropTypes.bool,
isLoadingProject: PropTypes.bool,
isShowingProject: PropTypes.bool,
loadingState: PropTypes.oneOf(LoadingStates),
onActivateTab: PropTypes.func,
onError: PropTypes.func,
onFetchedProjectData: PropTypes.func,
onProjectUnchanged: PropTypes.func,
projectHost: PropTypes.string,
projectToken: PropTypes.string,
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
setProjectId: PropTypes.func,
vm: PropTypes.instanceOf(VM)
};
ProjectFetcherComponent.defaultProps = {
assetHost: 'https://assets.scratch.mit.edu',
projectHost: 'https://projects.scratch.mit.edu'
};
const mapStateToProps = state => ({
isCreatingNew: getIsCreatingNew(state.scratchGui.projectState.loadingState),
isFetchingWithId: getIsFetchingWithId(state.scratchGui.projectState.loadingState),
isLoadingProject: getIsLoading(state.scratchGui.projectState.loadingState),
isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState),
loadingState: state.scratchGui.projectState.loadingState,
reduxProjectId: state.scratchGui.projectState.projectId,
vm: state.scratchGui.vm
});
const mapDispatchToProps = dispatch => ({
onActivateTab: tab => dispatch(activateTab(tab)),
onError: error => dispatch(projectError(error)),
onFetchedProjectData: (projectData, loadingState) =>
dispatch(onFetchedProjectData(projectData, loadingState)),
setProjectId: projectId => dispatch(setProjectId(projectId)),
onProjectUnchanged: () => dispatch(setProjectUnchanged())
});
// Allow incoming props to override redux-provided props. Used to mock in tests.
const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign(
{}, stateProps, dispatchProps, ownProps
);
return injectIntl(connect(
mapStateToProps,
mapDispatchToProps,
mergeProps
)(ProjectFetcherComponent));
};
export {
ProjectFetcherHOC as default
};