diff --git a/src/addons/hooks.js b/src/addons/hooks.js index 77db3cb035f..68c8f14c370 100644 --- a/src/addons/hooks.js +++ b/src/addons/hooks.js @@ -2,7 +2,8 @@ const AddonHooks = { appStateReducer: () => {}, appStateStore: null, blockly: null, - blocklyCallbacks: [] + blocklyCallbacks: [], + disableRestorePoints: false }; export default AddonHooks; diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 48b7266989f..a49afd3a242 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -35,6 +35,7 @@ import TWUsernameModal from '../../containers/tw-username-modal.jsx'; import TWSettingsModal from '../../containers/tw-settings-modal.jsx'; import TWSecurityManager from '../../containers/tw-security-manager.jsx'; import TWCustomExtensionModal from '../../containers/tw-custom-extension-modal.jsx'; +import TWRestorePointManager from '../../containers/tw-restore-point-manager.jsx'; import layout, {STAGE_SIZE_MODES} from '../../lib/layout-constants'; import {resolveStageSize} from '../../lib/screen-utils'; @@ -167,6 +168,7 @@ const GUIComponent = props => { const alwaysEnabledModals = ( + {usernameModalVisible && } {settingsModalVisible && } {customExtensionModalVisible && } diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 31551392e1f..3376ebd0060 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -29,10 +29,9 @@ import MenuBarHOC from '../../containers/menu-bar-hoc.jsx'; import FramerateChanger from '../../containers/tw-framerate-changer.jsx'; import ChangeUsername from '../../containers/tw-change-username.jsx'; import CloudVariablesToggler from '../../containers/tw-cloud-toggler.jsx'; -import TWRestorePointLoader from '../../containers/tw-restore-point-loader.jsx'; import TWSaveStatus from './tw-save-status.jsx'; -import {openTipsLibrary, openSettingsModal} from '../../reducers/modals'; +import {openTipsLibrary, openSettingsModal, openRestorePointModal} from '../../reducers/modals'; import {setPlayer} from '../../reducers/mode'; import { autoUpdateProject, @@ -205,6 +204,7 @@ class MenuBar extends React.Component { 'handleClickSave', 'handleClickSaveAsCopy', 'handleClickPackager', + 'handleClickRestorePoints', 'handleClickSeeCommunity', 'handleClickShare', 'handleKeyPress', @@ -251,6 +251,10 @@ class MenuBar extends React.Component { this.props.onClickPackager(); this.props.onRequestCloseFile(); } + handleClickRestorePoints () { + this.props.onClickRestorePoints(); + this.props.onRequestCloseFile(); + } handleClickSeeCommunity (waitForUpdate) { if (this.props.shouldSaveBeforeTransition()) { this.props.autoUpdateProject(); // save before transitioning to project page @@ -644,18 +648,13 @@ class MenuBar extends React.Component { )} - {(className, loadRestorePoint) => ( - - - - )} + + + @@ -963,6 +962,7 @@ MenuBar.propTypes = { onClickAddonSettings: PropTypes.func, onClickTheme: PropTypes.func, onClickPackager: PropTypes.func, + onClickRestorePoints: PropTypes.func, onClickEdit: PropTypes.func, onClickFile: PropTypes.func, onClickLanguage: PropTypes.func, @@ -1061,6 +1061,7 @@ const mapDispatchToProps = dispatch => ({ onClickRemix: () => dispatch(remixProject()), onClickSave: () => dispatch(manualUpdateProject()), onClickSaveAsCopy: () => dispatch(saveProjectAsCopy()), + onClickRestorePoints: () => dispatch(openRestorePointModal()), onClickSettings: () => { dispatch(openSettingsModal()); dispatch(closeEditMenu()); diff --git a/src/components/tw-restore-point-modal/restore-point-modal.css b/src/components/tw-restore-point-modal/restore-point-modal.css new file mode 100644 index 00000000000..2270ae70db0 --- /dev/null +++ b/src/components/tw-restore-point-modal/restore-point-modal.css @@ -0,0 +1,132 @@ +@import "../../css/colors.css"; + +.modal-content { + max-width: 550px; + margin-top: 50px; +} + +.body { + background: $ui-white; + padding: 1.5rem 2.25rem; + max-height: calc(100vh - 150px); + overflow: auto; +} +[theme="dark"] .body { + color: $text-primary; + background: $ui-primary; +} + +.extra-container, +.disabled, +.loading, +.error, +.empty, +.restore-point-container, +.legacy-transition { + margin: 1rem 0 0 0; +} + +.extra-container { + display: flex; + justify-content: space-between; + align-items: center; +} +.total-size { + +} +.button { + font: inherit; + color: inherit; + padding: 0.75rem 1rem; + border-radius: 0.25rem; + border: 1px solid $ui-black-transparent; + font-weight: 600; + font-size: 0.85rem; + color: $ui-white; +} +.button:disabled { + opacity: 0.8; +} +.delete-all-button { + margin-left: auto; + background-color: $data-primary; +} + +.error-message { + font-family: monospace; + user-select: text; +} + +.restore-point-container { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; +} + +.restore-point { + width: 100%; + cursor: pointer; + display: flex; + align-items: center; + border: 2px solid $ui-black-transparent; + padding: 0.5rem; + border-radius: 0.5rem; + gap: 0.5rem; +} +.restore-point:hover { + border-color: $motion-primary; +} +.restore-point-details { + +} +.restore-point-thumbnail { + display: block; + border-radius: 0.25rem; + width: 100px; + height: 100%; + max-height: 100px; +} +.restore-point-title { + font-weight: bold; +} + +.delete-button { + appearance: none; + background: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + width: 2rem; + height: 2rem; + font-size: 2rem; + line-height: 1; + margin-left: auto; +} +.delete-button:hover { + background-color: $ui-black-transparent; +} + +.disabled { + padding: 0.5rem; + border-radius: 0.5rem; + background-color: rgba(255, 0, 0, 0.18); + border: 2px solid rgba(255, 0, 0, 0.568); + font-weight: bold; +} + +.legacy-transition { + display: flex; + justify-content: space-between; + padding: 0.5rem; + border-radius: 0.5rem; + background-color: rgba(128, 0, 128, 0.18); + border: 2px solid rgba(128, 0, 128, 0.568); + text-align: center; + font-weight: bold; +} +.load-legacy-button { + margin-left: 1rem; + background-color: $pen-primary; +} diff --git a/src/components/tw-restore-point-modal/restore-point-modal.jsx b/src/components/tw-restore-point-modal/restore-point-modal.jsx new file mode 100644 index 00000000000..b4cf7f9d2b3 --- /dev/null +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -0,0 +1,160 @@ +import {defineMessages, FormattedMessage, intlShape, injectIntl} from 'react-intl'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from '../../containers/modal.jsx'; +import RestorePoint from './restore-point.jsx'; +import styles from './restore-point-modal.css'; +import classNames from 'classnames'; +import {APP_NAME} from '../../lib/brand'; +import {formatBytes} from '../../lib/tw-bytes-utils'; + +const messages = defineMessages({ + title: { + defaultMessage: 'Restore Points', + description: 'Title of restore point management modal', + id: 'tw.restorePoints.title' + } +}); + +const RestorePointModal = props => ( + +
+

+ +

+ + {props.disabled && ( +

+ +

+ )} + + {props.error ? ( +
+

+ +

+

+ {props.error} +

+
+ ) : props.isLoading ? ( +
+ +
+ ) : props.restorePoints.length === 0 ? ( +
+
+ +
+
+ ) : ( +
+
+ {props.restorePoints.map(restorePoint => ( + + ))} +
+ +
+
+ +
+ + +
+
+ )} + + {!props.isLoading && ( +
+ {/* This is going away within a few days */} + {/* No reason to bother translating */} + + {/* eslint-disable-next-line max-len */} + {'We just rewrote restore points from the ground up. If you can\'t find your project in the list, try loading the old restore point:'} + + +
+ )} +
+
+); + +RestorePointModal.propTypes = { + intl: intlShape, + onClose: PropTypes.func.isRequired, + onClickCreate: PropTypes.func.isRequired, + onClickDelete: PropTypes.func.isRequired, + onClickDeleteAll: PropTypes.func.isRequired, + onClickLoad: PropTypes.func.isRequired, + onClickLoadLegacy: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + totalSize: PropTypes.number.isRequired, + restorePoints: PropTypes.arrayOf(PropTypes.shape({})), + error: PropTypes.string +}; + +export default injectIntl(RestorePointModal); diff --git a/src/components/tw-restore-point-modal/restore-point.jsx b/src/components/tw-restore-point-modal/restore-point.jsx new file mode 100644 index 00000000000..883a882c570 --- /dev/null +++ b/src/components/tw-restore-point-modal/restore-point.jsx @@ -0,0 +1,139 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage, FormattedDate, FormattedTime, FormattedRelative} from 'react-intl'; +import bindAll from 'lodash.bindall'; +import styles from './restore-point-modal.css'; +import {formatBytes} from '../../lib/tw-bytes-utils'; +import RestorePointAPI from '../../lib/tw-restore-point-api'; + +// Browser support is not perfect yet +const relativeTimeSupported = () => typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat !== 'undefined'; + +class RestorePoint extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleClickDelete', + 'handleClickLoad' + ]); + this.state = { + thumbnail: null + }; + this.unmounted = false; + + // should never change for the same restore point + this.totalSize = this.getTotalSize(); + } + + componentDidMount () { + RestorePointAPI.getThumbnail(this.props.id) + .then(url => { + if (this.unmounted) { + URL.revokeObjectURL(url); + } else { + this.setState({ + thumbnail: url + }); + } + }); + } + + componentWillUnmount () { + if (this.state.thumbnail) { + URL.revokeObjectURL(this.state.thumbnail); + } + this.unmounted = true; + } + + getTotalSize () { + let size = this.props.projectSize + this.props.thumbnailSize; + for (const assetSize of Object.values(this.props.assets)) { + size += assetSize; + } + return size; + } + + handleClickDelete (e) { + e.stopPropagation(); + this.props.onClickDelete(this.props.id); + } + + handleClickLoad () { + this.props.onClickLoad(this.props.id); + } + + render () { + const createdDate = new Date(this.props.created * 1000); + return ( +
+ + +
+
+ {this.props.title} +
+ +
+ {relativeTimeSupported() && ( + + + {' ('} + + )} + + {', '} + + {relativeTimeSupported() && ')'} +
+ +
+ {formatBytes(this.totalSize)} + {', '} + +
+
+ + +
+ ); + } +} + +RestorePoint.propTypes = { + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + created: PropTypes.number.isRequired, + projectSize: PropTypes.number.isRequired, + thumbnailSize: PropTypes.number.isRequired, + thumbnailWidth: PropTypes.number.isRequired, + thumbnailHeight: PropTypes.number.isRequired, + assets: PropTypes.shape({}).isRequired, // Record + onClickDelete: PropTypes.func.isRequired, + onClickLoad: PropTypes.func.isRequired +}; + +export default RestorePoint; diff --git a/src/containers/alert.jsx b/src/containers/alert.jsx index 03a5d6f31d9..08909a7b7d5 100644 --- a/src/containers/alert.jsx +++ b/src/containers/alert.jsx @@ -3,7 +3,6 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import SB3Downloader from './sb3-downloader.jsx'; -import TWRestorePointLoader from './tw-restore-point-loader.jsx'; import AlertComponent from '../components/alerts/alert.jsx'; import {openConnectionModal} from '../reducers/modals'; import {setConnectionModalExtensionId} from '../reducers/connection-modal'; diff --git a/src/containers/tw-restore-point-loader.jsx b/src/containers/tw-restore-point-loader.jsx deleted file mode 100644 index 58943e843d8..00000000000 --- a/src/containers/tw-restore-point-loader.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import bindAll from 'lodash.bindall'; -import PropTypes from 'prop-types'; -import React from 'react'; -import {connect} from 'react-redux'; -import {defineMessages, injectIntl, intlShape} from 'react-intl'; -import VM from 'scratch-vm'; -import {closeFileMenu} from '../reducers/menus'; -import {closeLoadingProject, openLoadingProject} from '../reducers/modals'; -import {onLoadedProject, requestProjectUpload} from '../reducers/project-state'; -import {setFileHandle} from '../reducers/tw'; -import RestorePointAPI from '../lib/tw-restore-point-api'; - -const messages = defineMessages({ - error: { - defaultMessage: 'Could not load restore point: {error}', - description: 'Alert displayed when restore point loading failed', - id: 'tw.restorePoint.loadFail' - }, - confirm: { - // eslint-disable-next-line max-len - defaultMessage: 'The editor automatically records one restore point in case something goes wrong and you forget to save. You shouldn\'t rely on this and we can\'t guarantee it will recover your project. Try to load it?', - description: 'Confirmation to load restore point', - id: 'tw.restorePoint.confirm' - } -}); - -class RestorePointLoader extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'loadRestorePoint' - ]); - } - loadRestorePoint () { - // eslint-disable-next-line no-alert - if (!confirm(this.props.intl.formatMessage(messages.confirm))) { - return; - } - this.props.onLoadingStarted(); - this.props.requestProjectUpload(this.props.loadingState); - RestorePointAPI.load() - .then(arrayBuffer => this.props.vm.loadProject(arrayBuffer)) - .then(() => { - this.props.onLoadingFinished(this.props.loadingState, true); - }) - .catch(error => { - this.props.onLoadingFinished(this.props.loadingState, false); - // eslint-disable-next-line no-alert - alert(this.props.intl.formatMessage(messages.error, { - error - })); - }); - } - render () { - return this.props.children( - this.props.className, - this.loadRestorePoint - ); - } -} - -RestorePointLoader.propTypes = { - intl: intlShape, - children: PropTypes.func, - className: PropTypes.string, - loadingState: PropTypes.string, - onLoadingStarted: PropTypes.func, - onLoadingFinished: PropTypes.func, - requestProjectUpload: PropTypes.func, - vm: PropTypes.instanceOf(VM) -}; - -const mapStateToProps = state => ({ - loadingState: state.scratchGui.projectState.loadingState, - vm: state.scratchGui.vm -}); - -const mapDispatchToProps = dispatch => ({ - onLoadingFinished: (loadingState, success) => { - dispatch(onLoadedProject(loadingState, false, success)); - dispatch(closeLoadingProject()); - dispatch(closeFileMenu()); - dispatch(setFileHandle(null)); - }, - requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)), - onLoadingStarted: () => dispatch(openLoadingProject()) -}); - -export default injectIntl(connect( - mapStateToProps, - mapDispatchToProps -)(RestorePointLoader)); diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx new file mode 100644 index 00000000000..51b9b40ba4b --- /dev/null +++ b/src/containers/tw-restore-point-manager.jsx @@ -0,0 +1,366 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import {intlShape, injectIntl, defineMessages} from 'react-intl'; +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; +import {showAlertWithTimeout, showStandardAlert} from '../reducers/alerts'; +import {closeLoadingProject, closeRestorePointModal, openLoadingProject} from '../reducers/modals'; +import {LoadingStates, getIsShowingProject, onLoadedProject, requestProjectUpload} from '../reducers/project-state'; +import {setFileHandle} from '../reducers/tw'; +import TWRestorePointModal from '../components/tw-restore-point-modal/restore-point-modal.jsx'; +import RestorePointAPI from '../lib/tw-restore-point-api'; +import log from '../lib/log'; +import AddonHooks from '../addons/hooks'; + +/* eslint-disable no-alert */ + +const AUTOMATIC_INTERVAL = 1000 * 60 * 5; +const SAVE_DELAY = 250; +const MINIMUM_SAVE_TIME = 750; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +const messages = defineMessages({ + confirmLoad: { + defaultMessage: 'You have unsaved changes. Replace existing project?', + description: 'Confirmation that appears when loading a restore point to confirm overwriting unsaved changes.', + id: 'tw.restorePoints.confirmLoad' + }, + confirmDelete: { + defaultMessage: 'Are you sure you want to delete "{projectTitle}"? This cannot be undone.', + description: 'Confirmation that appears when deleting a restore poinnt', + id: 'tw.restorePoints.confirmDelete' + }, + confirmDeleteAll: { + defaultMessage: 'Are you sure you want to delete ALL restore points? This cannot be undone.', + description: 'Confirmation that appears when deleting ALL restore points.', + id: 'tw.restorePoints.confirmDeleteAll' + }, + loadError: { + defaultMessage: 'Error loading restore point: {error}', + description: 'Error message when a restore point could not be loaded', + id: 'tw.restorePoints.error' + } +}); + +class TWRestorePointManager extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleClickCreate', + 'handleClickDelete', + 'handleClickDeleteAll', + 'handleClickLoad', + 'handleClickLoadLegacy' + ]); + this.state = { + loading: true, + totalSize: 0, + restorePoints: [], + error: null + }; + this.timeout = null; + } + + componentDidMount () { + if (this.shouldBeAutosaving()) { + this.queueRestorePoint(); + } + } + + componentWillReceiveProps (nextProps) { + if (nextProps.isModalVisible && !this.props.isModalVisible) { + this.refreshState(); + } else if (!nextProps.isModalVisible && this.props.isModalVisible) { + this.setState({ + restorePoints: [] + }); + } + } + + componentDidUpdate (prevProps) { + if ( + this.props.projectChanged !== prevProps.projectChanged || + this.props.isShowingProject !== prevProps.isShowingProject + ) { + if (this.shouldBeAutosaving()) { + // Project was modified + this.queueRestorePoint(); + } else { + // Project was saved + clearTimeout(this.timeout); + this.timeout = null; + } + } + } + + componentWillUnmount () { + clearTimeout(this.timeout); + this.timeout = null; + } + + shouldBeAutosaving () { + return this.props.projectChanged && this.props.isShowingProject; + } + + isDisabled () { + return AddonHooks.disableRestorePoints; + } + + handleClickCreate () { + this.createRestorePoint(RestorePointAPI.TYPE_MANUAL) + .catch(error => { + this.handleModalError(error); + }); + } + + handleClickDelete (id) { + const projectTitle = this.state.restorePoints.find(i => i.id === id).title; + if (!confirm(this.props.intl.formatMessage(messages.confirmDelete, {projectTitle}))) { + return; + } + + this.setState({ + loading: true + }); + RestorePointAPI.deleteRestorePoint(id) + .then(() => { + this.refreshState(); + }) + .catch(error => { + this.handleModalError(error); + }); + } + + handleClickDeleteAll () { + if (!confirm(this.props.intl.formatMessage(messages.confirmDeleteAll))) { + return; + } + + this.setState({ + loading: true + }); + RestorePointAPI.deleteAllRestorePoints() + .then(() => { + this.refreshState(); + }) + .catch(error => { + this.handleModalError(error); + }); + } + + _startLoading () { + this.props.onCloseModal(); + this.props.onStartLoadingRestorePoint(this.props.loadingState); + } + + _finishLoading (success) { + setTimeout(() => { + this.props.vm.renderer.draw(); + }); + this.props.onFinishLoadingRestorePoint(success, this.props.loadingState); + } + + canLoadProject () { + if (!this.props.isShowingProject) { + // Loading a project now will break the state machine + return false; + } + if (this.props.projectChanged && !confirm(this.props.intl.formatMessage(messages.confirmLoad))) { + return false; + } + return true; + } + + handleClickLoad (id) { + if (!this.canLoadProject()) { + return; + } + this._startLoading(); + RestorePointAPI.loadRestorePoint(this.props.vm, id) + .then(() => { + this._finishLoading(true); + }) + .catch(error => { + this.handleLoadError(error); + }); + } + + handleClickLoadLegacy () { + if (!this.canLoadProject()) { + return; + } + this._startLoading(); + this.props.vm.stop(); + RestorePointAPI.loadLegacyRestorePoint() + .then(buffer => this.props.vm.loadProject(buffer)) + .then(() => { + setTimeout(() => { + this.props.vm.renderer.draw(); + }); + this._finishLoading(true); + }) + .catch(error => { + this.handleLoadError(error); + }); + } + + handleLoadError (error) { + log.error(error); + alert(this.props.intl.formatMessage(messages.loadError, { + error + })); + this._finishLoading(false); + } + + queueRestorePoint () { + if (this.timeout) { + return; + } + this.timeout = setTimeout(() => { + this.createRestorePoint(RestorePointAPI.TYPE_AUTOMATIC).then(() => { + this.timeout = null; + if (this.shouldBeAutosaving()) { + this.queueRestorePoint(); + } + }); + }, AUTOMATIC_INTERVAL); + } + + createRestorePoint (type) { + if (this.isDisabled()) { + return Promise.reject(new Error('Disabled')); + } + + if (this.props.isModalVisible) { + this.setState({ + loading: true + }); + } + + this.props.onStartCreatingRestorePoint(); + return Promise.all([ + // Wait a little bit before saving so UI can update before saving, which can cause stutter + sleep(SAVE_DELAY) + .then(() => RestorePointAPI.createRestorePoint(this.props.vm, this.props.projectTitle, type)) + .then(() => RestorePointAPI.removeExtraneousRestorePoints()), + + // Force saves to not be instant so people can see that we're making a restore point + // It also makes refreshes less likely to cause accidental clicks in the modal + sleep(MINIMUM_SAVE_TIME) + ]) + .then(() => { + this.props.onFinishCreatingRestorePoint(); + if (this.props.isModalVisible) { + this.refreshState(); + } + }) + .catch(error => { + log.error(error); + this.props.onErrorCreatingRestorePoint(); + if (this.props.isModalVisible) { + this.refreshState(); + } + }); + } + + refreshState () { + this.setState({ + loading: true, + error: null, + restorePoints: [] + }); + RestorePointAPI.getAllRestorePoints() + .then(data => { + this.setState({ + loading: false, + totalSize: data.totalSize, + restorePoints: data.restorePoints + }); + }) + .catch(error => { + this.handleModalError(error); + }); + } + + handleModalError (error) { + log.error('Restore point error', error); + this.setState({ + error: `${error}`, + loading: false + }); + } + + render () { + if (this.props.isModalVisible) { + return ( + + ); + } + return null; + } +} + +TWRestorePointManager.propTypes = { + intl: intlShape, + projectChanged: PropTypes.bool.isRequired, + projectTitle: PropTypes.string.isRequired, + onStartCreatingRestorePoint: PropTypes.func.isRequired, + onFinishCreatingRestorePoint: PropTypes.func.isRequired, + onErrorCreatingRestorePoint: PropTypes.func.isRequired, + onStartLoadingRestorePoint: PropTypes.func.isRequired, + onFinishLoadingRestorePoint: PropTypes.func.isRequired, + onCloseModal: PropTypes.func.isRequired, + loadingState: PropTypes.oneOf(LoadingStates).isRequired, + isShowingProject: PropTypes.bool.isRequired, + isModalVisible: PropTypes.bool.isRequired, + vm: PropTypes.shape({ + loadProject: PropTypes.func.isRequired, + stop: PropTypes.func.isRequired, + renderer: PropTypes.shape({ + draw: PropTypes.func.isRequired + }) + }).isRequired +}; + +const mapStateToProps = state => ({ + projectChanged: state.scratchGui.projectChanged, + projectTitle: state.scratchGui.projectTitle, + loadingState: state.scratchGui.projectState.loadingState, + isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState), + isModalVisible: state.scratchGui.modals.restorePointModal, + vm: state.scratchGui.vm +}); + +const mapDispatchToProps = dispatch => ({ + onStartCreatingRestorePoint: () => dispatch(showStandardAlert('twCreatingRestorePoint')), + onFinishCreatingRestorePoint: () => showAlertWithTimeout(dispatch, 'twRestorePointSuccess'), + onErrorCreatingRestorePoint: () => showAlertWithTimeout(dispatch, 'twRestorePointError'), + onStartLoadingRestorePoint: loadingState => { + dispatch(openLoadingProject()); + dispatch(requestProjectUpload(loadingState)); + }, + onFinishLoadingRestorePoint: (success, loadingState) => { + dispatch(onLoadedProject(loadingState, false, success)); + dispatch(closeLoadingProject()); + dispatch(setFileHandle(null)); + }, + onCloseModal: () => dispatch(closeRestorePointModal()) +}); + +export default injectIntl(connect( + mapStateToProps, + mapDispatchToProps +)(TWRestorePointManager)); diff --git a/src/lib/alerts/index.jsx b/src/lib/alerts/index.jsx index b2d89930659..b1e2da42516 100644 --- a/src/lib/alerts/index.jsx +++ b/src/lib/alerts/index.jsx @@ -185,18 +185,51 @@ const alerts = [ level: AlertLevels.INFO }, { - alertId: 'twAutosaving', + alertId: 'twCreatingRestorePoint', alertType: AlertTypes.INLINE, + clearList: ['twRestorePointSuccess', 'twRestorePointError'], content: ( ), iconSpinner: true, level: AlertLevels.INFO }, + { + alertId: 'twRestorePointSuccess', + alertType: AlertTypes.INLINE, + clearList: ['twCreatingRestorePoint'], + content: ( + + ), + iconURL: successImage, + level: AlertLevels.SUCCESS, + maxDisplaySecs: 3 + }, + { + alertId: 'twRestorePointError', + alertType: AlertTypes.INLINE, + clearList: ['twCreatingRestorePoint'], + content: ( + + ), + iconURL: successImage, + level: AlertLevels.WARN, + maxDisplaySecs: 5 + }, { alertId: 'cloudInfo', alertType: AlertTypes.STANDARD, diff --git a/src/lib/tw-base64-utils.js b/src/lib/tw-base64-utils.js new file mode 100644 index 00000000000..ca886dce777 --- /dev/null +++ b/src/lib/tw-base64-utils.js @@ -0,0 +1,19 @@ +export const base64ToArrayBuffer = base64 => { + const binaryString = atob(base64); + const len = binaryString.length; + const array = new Uint8Array(len); + for (let i = 0; i < len; i++) { + array[i] = binaryString.charCodeAt(i); + } + return array.buffer; +}; + +export const arrayBufferToBase64 = buffer => { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +}; diff --git a/src/lib/tw-bytes-utils.js b/src/lib/tw-bytes-utils.js new file mode 100644 index 00000000000..b8f80eae0bb --- /dev/null +++ b/src/lib/tw-bytes-utils.js @@ -0,0 +1,6 @@ +export const formatBytes = bytes => { + if (bytes < 1000 * 1000) { + return `${(bytes / 1000).toFixed(2)}KB`; + } + return `${(bytes / 1000 / 1000).toFixed(2)}MB`; +}; diff --git a/src/lib/tw-local-backpack-api.js b/src/lib/tw-local-backpack-api.js index c8afb88cfcf..6149f3b14b9 100644 --- a/src/lib/tw-local-backpack-api.js +++ b/src/lib/tw-local-backpack-api.js @@ -1,32 +1,13 @@ import storage from './storage'; import md5 from 'js-md5'; import {soundThumbnail} from './backpack/sound-payload'; +import {arrayBufferToBase64, base64ToArrayBuffer} from './tw-base64-utils'; // Special constants -- do not change without care. const DATABASE_NAME = 'TW_Backpack'; const DATABASE_VERSION = 1; const STORE_NAME = 'backpack'; -const base64ToArrayBuffer = base64 => { - const binaryString = atob(base64); - const len = binaryString.length; - const array = new Uint8Array(len); - for (let i = 0; i < len; i++) { - array[i] = binaryString.charCodeAt(i); - } - return array.buffer; -}; - -const arrayBufferToBase64 = buffer => { - let binary = ''; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -}; - const idbItemToBackpackItem = item => { // convert id to string item.id = `${item.id}`; diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js index e85daa844b8..65ddf1037d0 100644 --- a/src/lib/tw-restore-point-api.js +++ b/src/lib/tw-restore-point-api.js @@ -1,126 +1,578 @@ import JSZip from 'jszip'; +import {base64ToArrayBuffer} from './tw-base64-utils'; -// Special constants -- do not change without care. -const DATABASE_NAME = 'TW_AutoSave'; -const DATABASE_VERSION = 1; -const STORE_NAME = 'project'; +const TYPE_AUTOMATIC = 0; +const TYPE_MANUAL = 1; -let _db; +/** + * @typedef {0|1} MetadataType + */ + +/** + * @typedef Metadata + * @property {string} title + * @property {number} created Unix seconds + * @property {Type} type + * @property {number} projectSize JSON size in bytes + * @property {number} thumbnailSize Thumbnail size in bytes + * @property {number} thumbnailWidth + * @property {number} thumbnailHeight + * @property {Record} assets maps md5exts to size in bytes + */ + +const DATABASE_NAME = 'TW_RestorePoints'; +const DATABASE_VERSION = 2; +const METADATA_STORE = 'meta'; +const PROJECT_STORE = 'projects'; +const ASSET_STORE = 'assets'; +const THUMBNAIL_STORE = 'thumbnails'; +const ALL_STORES = [METADATA_STORE, PROJECT_STORE, ASSET_STORE, THUMBNAIL_STORE]; + +const MAX_AUTOMATIC_RESTORE_POINTS = 5; + +/** @type {IDBDatabase|null} */ +let _cachedDB = null; +/** + * @returns {Promise} IDB database with all stores created. + */ const openDB = () => { - if (_db) { - return _db; + if (_cachedDB) { + return Promise.resolve(_cachedDB); } - if (!window.indexedDB) { - throw new Error('indexedDB is not supported'); + if (typeof indexedDB === 'undefined') { + return Promise.resolve(null); } return new Promise((resolve, reject) => { const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); - request.onupgradeneeded = e => { - const db = e.target.result; - db.createObjectStore(STORE_NAME, { - keyPath: 'file' + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore(METADATA_STORE, { + autoIncrement: true }); + db.createObjectStore(PROJECT_STORE); + db.createObjectStore(ASSET_STORE); + db.createObjectStore(THUMBNAIL_STORE); }; - request.onsuccess = e => { - _db = e.target.result; - resolve(_db); + request.onsuccess = () => { + _cachedDB = request.result; + resolve(request.result); }; request.onerror = () => { - reject(new Error(`DB error: ${request.error}`)); + reject(new Error(`Could not open database: ${request.error}`)); }; }); }; /** - * Save a project to IDB. - * @param {VirtualMachine} vm Scratch VM + * Converts a possibly unknown or corrupted object to a known-good metadata object. + * @param {Partial} obj Unknown object + * @returns {Metadata} Metadata object with ID */ -const save = async vm => { - // To save a project, we will get all the assets inside it and save them to IDB. - // We will not actually generate a zip as that is slow. - const files = vm.saveProjectSb3DontZip(); - const db = await openDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - transaction.onerror = () => { - reject(new Error(`Save transaction error: ${transaction.error}`)); - }; +const parseMetadata = obj => { + // Must not throw -- always return the most salvageable object possible. + if (!obj || typeof obj !== 'object') { + obj = {}; + } - // Remove unused assets and don't waste time updating assets that are already in IDB. - const exists = []; - const projectStore = transaction.objectStore(STORE_NAME); - const request = projectStore.openCursor(); - request.onsuccess = e => { - const cursor = e.target.result; - if (cursor) { - const key = cursor.key; - if (files[key]) { - exists.push(key); - } else { + obj.title = typeof obj.title === 'string' ? obj.title : '?'; + obj.created = typeof obj.created === 'number' ? obj.created : 0; + obj.type = [TYPE_AUTOMATIC, TYPE_MANUAL].includes(obj.type) ? obj.type : 1; + + obj.thumbnailSize = typeof obj.thumbnailSize === 'number' ? obj.thumbnailSize : 0; + obj.projectSize = typeof obj.projectSize === 'number' ? obj.projectSize : 0; + + obj.thumbnailWidth = typeof obj.thumbnailWidth === 'number' ? obj.thumbnailWidth : 480; + obj.thumbnailHeight = typeof obj.thumbnailHeight === 'number' ? obj.thumbnailHeight : 360; + + obj.assets = (obj.assets && typeof obj.assets === 'object') ? obj.assets : {}; + for (const [asestId, size] of Object.entries(obj.assets)) { + if (typeof size !== 'number') { + delete obj.assets[asestId]; + } + } + + return obj; +}; + +/** + * @param {IDBObjectStore} objectStore IDB object store + * @param {Set} keysToKeep IDB keys that should continue to exist. Type sensitive. + * @returns {Promise} Resolves when unused items have been deleted + */ +const deleteUnknownKeys = (objectStore, keysToKeep) => new Promise(resolve => { + const keysRequest = objectStore.getAllKeys(); + keysRequest.onsuccess = async () => { + const allKeys = keysRequest.result; + + for (const key of allKeys) { + if (!keysToKeep.has(key)) { + await new Promise(innerResolve => { + const deleteRequest = objectStore.delete(key); + deleteRequest.onsuccess = () => { + innerResolve(); + }; + }); + } + } + + resolve(); + }; +}); + +/** + * @param {IDBTransaction} transaction readwrite transaction with access to all stores + * @returns {Promise} Resolves when data has finished being removed. + */ +const removeExtraneousData = transaction => new Promise(resolve => { + const metadataStore = transaction.objectStore(METADATA_STORE); + const projectStore = transaction.objectStore(PROJECT_STORE); + const assetStore = transaction.objectStore(ASSET_STORE); + const thumbnailStore = transaction.objectStore(THUMBNAIL_STORE); + + const requiredProjects = new Set(); + const requiredAssetIDs = new Set(); + + const request = metadataStore.openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + requiredProjects.add(cursor.key); + const metadata = parseMetadata(cursor.value); + for (const assetId of Object.keys(metadata.assets)) { + requiredAssetIDs.add(assetId); + } + cursor.continue(); + } else { + deleteUnknownKeys(projectStore, requiredProjects) + .then(() => deleteUnknownKeys(assetStore, requiredAssetIDs)) + .then(() => deleteUnknownKeys(thumbnailStore, requiredProjects)) + .then(() => resolve()); + } + }; +}); + +/** + * @returns {Promise} Resolves when extraneous restore points have been removed. + */ +const removeExtraneousRestorePoints = () => openDB().then(db => new Promise((resolveTransaction, rejectTransaction) => { + const transaction = db.transaction(ALL_STORES, 'readwrite'); + transaction.onerror = () => { + rejectTransaction(new Error(`Transaction error: ${transaction.error}`)); + }; + + let automaticCount = 0; + + const metadataStore = transaction.objectStore(METADATA_STORE); + const getRequest = metadataStore.openCursor(null, 'prev'); + getRequest.onsuccess = () => { + const cursor = getRequest.result; + if (cursor) { + const manifest = parseMetadata(cursor.value); + if (manifest.type === TYPE_AUTOMATIC) { + automaticCount++; + if (automaticCount > MAX_AUTOMATIC_RESTORE_POINTS) { cursor.delete(); } - cursor.continue(); - } else { - // Cursor is done, save all new files and project.json to IDB. - for (const file of Object.keys(files)) { - if (file === 'project.json' || !exists.includes(file)) { - projectStore.put({ - file, - data: files[file].buffer - }); - } - } + } + cursor.continue(); + } else { + removeExtraneousData(transaction) + .then(() => resolveTransaction()); + } + }; +})); + +// eslint-disable-next-line valid-jsdoc +/** + * @param {VirtualMachine} vm scratch-vm instance + * @returns {Promise<{type: string; data: ArrayBuffer;}>} Thumbnail data + */ +const generateThumbnail = vm => new Promise(resolve => { + // Piggyback off of the next draw if we can, otherwise just force it to render + const drawTimeout = setTimeout(() => { + vm.renderer.draw(); + }, 100); + + vm.renderer.requestSnapshot(dataURL => { + clearTimeout(drawTimeout); + + const index = dataURL.indexOf(','); + const base64 = dataURL.substring(index + 1); + const arrayBuffer = base64ToArrayBuffer(base64); + const type = 'image/png'; + resolve({ + type, + data: arrayBuffer + }); + }); +}); + +/** + * @param {VirtualMachine} vm scratch-vm instance + * @param {string} title project title + * @param {MetadataType} type restore point type + * @returns {Promise} resolves when the restore point is created + */ +const createRestorePoint = ( + vm, + title, + type +) => openDB().then(db => new Promise((resolveTransaction, rejectTransaction) => { + /** @type {Record} */ + const projectFiles = vm.saveProjectSb3DontZip(); + const jsonData = projectFiles['project.json']; + const projectAssetIDs = Object.keys(projectFiles).filter(i => i !== 'project.json'); + if (projectAssetIDs.length === 0) { + throw new Error('There are no assets in this project'); + } + + generateThumbnail(vm).then(thumbnailData => { + const transaction = db.transaction(ALL_STORES, 'readwrite'); + transaction.onerror = () => { + rejectTransaction(new Error(`Transaction error: ${transaction.error}`)); + }; + + // Will be generated by database + /** @type {IDBValidKey} */ + let generatedId = null; + + const writeThumbnail = () => { + const thumbnailStore = transaction.objectStore(THUMBNAIL_STORE); + const request = thumbnailStore.add(thumbnailData, generatedId); + request.onsuccess = () => { + resolveTransaction(); + }; + }; + + const writeMissingAssets = async missingAssets => { + const assetStore = transaction.objectStore(ASSET_STORE); + for (const assetId of missingAssets) { + await new Promise(resolveAsset => { + // TODO: should we insert arraybuffer or uint8array? + const assetData = projectFiles[assetId]; + const request = assetStore.add(assetData, assetId); + request.onsuccess = () => { + resolveAsset(); + }; + }); + } + + writeThumbnail(); + }; + + const checkMissingAssets = () => { + const assetStore = transaction.objectStore(ASSET_STORE); + const keyRequest = assetStore.getAllKeys(); + keyRequest.onsuccess = () => { + const savedAssets = keyRequest.result; + const missingAssets = projectAssetIDs.filter(assetId => !savedAssets.includes(assetId)); + writeMissingAssets(missingAssets); + }; + }; + + const writeProjectJSON = () => { + const projectStore = transaction.objectStore(PROJECT_STORE); + const request = projectStore.add(jsonData, generatedId); + request.onsuccess = () => { + checkMissingAssets(); + }; + }; - resolve(); + const writeMetadata = () => { + const assetSizeData = {}; + for (const assetId of projectAssetIDs) { + const assetData = projectFiles[assetId]; + assetSizeData[assetId] = assetData.byteLength; } + + /** @type {Metadata} */ + const metadata = { + title, + created: Math.round(Date.now() / 1000), + type, + projectSize: jsonData.byteLength, + thumbnailSize: thumbnailData.data.byteLength, + thumbnailWidth: vm.runtime.stageWidth, + thumbnailHeight: vm.runtime.stageHeight, + assets: assetSizeData + }; + + const metadataStore = transaction.objectStore(METADATA_STORE); + const request = metadataStore.add(metadata); + request.onsuccess = () => { + generatedId = request.result; + writeProjectJSON(); + }; }; + + writeMetadata(); }); -}; +})); /** - * Load a project from IDB. - * @returns {Promise} sb3 project to load. + * @param {number} id the restore point's ID + * @returns {Promise} Resovles when the restore point has been deleted. */ -const load = async () => { - // To load a project, read the files from IDB and generate a .sb3 - const db = await openDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readonly'); +const deleteRestorePoint = id => openDB().then(db => new Promise((resolve, reject) => { + const transaction = db.transaction(ALL_STORES, 'readwrite'); + transaction.onerror = () => { + reject(new Error(`Transaction error: ${transaction.error}`)); + }; + + const metadataStore = transaction.objectStore(METADATA_STORE); + const request = metadataStore.delete(id); + request.onsuccess = () => { + removeExtraneousData(transaction) + .then(() => resolve()); + }; +})); + +/** + * @returns {Promise} Resolves when all data in the database has been deleted. + */ +const deleteAllRestorePoints = () => openDB().then(db => new Promise((resolveTransaction, rejectTransaction) => { + const transaction = db.transaction(ALL_STORES, 'readwrite'); + transaction.onerror = () => { + rejectTransaction(new Error(`Transaction error: ${transaction.error}`)); + }; + + const deleteEverything = async () => { + for (const storeName of ALL_STORES) { + await new Promise(resolve => { + const store = transaction.objectStore(storeName); + const request = store.clear(); + request.onsuccess = () => { + resolve(); + }; + }); + } + + resolveTransaction(); + }; + + deleteEverything(); +})); + +/** + * @param {VirtualMachine} vm scratch-vm instance + * @param {number} id the restore point's ID + * @returns {Promise} Resolves with sb3 file + */ +const loadRestorePoint = (vm, id) => openDB().then(db => new Promise((resolveTransaction, rejectTransaction) => { + const transaction = db.transaction([METADATA_STORE, PROJECT_STORE, ASSET_STORE], 'readonly'); + transaction.onerror = () => { + rejectTransaction(new Error(`Transaction error: ${transaction.error}`)); + }; + + const zip = new JSZip(); + /** @type {Metadata} */ + let metadata; + + // TODO: we should be able to use a custom scratch-storage helper to avoid putting the + // zip in memory. + + const loadVM = () => { + resolveTransaction( + zip.generateAsync({ + // Don't bother compressing it since it will be immediately decompressed + type: 'arraybuffer' + }) + .then(sb3 => vm.loadProject(sb3)) + .then(() => { + setTimeout(() => { + vm.renderer.draw(); + }); + }) + ); + }; + + const loadAssets = async () => { + const assetStore = transaction.objectStore(ASSET_STORE); + for (const assetId of Object.keys(metadata.assets)) { + await new Promise(resolve => { + const request = assetStore.get(assetId); + request.onsuccess = () => { + const data = request.result; + zip.file(assetId, data); + resolve(); + }; + }); + } + + loadVM(); + }; + + const loadProjectJSON = () => { + const projectStore = transaction.objectStore(PROJECT_STORE); + const request = projectStore.get(id); + request.onsuccess = () => { + zip.file('project.json', request.result); + loadAssets(); + }; + }; + + const loadMetadata = () => { + const metadataStore = transaction.objectStore(METADATA_STORE); + const request = metadataStore.get(id); + request.onsuccess = () => { + metadata = parseMetadata(request.result); + loadProjectJSON(); + }; + }; + + vm.stop(); + + loadMetadata(); +})); + +// eslint-disable-next-line valid-jsdoc +/** + * @returns {Promise<{totalSize: number; restorePoints: Array}>} Restore point information. + */ +const getAllRestorePoints = () => openDB().then(db => new Promise((resolve, reject) => { + const transaction = db.transaction([METADATA_STORE], 'readonly'); + transaction.onerror = () => { + reject(new Error(`Transaction error: ${transaction.error}`)); + }; + + /** @type {Metadata[]} */ + const restorePoints = []; + /** @type {Set} */ + const countedAssets = new Set(); + let totalSize = 0; + + const metadataStore = transaction.objectStore(METADATA_STORE); + const request = metadataStore.openCursor(null, 'prev'); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + const parsed = parseMetadata(cursor.value); + parsed.id = cursor.key; + restorePoints.push(parsed); + + totalSize += parsed.projectSize; + totalSize += parsed.thumbnailSize; + for (const [assetId, assetSize] of Object.entries(parsed.assets)) { + if (!countedAssets.has(assetId)) { + countedAssets.add(assetId); + totalSize += assetSize; + } + } + + cursor.continue(); + } else { + resolve({ + totalSize, + restorePoints + }); + } + }; +})); + +/** + * @param {number} id restore point's ID + * @returns {Promise} The URL to load + */ +const getThumbnail = id => openDB().then(db => new Promise((resolve, reject) => { + const transaction = db.transaction([THUMBNAIL_STORE], 'readonly'); + transaction.onerror = () => { + reject(new Error(`Transaction error: ${transaction.error}`)); + }; + + const thumbnailStore = transaction.objectStore(THUMBNAIL_STORE); + const request = thumbnailStore.get(id); + request.onsuccess = () => { + const thumbnail = request.result; + if (!thumbnail) { + reject(new Error('No thumbnail found')); + return; + } + + const blob = new Blob([thumbnail.data], { + type: thumbnail.type + }); + const url = URL.createObjectURL(blob); + resolve(url); + }; +})); + +// We will enable this after a couple days +/* +const deleteLegacyData = () => { + try { + if (typeof indexedDB !== 'undefined') { + const _request = indexedDB.deleteDatabase('TW_AutoSave'); + // don't really care what happens to the request at this point + } + } catch (e) { + // ignore + } +}; +*/ + +const loadLegacyRestorePoint = () => new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('indexedDB not supported')); + return; + } + + const LEGACY_DATABASE_NAME = 'TW_AutoSave'; + const LEGACY_DATABASE_VERSION = 1; + const LEGACY_STORE_NAME = 'project'; + + const openRequest = indexedDB.open(LEGACY_DATABASE_NAME, LEGACY_DATABASE_VERSION); + openRequest.onerror = () => { + reject(new Error(`Error opening DB: ${openRequest.error}`)); + }; + openRequest.onsuccess = () => { + const db = openRequest.result; + if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { + reject(new Error('Object store does not exist')); + return; + } + + const transaction = db.transaction(LEGACY_STORE_NAME, 'readonly'); transaction.onerror = () => { - reject(new Error(`Load transaction error: ${transaction.error}`)); + reject(new Error(`Transaction error: ${transaction.error}`)); }; const zip = new JSZip(); - const projectStore = transaction.objectStore(STORE_NAME); - const request = projectStore.openCursor(); - request.onsuccess = e => { - const cursor = e.target.result; + const projectStore = transaction.objectStore(LEGACY_STORE_NAME); + const cursorRequest = projectStore.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; if (cursor) { zip.file(cursor.key, cursor.value.data); cursor.continue(); } else { - // Cursor is done, all files added to zip. - const hasJSON = zip.file('project.json'); + const hasJSON = !!zip.file('project.json'); if (hasJSON) { resolve(zip.generateAsync({ type: 'arraybuffer' - // No reason to compress this zip. })); } else { - reject(new Error('Could not find project')); + reject(new Error('Could not find project.json')); } } }; - }); -}; + }; +}); export default { - save, - load + TYPE_AUTOMATIC, + TYPE_MANUAL, + getAllRestorePoints, + createRestorePoint, + removeExtraneousRestorePoints, + deleteRestorePoint, + deleteAllRestorePoints, + getThumbnail, + loadRestorePoint, + loadLegacyRestorePoint }; diff --git a/src/lib/tw-restore-point-hoc.jsx b/src/lib/tw-restore-point-hoc.jsx deleted file mode 100644 index 9bb3cb50306..00000000000 --- a/src/lib/tw-restore-point-hoc.jsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; -import {showStandardAlert, closeAlertWithId} from '../reducers/alerts'; -import {getIsShowingProject} from '../reducers/project-state'; -import bindAll from 'lodash.bindall'; -import VM from 'scratch-vm'; -import RestorePointAPI from './tw-restore-point-api'; - -/** - * @fileoverview - * HOC responsible for automatically creating restore points. - */ - -const INTERVAL = 1000 * 60 * 5; - -let bailed = false; - -const disabled = () => bailed || window.DISABLE_RESTORE_POINTS; - -const TWRestorePointHOC = function (WrappedComponent) { - class RestorePointComponent extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'createRestorePoint' - ]); - this.timeout = null; - } - componentDidUpdate (prevProps) { - if (disabled()) { - return; - } - if ( - this.props.projectChanged !== prevProps.projectChanged || - this.props.isShowingProject !== prevProps.isShowingProject - ) { - if (this.props.projectChanged && this.props.isShowingProject) { - // Project was modified; queue restore point. - this.timeout = setTimeout(this.createRestorePoint, INTERVAL); - } else { - // Project was saved; abort restore point. - clearTimeout(this.timeout); - this.timeout = null; - } - } - } - componentWillUnmount () { - clearTimeout(this.timeout); - } - async createRestorePoint () { - if (disabled()) { - return; - } - try { - this.props.onAutosavingStart(); - await RestorePointAPI.save(this.props.vm); - } catch (error) { - bailed = true; - } - this.timeout = null; - // Intentional delay. - setTimeout(() => { - this.props.onAutosavingFinish(); - if (this.timeout === null && !bailed && this.props.projectChanged && this.props.isShowingProject) { - this.timeout = setTimeout(this.createRestorePoint, INTERVAL); - } - }, 250); - } - render () { - const { - /* eslint-disable no-unused-vars */ - projectChanged, - onAutosavingStart, - onAutosavingFinish, - vm, - /* eslint-enable no-unused-vars */ - ...props - } = this.props; - return ( - - ); - } - } - RestorePointComponent.propTypes = { - isShowingProject: PropTypes.bool, - projectChanged: PropTypes.bool, - onAutosavingStart: PropTypes.func, - onAutosavingFinish: PropTypes.func, - vm: PropTypes.instanceOf(VM) - }; - const mapStateToProps = state => ({ - isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState), - projectChanged: state.scratchGui.projectChanged, - vm: state.scratchGui.vm - }); - const mapDispatchToProps = dispatch => ({ - onAutosavingStart: () => dispatch(showStandardAlert('twAutosaving')), - onAutosavingFinish: () => dispatch(closeAlertWithId('twAutosaving')) - }); - return connect( - mapStateToProps, - mapDispatchToProps - )(RestorePointComponent); -}; - -export { - TWRestorePointHOC as default -}; diff --git a/src/playground/render-interface.jsx b/src/playground/render-interface.jsx index f1f0dac5687..3612352a676 100644 --- a/src/playground/render-interface.jsx +++ b/src/playground/render-interface.jsx @@ -29,7 +29,6 @@ import TWStateManagerHOC from '../lib/tw-state-manager-hoc.jsx'; import TWThemeHOC from '../lib/tw-theme-hoc.jsx'; import SBFileUploaderHOC from '../lib/sb-file-uploader-hoc.jsx'; import TWPackagerIntegrationHOC from '../lib/tw-packager-integration-hoc.jsx'; -import TWRestorePointHOC from '../lib/tw-restore-point-hoc.jsx'; import SettingsStore from '../addons/settings-store-singleton'; import '../lib/tw-fix-history-api'; import GUI from './render-gui.jsx'; @@ -385,7 +384,6 @@ const WrappedInterface = compose( TWProjectMetaFetcherHOC, TWStateManagerHOC, TWThemeHOC, - TWRestorePointHOC, TWPackagerIntegrationHOC )(ConnectedInterface); diff --git a/src/reducers/modals.js b/src/reducers/modals.js index a6bdfecc8ef..032d4c95350 100644 --- a/src/reducers/modals.js +++ b/src/reducers/modals.js @@ -14,6 +14,7 @@ const MODAL_TIPS_LIBRARY = 'tipsLibrary'; const MODAL_USERNAME = 'usernameModal'; const MODAL_SETTINGS = 'settingsModal'; const MODAL_CUSTOM_EXTENSION = 'customExtensionModal'; +const MODAL_RESTORE_POINTS = 'restorePointModal'; const initialState = { [MODAL_BACKDROP_LIBRARY]: false, @@ -28,7 +29,8 @@ const initialState = { [MODAL_TIPS_LIBRARY]: false, [MODAL_USERNAME]: false, [MODAL_SETTINGS]: false, - [MODAL_CUSTOM_EXTENSION]: false + [MODAL_CUSTOM_EXTENSION]: false, + [MODAL_RESTORE_POINTS]: false }; const reducer = function (state, action) { @@ -97,6 +99,9 @@ const openSettingsModal = function () { const openCustomExtensionModal = function () { return openModal(MODAL_CUSTOM_EXTENSION); }; +const openRestorePointModal = function () { + return openModal(MODAL_RESTORE_POINTS); +}; const closeBackdropLibrary = function () { return closeModal(MODAL_BACKDROP_LIBRARY); }; @@ -136,6 +141,9 @@ const closeSettingsModal = function () { const closeCustomExtensionModal = function () { return closeModal(MODAL_CUSTOM_EXTENSION); }; +const closeRestorePointModal = function () { + return closeModal(MODAL_RESTORE_POINTS); +}; export { reducer as default, initialState as modalsInitialState, @@ -152,6 +160,7 @@ export { openUsernameModal, openSettingsModal, openCustomExtensionModal, + openRestorePointModal, closeBackdropLibrary, closeCostumeLibrary, closeExtensionLibrary, @@ -164,5 +173,6 @@ export { closeConnectionModal, closeUsernameModal, closeSettingsModal, - closeCustomExtensionModal + closeCustomExtensionModal, + closeRestorePointModal };