From a04554229f9311595c5d1624839f01bf1c6a8dc7 Mon Sep 17 00:00:00 2001
From: chrisgarrity <chrisg@media.mit.edu>
Date: Tue, 19 Jun 2018 15:40:47 -0400
Subject: [PATCH] Initial version of loading or switching language.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Add `?locale=<localecode>` to load in a new langage.
Add `?enable=language` to enable the language selector
Or combine the two: `?enable=language&locale=<localecode>`
Still accepts a hash, but requires that the hash be a sequence of digits.

Added new locales reducer to gui:
- initializes default  react-intl/localeData
- initializes default locale messages for all locales
- default locale is ‘en’ (can be overridden by URL)

Removed dependency on react-intl-redux, added ConnectedIntlProvider (uses correct locale messages from redux based on current locale)
---
 package.json                                  |  1 -
 src/containers/blocks.jsx                     | 21 ++++++--
 src/containers/language-selector.jsx          | 14 +++--
 src/containers/menu.jsx                       |  7 ++-
 src/index.js                                  |  5 +-
 src/lib/app-state-hoc.jsx                     | 23 ++++----
 src/lib/connected-intl-provider.jsx           | 10 ++++
 src/lib/hash-parser-hoc.jsx                   |  4 +-
 src/playground/intl.js                        | 19 -------
 src/reducers/gui.js                           | 18 +++++++
 src/reducers/intl.js                          | 14 -----
 src/reducers/locales.js                       | 53 +++++++++++++++++++
 .../util/hash-project-loader-hoc.test.jsx     | 16 ++++--
 13 files changed, 139 insertions(+), 66 deletions(-)
 create mode 100644 src/lib/connected-intl-provider.jsx
 delete mode 100644 src/playground/intl.js
 delete mode 100644 src/reducers/intl.js
 create mode 100644 src/reducers/locales.js

diff --git a/package.json b/package.json
index c598483d7..e2121c84b 100644
--- a/package.json
+++ b/package.json
@@ -79,7 +79,6 @@
     "react-draggable": "3.0.5",
     "react-ga": "2.5.3",
     "react-intl": "2.4.0",
-    "react-intl-redux": "0.7.0",
     "react-modal": "3.4.4",
     "react-popover": "0.5.7",
     "react-redux": "5.0.7",
diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx
index 036392421..fa51f4773 100644
--- a/src/containers/blocks.jsx
+++ b/src/containers/blocks.jsx
@@ -52,7 +52,8 @@ class Blocks extends React.Component {
             'onVisualReport',
             'onWorkspaceUpdate',
             'onWorkspaceMetricsChange',
-            'setBlocks'
+            'setBlocks',
+            'setLocale'
         ]);
         this.ScratchBlocks.prompt = this.handlePromptStart;
         this.state = {
@@ -86,7 +87,7 @@ class Blocks extends React.Component {
         addFunctionListener(this.workspace, 'zoom', this.onWorkspaceMetricsChange);
 
         this.attachVM();
-        this.props.vm.setLocale(this.props.locale, this.props.messages);
+        this.setLocale();
 
         analytics.pageview('/editors/blocks');
     }
@@ -109,7 +110,7 @@ class Blocks extends React.Component {
         }
 
         if (prevProps.locale !== this.props.locale) {
-            this.props.vm.setLocale(this.props.locale, this.props.messages);
+            this.setLocale();
         }
 
         if (prevProps.toolboxXML !== this.props.toolboxXML) {
@@ -144,6 +145,16 @@ class Blocks extends React.Component {
         clearTimeout(this.toolboxUpdateTimeout);
     }
 
+    setLocale () {
+        this.workspace.getFlyout().setRecyclingEnabled(false);
+        this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale);
+        this.props.vm.setLocale(this.props.locale, this.props.messages);
+
+        this.workspace.updateToolbox(this.props.toolboxXML);
+        this.props.vm.refreshWorkspace();
+        this.workspace.getFlyout().setRecyclingEnabled(true);
+    }
+
     updateToolbox () {
         this.toolboxUpdateTimeout = false;
 
@@ -461,8 +472,8 @@ const mapStateToProps = state => ({
         state.scratchGui.mode.isFullScreen
     ),
     extensionLibraryVisible: state.scratchGui.modals.extensionLibrary,
-    locale: state.intl.locale,
-    messages: state.intl.messages,
+    locale: state.scratchGui.locales.locale,
+    messages: state.scratchGui.locales.messages[state.scratchGui.locales.locale],
     toolboxXML: state.scratchGui.toolbox.toolboxXML,
     customProceduresVisible: state.scratchGui.customProcedures.active
 });
diff --git a/src/containers/language-selector.jsx b/src/containers/language-selector.jsx
index 49fb60943..0b61984be 100644
--- a/src/containers/language-selector.jsx
+++ b/src/containers/language-selector.jsx
@@ -2,7 +2,7 @@ import bindAll from 'lodash.bindall';
 import PropTypes from 'prop-types';
 import React from 'react';
 import {connect} from 'react-redux';
-import {updateIntl} from 'react-intl-redux';
+import {selectLocale} from '../reducers/locales';
 import {closeLanguageMenu} from '../reducers/menus';
 
 import LanguageSelectorComponent from '../components/language-selector/language-selector.jsx';
@@ -15,7 +15,10 @@ class LanguageSelector extends React.Component {
         ]);
     }
     handleChange (e) {
-        this.props.onChangeLanguage(e.target.value);
+        const newLocale = e.target.value;
+        if (this.props.locales.hasOwnProperty(newLocale)) {
+            this.props.onChangeLanguage(newLocale);
+        }
     }
     render () {
         const {
@@ -36,16 +39,19 @@ class LanguageSelector extends React.Component {
 
 LanguageSelector.propTypes = {
     children: PropTypes.node,
+    currentLocale: PropTypes.string.isRequired,
+    locales: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
     onChangeLanguage: PropTypes.func.isRequired
 };
 
 const mapStateToProps = state => ({
-    currentLocale: state.intl.locale
+    currentLocale: state.scratchGui.locales.locale,
+    locales: state.scratchGui.locales.messages
 });
 
 const mapDispatchToProps = dispatch => ({
     onChangeLanguage: locale => {
-        dispatch(updateIntl({locale: locale, messages: {}}));
+        dispatch(selectLocale(locale));
         dispatch(closeLanguageMenu());
     }
 });
diff --git a/src/containers/menu.jsx b/src/containers/menu.jsx
index 865ad19d3..e9a51cd4c 100644
--- a/src/containers/menu.jsx
+++ b/src/containers/menu.jsx
@@ -13,12 +13,17 @@ class Menu extends React.Component {
             'handleClick',
             'ref'
         ]);
-        if (props.open) this.addListeners();
+    }
+    componentDidMount () {
+        if (this.props.open) this.addListeners();
     }
     componentDidUpdate (prevProps) {
         if (this.props.open && !prevProps.open) this.addListeners();
         if (!this.props.open && prevProps.open) this.removeListeners();
     }
+    componentWillUnmount () {
+        this.removeListeners();
+    }
     addListeners () {
         document.addEventListener('mouseup', this.handleClick);
     }
diff --git a/src/index.js b/src/index.js
index 3bfa737f5..f47b279bc 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,12 +1,10 @@
 import GUI from './containers/gui.jsx';
-import GuiReducer, {guiInitialState, guiMiddleware, initFullScreen, initPlayer} from './reducers/gui';
+import GuiReducer, {guiInitialState, guiMiddleware, initFullScreen, initPlayer, initLocale} from './reducers/gui';
 import {ScratchPaintReducer} from 'scratch-paint';
-import IntlReducer from './reducers/intl';
 import {setFullScreen, setPlayer} from './reducers/mode';
 import {setAppElement} from 'react-modal';
 
 const guiReducers = {
-    intl: IntlReducer,
     scratchGui: GuiReducer,
     scratchPaint: ScratchPaintReducer
 };
@@ -19,6 +17,7 @@ export {
     guiMiddleware,
     initPlayer,
     initFullScreen,
+    initLocale,
     setFullScreen,
     setPlayer
 };
diff --git a/src/lib/app-state-hoc.jsx b/src/lib/app-state-hoc.jsx
index dc62aa31b..7c23c4839 100644
--- a/src/lib/app-state-hoc.jsx
+++ b/src/lib/app-state-hoc.jsx
@@ -2,12 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import {Provider} from 'react-redux';
 import {createStore, combineReducers, compose} from 'redux';
+import ConnectedIntlProvider from './connected-intl-provider.jsx';
 
-import {intlShape} from 'react-intl';
-import {IntlProvider, updateIntl} from 'react-intl-redux';
-import intlReducer from '../reducers/intl.js';
-
-import guiReducer, {guiInitialState, guiMiddleware, initFullScreen, initPlayer} from '../reducers/gui';
+import guiReducer, {guiInitialState, guiMiddleware, initFullScreen, initLocale, initPlayer} from '../reducers/gui';
 
 import {setPlayer, setFullScreen} from '../reducers/mode.js';
 
@@ -34,20 +31,22 @@ const AppStateHOC = function (WrappedComponent) {
                 initializedGui = initPlayer(initializedGui);
             }
             const reducer = combineReducers({
-                intl: intlReducer,
                 scratchGui: guiReducer,
                 scratchPaint: ScratchPaintReducer
             });
 
+            if (window.location.search.indexOf('locale=') !== -1 ||
+                window.location.search.indexOf('lang=') !== -1) {
+                const locale = window.location.search.match(/(?:locale|lang)=([\w]+)/)[1];
+                initializedGui = initLocale(initializedGui, locale);
+            }
+
             this.store = createStore(
                 reducer,
                 {scratchGui: initializedGui},
                 enhancer);
         }
         componentDidUpdate (prevProps) {
-            if (prevProps.intl !== this.props.intl) {
-                this.store.dispatch(updateIntl(this.props.intl));
-            }
             if (prevProps.isPlayerOnly !== this.props.isPlayerOnly) {
                 this.store.dispatch(setPlayer(this.props.isPlayerOnly));
             }
@@ -57,22 +56,20 @@ const AppStateHOC = function (WrappedComponent) {
         }
         render () {
             const {
-                intl, // eslint-disable-line no-unused-vars
                 isFullScreen, // eslint-disable-line no-unused-vars
                 isPlayerOnly, // eslint-disable-line no-unused-vars
                 ...componentProps
             } = this.props;
             return (
                 <Provider store={this.store}>
-                    <IntlProvider>
+                    <ConnectedIntlProvider>
                         <WrappedComponent {...componentProps} />
-                    </IntlProvider>
+                    </ConnectedIntlProvider>
                 </Provider>
             );
         }
     }
     AppStateWrapper.propTypes = {
-        intl: intlShape,
         isFullScreen: PropTypes.bool,
         isPlayerOnly: PropTypes.bool
     };
diff --git a/src/lib/connected-intl-provider.jsx b/src/lib/connected-intl-provider.jsx
new file mode 100644
index 000000000..e1eacb3ad
--- /dev/null
+++ b/src/lib/connected-intl-provider.jsx
@@ -0,0 +1,10 @@
+import {IntlProvider as ReactIntlProvider} from 'react-intl';
+import {connect} from 'react-redux';
+
+const mapStateToProps = state => ({
+    key: state.scratchGui.locales.locale,
+    locale: state.scratchGui.locales.locale,
+    messages: state.scratchGui.locales.messages[state.scratchGui.locales.locale]
+});
+
+export default connect(mapStateToProps)(ReactIntlProvider);
diff --git a/src/lib/hash-parser-hoc.jsx b/src/lib/hash-parser-hoc.jsx
index 04250aa6e..f3ac92542 100644
--- a/src/lib/hash-parser-hoc.jsx
+++ b/src/lib/hash-parser-hoc.jsx
@@ -24,9 +24,9 @@ const HashParserHOC = function (WrappedComponent) {
             window.removeEventListener('hashchange', this.handleHashChange);
         }
         handleHashChange () {
-            let projectId = window.location.hash.substring(1);
+            const hashMatch = window.location.hash.match(/#(\d+)/);
+            const projectId = hashMatch === null ? 0 : hashMatch[1];
             if (projectId !== this.state.projectId) {
-                if (projectId.length < 1) projectId = 0;
                 this.setState({projectId: projectId});
             }
         }
diff --git a/src/playground/intl.js b/src/playground/intl.js
deleted file mode 100644
index 42b72b003..000000000
--- a/src/playground/intl.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import {addLocaleData} from 'react-intl';
-
-import localeData from 'scratch-l10n';
-import editorMessages from 'scratch-l10n/locales/editor-msgs';
-
-Object.keys(localeData).forEach(locale => {
-    addLocaleData(localeData[locale].localeData);
-});
-
-const intlDefault = {
-    defaultLocale: 'en',
-    locale: 'en',
-    messages: editorMessages.en
-};
-
-export {
-    intlDefault as default,
-    editorMessages
-};
diff --git a/src/reducers/gui.js b/src/reducers/gui.js
index 829f0b044..810fce7c2 100644
--- a/src/reducers/gui.js
+++ b/src/reducers/gui.js
@@ -7,6 +7,7 @@ import blockDragReducer, {blockDragInitialState} from './block-drag';
 import editorTabReducer, {editorTabInitialState} from './editor-tab';
 import hoveredTargetReducer, {hoveredTargetInitialState} from './hovered-target';
 import menuReducer, {menuInitialState} from './menus';
+import localesReducer, {localesInitialState} from './locales';
 import modalReducer, {modalsInitialState} from './modals';
 import modeReducer, {modeInitialState} from './mode';
 import monitorReducer, {monitorsInitialState} from './monitors';
@@ -26,6 +27,7 @@ const guiInitialState = {
     colorPicker: colorPickerInitialState,
     customProcedures: customProceduresInitialState,
     editorTab: editorTabInitialState,
+    locales: localesInitialState,
     mode: modeInitialState,
     hoveredTarget: hoveredTargetInitialState,
     stageSize: stageSizeInitialState,
@@ -58,6 +60,20 @@ const initFullScreen = function (currentState) {
         }}
     );
 };
+const initLocale = function (currentState, locale) {
+    if (currentState.locales.messages.hasOwnProperty(locale)) {
+        return Object.assign(
+            {},
+            currentState,
+            {locales: {
+                locale: locale,
+                messages: currentState.locales.messages
+            }}
+        );
+    }
+    // don't change locale if it's not in the current messages
+    return currentState;
+};
 const guiReducer = combineReducers({
     assetDrag: assetDragReducer,
     blockDrag: blockDragReducer,
@@ -65,6 +81,7 @@ const guiReducer = combineReducers({
     colorPicker: colorPickerReducer,
     customProcedures: customProceduresReducer,
     editorTab: editorTabReducer,
+    locales: localesReducer,
     mode: modeReducer,
     hoveredTarget: hoveredTargetReducer,
     stageSize: stageSizeReducer,
@@ -82,5 +99,6 @@ export {
     guiInitialState,
     guiMiddleware,
     initFullScreen,
+    initLocale,
     initPlayer
 };
diff --git a/src/reducers/intl.js b/src/reducers/intl.js
deleted file mode 100644
index 8c49ee104..000000000
--- a/src/reducers/intl.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import {intlReducer} from 'react-intl-redux';
-
-const intlInitialState = {
-    intl: {
-        defaultLocale: 'en',
-        locale: 'en',
-        messages: {}
-    }
-};
-
-export {
-    intlReducer as default,
-    intlInitialState
-};
diff --git a/src/reducers/locales.js b/src/reducers/locales.js
new file mode 100644
index 000000000..d1336a91e
--- /dev/null
+++ b/src/reducers/locales.js
@@ -0,0 +1,53 @@
+import {addLocaleData} from 'react-intl';
+
+import {localeData} from 'scratch-l10n';
+import editorMessages from 'scratch-l10n/locales/editor-msgs';
+
+addLocaleData(localeData);
+
+const UPDATE_LOCALES = 'scratch-gui/locales/UPDATE_LOCALES';
+const SELECT_LOCALE = 'scratch-gui/locales/SELECT_LOCALE';
+
+const initialState = {
+    locale: 'en',
+    messages: editorMessages
+};
+
+const reducer = function (state, action) {
+    if (typeof state === 'undefined') state = initialState;
+    switch (action.type) {
+    case SELECT_LOCALE:
+        return Object.assign({}, state, {
+            locale: action.locale,
+            messages: state.messages
+        });
+    case UPDATE_LOCALES:
+        return Object.assign({}, state, {
+            locale: state.locale,
+            messages: action.messages
+        });
+    default:
+        return state;
+    }
+};
+
+const selectLocale = function (locale) {
+    return {
+        type: SELECT_LOCALE,
+        locale: locale
+    };
+};
+
+const setLocales = function (localesMessages) {
+    return {
+        type: UPDATE_LOCALES,
+        messages: localesMessages
+    };
+};
+
+export {
+    reducer as default,
+    initialState as localesInitialState,
+    selectLocale,
+    setLocales
+};
diff --git a/test/unit/util/hash-project-loader-hoc.test.jsx b/test/unit/util/hash-project-loader-hoc.test.jsx
index 5cf78747a..1242385d6 100644
--- a/test/unit/util/hash-project-loader-hoc.test.jsx
+++ b/test/unit/util/hash-project-loader-hoc.test.jsx
@@ -8,9 +8,9 @@ describe('HashParserHOC', () => {
     test('when there is a hash, it passes the hash as projectId', () => {
         const Component = ({projectId}) => <div>{projectId}</div>;
         const WrappedComponent = HashParserHOC(Component);
-        window.location.hash = '#winning';
+        window.location.hash = '#1234567';
         const mounted = mount(<WrappedComponent />);
-        expect(mounted.state().projectId).toEqual('winning');
+        expect(mounted.state().projectId).toEqual('1234567');
     });
 
     test('when there is no hash, it passes 0 as the projectId', () => {
@@ -21,14 +21,22 @@ describe('HashParserHOC', () => {
         expect(mounted.state().projectId).toEqual(0);
     });
 
+    test('when the hash is not a number, it passes 0 as projectId', () => {
+        const Component = ({projectId}) => <div>{projectId}</div>;
+        const WrappedComponent = HashParserHOC(Component);
+        window.location.hash = '#winning';
+        const mounted = mount(<WrappedComponent />);
+        expect(mounted.state().projectId).toEqual(0);
+    });
+
     test('when hash change happens, the projectId state is changed', () => {
         const Component = ({projectId}) => <div>{projectId}</div>;
         const WrappedComponent = HashParserHOC(Component);
         window.location.hash = '';
         const mounted = mount(<WrappedComponent />);
         expect(mounted.state().projectId).toEqual(0);
-        window.location.hash = '#winning';
+        window.location.hash = '#1234567';
         mounted.instance().handleHashChange();
-        expect(mounted.state().projectId).toEqual('winning');
+        expect(mounted.state().projectId).toEqual('1234567');
     });
 });
-- 
GitLab