Skip to content

Commit f172fe4

Browse files
committed
allow loading scratch projects using 'scratch:'
1 parent 4f8ba60 commit f172fe4

File tree

5 files changed

+155
-68
lines changed

5 files changed

+155
-68
lines changed

src/lib/project-fetcher-hoc.jsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {connect} from 'react-redux';
77
const {API_HOST, ASSET_HOST} = require('./brand');
88

99
import {setProjectUnchanged} from '../reducers/project-changed';
10+
import {fetchProjectMetaWithCache} from './tw-project-meta-fetcher-hoc.jsx';
1011
import {
1112
LoadingStates,
1213
getIsCreatingNew,
@@ -79,7 +80,7 @@ const ProjectFetcherHOC = function (WrappedComponent) {
7980
storage.setAssetLoadHost(this.props.assetLoadHost);
8081
}
8182
if (this.props.isFetchingWithId && !prevProps.isFetchingWithId) {
82-
this.fetchProject(this.props.reduxProjectId, this.props.loadingState);
83+
this.fetchProject(this.props.reduxProjectId, this.props.loadingState, this.props.isScratchProject);
8384
}
8485
if (this.props.isShowingProject && !prevProps.isShowingProject) {
8586
this.props.onProjectUnchanged();
@@ -88,7 +89,11 @@ const ProjectFetcherHOC = function (WrappedComponent) {
8889
this.props.onActivateTab(BLOCKS_TAB_INDEX);
8990
}
9091
}
91-
fetchProject (projectId, loadingState) {
92+
fetchProject (projectId, loadingState, isScratchProject) {
93+
if (isScratchProject){
94+
storage.setProjectHost(this.props.scratchProjectHost);
95+
storage.setAssetLoadHost(this.props.scratchTrampolineHost);
96+
}
9297
// tw: clear and stop the VM before fetching
9398
// these will also happen later after the project is fetched, but fetching may take a while and
9499
// the project shouldn't be running while fetching the new project
@@ -117,8 +122,16 @@ const ProjectFetcherHOC = function (WrappedComponent) {
117122
return r.arrayBuffer();
118123
})
119124
.then(buffer => ({data: buffer}));
125+
} else if (isScratchProject) {
126+
assetPromise = fetchProjectMetaWithCache(projectId, true)
127+
.then(() =>
128+
storage.load(
129+
storage.AssetType.Project,
130+
projectId,
131+
storage.DataFormat.JSON
132+
)
133+
);
120134
} else {
121-
// TW: Temporary hack for project tokens
122135
assetPromise = fetchProjectToken(projectId)
123136
.then(token => {
124137
storage.setProjectToken(token);
@@ -194,12 +207,15 @@ const ProjectFetcherHOC = function (WrappedComponent) {
194207
ProjectFetcherComponent.propTypes = {
195208
assetHost: PropTypes.string,
196209
assetLoadHost: PropTypes.string,
210+
scratchProjectHost: PropTypes.string,
211+
scratchTrampolineHost: PropTypes.string,
197212
canSave: PropTypes.bool,
198213
intl: intlShape.isRequired,
199214
isCreatingNew: PropTypes.bool,
200215
isFetchingWithId: PropTypes.bool,
201216
isLoadingProject: PropTypes.bool,
202217
isShowingProject: PropTypes.bool,
218+
isScratchProject: PropTypes.bool,
203219
loadingState: PropTypes.oneOf(LoadingStates),
204220
onActivateTab: PropTypes.func,
205221
onError: PropTypes.func,
@@ -215,7 +231,9 @@ const ProjectFetcherHOC = function (WrappedComponent) {
215231
ProjectFetcherComponent.defaultProps = {
216232
assetHost: `${API_HOST}/v1/projects/blocks/assets`, // used to upload assets
217233
assetLoadHost: `${ASSET_HOST}/block_project_assets`, // used to load assets
218-
projectHost: `${API_HOST}/v1/projects/blocks`
234+
scratchTrampolineHost: `${ASSET_HOST}/scratch_project_assets`, // used to load Scratch projects
235+
projectHost: `${API_HOST}/v1/projects/blocks`,
236+
scratchProjectHost: `${ASSET_HOST}/scratch_project_json` // used to load Scratch projects
219237
};
220238

221239
const mapStateToProps = state => ({
@@ -225,6 +243,7 @@ const ProjectFetcherHOC = function (WrappedComponent) {
225243
isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState),
226244
loadingState: state.scratchGui.projectState.loadingState,
227245
reduxProjectId: state.scratchGui.projectState.projectId,
246+
isScratchProject: state.scratchGui.projectState.isScratchProject,
228247
vm: state.scratchGui.vm
229248
});
230249
const mapDispatchToProps = dispatch => ({

src/lib/storage.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ class Storage extends ScratchStorage {
4343
setProjectToken (projectToken) {
4444
this.projectToken = projectToken;
4545
}
46+
setScratchProjectToken (projectToken) {
47+
this.scratchProjectToken = projectToken;
48+
}
4649
getTrustedHost (inputUrl){
4750
const url = new URL(inputUrl);
4851
const parts = url.hostname.split('.');
@@ -98,6 +101,9 @@ class Storage extends ScratchStorage {
98101
}
99102
getProjectGetConfig (projectAsset) {
100103
const path = `${this.projectHost}/${projectAsset.assetId}`;
104+
if (this.scratchProjectToken) {
105+
return `${path}?token=${this.scratchProjectToken}`;
106+
}
101107
return path;
102108
}
103109
getProjectCreateConfig () {

src/lib/tw-project-meta-fetcher-hoc.jsx

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,57 @@ import {connect} from 'react-redux';
44
import log from './log';
55

66
// eslint-disable-next-line import/no-commonjs
7-
const {API_HOST} = require('./brand');
7+
const {API_HOST, ASSET_HOST} = require('./brand');
88

99
import {setProjectTitle} from '../reducers/project-title';
1010
import {setAuthor, setDescription} from '../reducers/tw';
1111

1212
import storage from './storage';
1313

14-
export const fetchProjectMeta = async projectId => {
14+
/**
15+
* Shared promise cache to prevent double-loading metadata
16+
* when both HOCs trigger at the same time.
17+
* (this is primarily due to Scratch Project Loading)
18+
*/
19+
let activeFetchMetadataPromise = null;
20+
21+
export const fetchProjectMeta = async (projectId, isScratch) => {
1522
const authToken = await storage.getProjectToken();
16-
const urls = [
23+
let urls = [
1724
`${API_HOST}/v1/projects/blocks/${projectId}/meta`,
1825
`${API_HOST}/v1/projects/blocks/${projectId}/meta`
1926
];
27+
if (isScratch) {
28+
urls = [
29+
`${ASSET_HOST}/scratch_project_meta/${projectId}`,
30+
`${ASSET_HOST}/scratch_project_meta/${projectId}`
31+
];
32+
}
2033
let firstError;
2134
for (const url of urls) {
2235
try {
2336
const res = await fetch(url, {
24-
headers: {
37+
headers: isScratch ? {} : {
2538
Authorization: `Bearer ${authToken}`
26-
}});
39+
}
40+
});
2741

2842
const data = await res.json();
2943
if (res.ok) {
44+
if (isScratch){
45+
storage.setScratchProjectToken(data.project_token); // so we can load actual project JSON file
46+
return {
47+
title: data.title,
48+
author: {
49+
username: data.author.username,
50+
PFP: data.author.profile.images['90x90']
51+
},
52+
instructions: data.instructions,
53+
description: data.description,
54+
canSave: 'false',
55+
canRemix: 'false'
56+
};
57+
}
3058
return data;
3159
}
3260
if (res.status === 404) {
@@ -42,6 +70,20 @@ export const fetchProjectMeta = async projectId => {
4270
throw firstError;
4371
};
4472

73+
export const fetchProjectMetaWithCache = (projectId, isScratch) => {
74+
if (activeFetchMetadataPromise && activeFetchMetadataPromise.id === projectId) {
75+
return activeFetchMetadataPromise.promise;
76+
}
77+
78+
const promise = fetchProjectMeta(projectId, isScratch);
79+
activeFetchMetadataPromise = {
80+
id: projectId,
81+
promise: promise.finally(() => {
82+
})
83+
};
84+
return promise;
85+
};
86+
4587
const getNoIndexTag = () => document.querySelector('meta[name="robots"][content="noindex"]');
4688
const setIndexable = indexable => {
4789
if (indexable) {
@@ -68,21 +110,22 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) {
68110
canEditTitle: false
69111
};
70112
}
113+
71114
componentDidUpdate (prevProps) {
72-
// project title resetting is handled in titled-hoc.jsx
73-
if (this.props.reduxProjectId !== prevProps.reduxProjectId) {
115+
if (
116+
this.props.reduxProjectId !== prevProps.reduxProjectId ||
117+
this.props.isScratchProject !== prevProps.isScratchProject
118+
) {
74119
this.props.onSetAuthor('', '');
75120
this.props.onSetDescription('', '');
76121
const projectId = this.props.reduxProjectId;
122+
const isScratch = this.props.isScratchProject;
77123

78124
if (projectId === '0') {
79-
// don't try to get metadata
125+
activeFetchMetadataPromise = null; // Reset cache on new project
80126
} else {
81-
fetchProjectMeta(projectId).then(data => {
82-
// If project ID changed, ignore the results.
83-
if (this.props.reduxProjectId !== projectId) {
84-
return;
85-
}
127+
fetchProjectMetaWithCache(projectId, isScratch).then(data => {
128+
if (this.props.reduxProjectId !== projectId) return;
86129

87130
const title = data.title;
88131
if (title) {
@@ -92,6 +135,7 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) {
92135
const authorName = data.author.username;
93136
const authorThumbnail = data.author.PFP;
94137
this.props.onSetAuthor(authorName, authorThumbnail);
138+
95139
const instructions = data.instructions || '';
96140
const credits = data.description || '';
97141
if (instructions || credits) {
@@ -105,11 +149,15 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) {
105149
canEditTitle: canSave // Enable title editing if user has save permissions
106150
});
107151

108-
storage.setCloudOTT(data?.cloudDataOTT);
109-
storage.setCustomAchievements(data?.customAchievements);
110-
window.CollaborationRoom = data?.collaboratorRoom;
111-
window.CollaborationUsername = data?.username;
112-
window.collaborationOTT = data?.collaborationOTT;
152+
if (isScratch) {
153+
window.CollaborationRoom = null;
154+
} else {
155+
storage.setCloudOTT(data?.cloudDataOTT);
156+
storage.setCustomAchievements(data?.customAchievements);
157+
window.CollaborationRoom = data?.collaboratorRoom;
158+
window.CollaborationUsername = data?.username;
159+
window.collaborationOTT = data?.collaborationOTT;
160+
}
113161
setIndexable(true);
114162
})
115163
.catch(err => {
@@ -126,6 +174,7 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) {
126174
const {
127175
/* eslint-disable no-unused-vars */
128176
reduxProjectId,
177+
isScratchProject,
129178
onSetAuthor,
130179
onSetDescription,
131180
onSetProjectTitle,
@@ -145,12 +194,14 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) {
145194
}
146195
ProjectMetaFetcherComponent.propTypes = {
147196
reduxProjectId: PropTypes.string,
197+
isScratchProject: PropTypes.bool,
148198
onSetAuthor: PropTypes.func,
149199
onSetDescription: PropTypes.func,
150200
onSetProjectTitle: PropTypes.func
151201
};
152202
const mapStateToProps = state => ({
153-
reduxProjectId: state.scratchGui.projectState.projectId
203+
reduxProjectId: state.scratchGui.projectState.projectId,
204+
isScratchProject: state.scratchGui.projectState.isScratchProject
154205
});
155206
const mapDispatchToProps = dispatch => ({
156207
onSetAuthor: (username, thumbnail) => dispatch(setAuthor({

src/lib/tw-state-manager-hoc.jsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,29 @@ const setLocalStorage = (key, value) => {
5353

5454
const readHashProjectId = () => {
5555
if (location.pathname === '/projects/editor'){
56-
return '0';
56+
return {id: '0', isScratch: false};
5757
}
5858
try {
59-
return location.pathname.split('/projects/')[1].split('/editor')[0].split('/fullscreen')[0].replaceAll('/', '');
60-
} catch (e){
61-
const match = location.hash.match(/#(\d+)/);
62-
return match === null ? null : match[1];
59+
const pathId = location.pathname.split('/projects/')[1]
60+
.split('/editor')[0]
61+
.split('/fullscreen')[0]
62+
.replaceAll('/', '');
63+
if (pathId) return {id: pathId, isScratch: false};
64+
} catch (e) {
65+
// do nothing
66+
}
67+
68+
const scratchMatch = location.hash.match(/#scratch:(\d+)/);
69+
if (scratchMatch) {
70+
return {id: scratchMatch[1], isScratch: true};
6371
}
6472

73+
const normalMatch = location.hash.match(/#(\d+)/);
74+
if (normalMatch) {
75+
return {id: normalMatch[1], isScratch: false};
76+
}
77+
78+
return null;
6579
};
6680

6781
const shouldRemix = () => {
@@ -92,7 +106,12 @@ class Router {
92106

93107
class HashRouter extends Router {
94108
onhashchange () {
95-
this.onSetProjectId(readHashProjectId() || defaultProjectId);
109+
const hashData = readHashProjectId();
110+
if (hashData) {
111+
this.onSetProjectId(hashData.id, hashData.isScratch);
112+
} else {
113+
this.onSetProjectId(defaultProjectId, false);
114+
}
96115
}
97116

98117
generateURL ({projectId}) {
@@ -490,7 +509,7 @@ const TWStateManager = function (WrappedComponent) {
490509
handlePopState () {
491510
this.router.onpathchange();
492511
}
493-
onSetProjectId (id) {
512+
onSetProjectId (id, isScratch = false) {
494513
if (`${id}` === `${this.props.reduxProjectId}`) {
495514
return true;
496515
}
@@ -499,7 +518,7 @@ const TWStateManager = function (WrappedComponent) {
499518
return false;
500519
}
501520
}
502-
this.props.onSetProjectId(id);
521+
this.props.onSetProjectId(id, isScratch);
503522
return true;
504523
}
505524
onSetIsPlayerOnly (isPlayerOnly) {
@@ -601,7 +620,7 @@ const TWStateManager = function (WrappedComponent) {
601620
const mapDispatchToProps = dispatch => ({
602621
onSetIsFullScreen: isFullScreen => dispatch(setFullScreen(isFullScreen)),
603622
onSetIsPlayerOnly: isPlayerOnly => dispatch(setPlayer(isPlayerOnly)),
604-
onSetProjectId: projectId => dispatch(setProjectId(projectId)),
623+
onSetProjectId: (projectId, isScratch) => dispatch(setProjectId(projectId, isScratch)),
605624
onSetUsername: username => dispatch(setUsername(username)),
606625
handleRemix: () => dispatch(remixProject())
607626
});

0 commit comments

Comments
 (0)