From 008f73fec275aba58fa9242a5a05881eedf85b13 Mon Sep 17 00:00:00 2001 From: Jeremy Kauffman Date: Wed, 4 Sep 2019 17:43:37 -0400 Subject: [PATCH] proof of concept of i18n component wherever I was this is such a heap of shit but it technically works? progress working? but bad on desktop use browser default for date time fix language setting loading fixes --- .eslintrc.json | 1 - flow-typed/i18n.js | 1 - src/ui/component/app/index.js | 7 +- src/ui/component/dateTime/view.jsx | 16 +- src/ui/component/i18nMessage/index.js | 11 + src/ui/component/i18nMessage/view.jsx | 35 + src/ui/component/publishFile/view.jsx | 5 +- .../component/publishName/bid-help-text.jsx | 13 +- src/ui/component/rewardSummary/view.jsx | 18 +- src/ui/component/settingLanguage/index.js | 21 + src/ui/component/settingLanguage/view.jsx | 68 ++ src/ui/component/walletSendTip/view.jsx | 10 +- src/ui/constants/action_types.js | 4 - src/ui/i18n.js | 64 ++ src/ui/i18n/__.js | 3 - src/ui/i18n/__n.js | 3 - src/ui/i18n/index.js | 22 - src/ui/index.jsx | 3 +- src/ui/page/settings/index.js | 18 +- src/ui/page/settings/view.jsx | 56 +- src/ui/redux/actions/settings.js | 75 +- src/ui/redux/reducers/settings.js | 32 +- src/ui/redux/selectors/settings.js | 5 + static/{locales/en.json => app-strings.json} | 14 +- static/index-electron.html | 25 +- static/index-web.html | 25 +- static/locales/de.json | 642 ----------------- static/locales/id.json | 642 ----------------- static/locales/pl.json | 653 ------------------ webpack.base.config.js | 6 +- 30 files changed, 316 insertions(+), 2182 deletions(-) delete mode 100644 flow-typed/i18n.js create mode 100644 src/ui/component/i18nMessage/index.js create mode 100644 src/ui/component/i18nMessage/view.jsx create mode 100644 src/ui/component/settingLanguage/index.js create mode 100644 src/ui/component/settingLanguage/view.jsx create mode 100644 src/ui/i18n.js delete mode 100644 src/ui/i18n/__.js delete mode 100644 src/ui/i18n/__n.js delete mode 100644 src/ui/i18n/index.js rename static/{locales/en.json => app-strings.json} (98%) delete mode 100644 static/locales/de.json delete mode 100644 static/locales/id.json delete mode 100644 static/locales/pl.json diff --git a/.eslintrc.json b/.eslintrc.json index 165343cec..023edb1c9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -17,7 +17,6 @@ "__static": true, "i18n": true, "__": true, - "__n": true, "app": true, "IS_WEB": true, "WEBPACK_PORT": true diff --git a/flow-typed/i18n.js b/flow-typed/i18n.js deleted file mode 100644 index d65657ff9..000000000 --- a/flow-typed/i18n.js +++ /dev/null @@ -1 +0,0 @@ -declare function __(a: string, b?: string | number): string; diff --git a/src/ui/component/app/index.js b/src/ui/component/app/index.js index 5d5460c33..6360fa5f2 100644 --- a/src/ui/component/app/index.js +++ b/src/ui/component/app/index.js @@ -1,22 +1,23 @@ import { hot } from 'react-hot-loader/root'; import { connect } from 'react-redux'; -import { doError, doFetchTransactions } from 'lbry-redux'; +import { doFetchTransactions } from 'lbry-redux'; import { selectUser, doRewardList, doFetchRewardedContent, doFetchAccessToken, selectAccessToken } from 'lbryinc'; -import { selectThemePath } from 'redux/selectors/settings'; +import { makeSelectClientSetting, selectThemePath } from 'redux/selectors/settings'; import { selectIsUpgradeAvailable, selectAutoUpdateDownloaded } from 'redux/selectors/app'; import { doDownloadUpgradeRequested } from 'redux/actions/app'; +import * as SETTINGS from 'constants/settings'; import App from './view'; const select = state => ({ user: selectUser(state), theme: selectThemePath(state), + language: makeSelectClientSetting(SETTINGS.LANGUAGE)(state), accessToken: selectAccessToken(state), autoUpdateDownloaded: selectAutoUpdateDownloaded(state), isUpgradeAvailable: selectIsUpgradeAvailable(state), }); const perform = dispatch => ({ - alertError: errorList => dispatch(doError(errorList)), fetchRewards: () => dispatch(doRewardList()), fetchRewardedContent: () => dispatch(doFetchRewardedContent()), fetchTransactions: () => dispatch(doFetchTransactions()), diff --git a/src/ui/component/dateTime/view.jsx b/src/ui/component/dateTime/view.jsx index e0bc0eaee..2fb407ced 100644 --- a/src/ui/component/dateTime/view.jsx +++ b/src/ui/component/dateTime/view.jsx @@ -1,7 +1,6 @@ // @flow import React from 'react'; import moment from 'moment'; -import i18n from 'i18n'; type Props = { date?: any, @@ -23,22 +22,9 @@ class DateTime extends React.PureComponent { }, }; - componentWillMount() { - // this.refreshDate(this.props); - } - - componentWillReceiveProps() { - // this.refreshDate(props); - } - render() { const { date, formatOptions, timeAgo } = this.props; const show = this.props.show || DateTime.SHOW_BOTH; - const locale = i18n.getLocale(); - const locales = ['en-US']; - if (locale) { - locales.push(locale); - } if (timeAgo) { return date ? {moment(date).from(moment())} : ; @@ -48,7 +34,7 @@ class DateTime extends React.PureComponent { {date && (show === DateTime.SHOW_BOTH || show === DateTime.SHOW_DATE) && - date.toLocaleDateString(locales, formatOptions)} + date.toLocaleDateString(undefined, formatOptions)} {show === DateTime.SHOW_BOTH && ' '} {date && (show === DateTime.SHOW_BOTH || show === DateTime.SHOW_TIME) && date.toLocaleTimeString()} {!date && '...'} diff --git a/src/ui/component/i18nMessage/index.js b/src/ui/component/i18nMessage/index.js new file mode 100644 index 000000000..251abdbbf --- /dev/null +++ b/src/ui/component/i18nMessage/index.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; +import i18n from './view'; + +const select = state => ({}); + +const perform = () => ({}); + +export default connect( + select, + perform +)(i18n); diff --git a/src/ui/component/i18nMessage/view.jsx b/src/ui/component/i18nMessage/view.jsx new file mode 100644 index 000000000..ed5cf708b --- /dev/null +++ b/src/ui/component/i18nMessage/view.jsx @@ -0,0 +1,35 @@ +// @flow +import React from 'react'; + +type Props = { + tokens: Object, + children: any, +}; + +export default function I18nMessage(props: Props) { + const message = __(props.children), + regexp = /%\w+%/g, + matchingGroups = message.match(regexp); + + if (!matchingGroups) { + return {message}; + } + + const messageSubstrings = props.children.split(regexp), + tokens = props.tokens; + + return ( + + {messageSubstrings.map((substring, index) => { + const token = + index < matchingGroups.length ? matchingGroups[index].substring(1, matchingGroups[index].length - 1) : null; // get token without % on each side + return ( + + {substring} + {token && tokens[token]} + + ); + })} + + ); +} diff --git a/src/ui/component/publishFile/view.jsx b/src/ui/component/publishFile/view.jsx index 40d16478f..ba3ce716c 100644 --- a/src/ui/component/publishFile/view.jsx +++ b/src/ui/component/publishFile/view.jsx @@ -46,10 +46,7 @@ function PublishFile(props: Props) { )} {!!isStillEditing && name && (

- {/* @i18nfixme */} - {__("If you don't choose a file, the file from your existing claim")} - {` "${name}" `} - {__('will be used.')} + {__("If you don't choose a file, the file from your existing claim %name% will be used", { name: name })}

)} diff --git a/src/ui/component/publishName/bid-help-text.jsx b/src/ui/component/publishName/bid-help-text.jsx index 97311a86c..73dc9080f 100644 --- a/src/ui/component/publishName/bid-help-text.jsx +++ b/src/ui/component/publishName/bid-help-text.jsx @@ -18,12 +18,13 @@ function BidHelpText(props: Props) { } else if (!amountNeededForTakeover) { bidHelpText = __('Any amount will give you the winning bid.'); } else { - // @i18nfixme - bidHelpText = `${__('If you bid more than')} ${amountNeededForTakeover} LBC, ${__( - 'when someone navigates to' - )} ${uri} ${__('it will load your published content')}. ${__( - 'However, you can get a longer version of this URL for any bid' - )}.`; + bidHelpText = __( + 'If you bid more than %amount% LBC, when someone navigates to %uri%, it will load your published content. However, you can get a longer version of this URL for any bid.', + { + amount: amountNeededForTakeover, + uri: uri, + } + ); } } else { bidHelpText = __('This LBC remains yours and the deposit can be undone at any time.'); diff --git a/src/ui/component/rewardSummary/view.jsx b/src/ui/component/rewardSummary/view.jsx index 36040df6d..2f81e3cda 100644 --- a/src/ui/component/rewardSummary/view.jsx +++ b/src/ui/component/rewardSummary/view.jsx @@ -2,6 +2,7 @@ import * as React from 'react'; import Button from 'component/button'; import CreditAmount from 'component/common/credit-amount'; +import I18nMessage from 'component/i18nMessage'; type Props = { unclaimedRewardAmount: number, @@ -19,16 +20,15 @@ class RewardSummary extends React.Component {

{fetching && __('You have...')} {!fetching && hasRewards ? ( - - {/* @i18nfixme */} - {__('You have')} -   - -   - {__('in unclaimed rewards')}. - + , + }} + > + You have %credit_amount% in unclaimed rewards. + ) : ( - __('You have no rewards available, please check') + {__('You have no rewards available, please check')} )}

diff --git a/src/ui/component/settingLanguage/index.js b/src/ui/component/settingLanguage/index.js new file mode 100644 index 000000000..e8852ff27 --- /dev/null +++ b/src/ui/component/settingLanguage/index.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import * as SETTINGS from 'constants/settings'; +import { doSetClientSetting } from 'redux/actions/settings'; +import { makeSelectClientSetting, selectLanguages } from 'redux/selectors/settings'; +import { doToast } from 'lbry-redux'; +import SettingLanguage from './view'; + +const select = state => ({ + language: makeSelectClientSetting(SETTINGS.LANGUAGE)(state), + languages: selectLanguages(state), +}); + +const perform = dispatch => ({ + setClientSetting: (key, value) => dispatch(doSetClientSetting(key, value)), + showToast: options => dispatch(doToast(options)), +}); + +export default connect( + select, + perform +)(SettingLanguage); diff --git a/src/ui/component/settingLanguage/view.jsx b/src/ui/component/settingLanguage/view.jsx new file mode 100644 index 000000000..444e0efa1 --- /dev/null +++ b/src/ui/component/settingLanguage/view.jsx @@ -0,0 +1,68 @@ +// @flow + +import React, { useState } from 'react'; +import { FormField } from 'component/common/form'; +import Spinner from 'component/spinner'; +import { SETTINGS } from 'lbry-redux'; + +type Props = { + language: string, + languages: {}, + showToast: ({}) => void, + setClientSetting: (string, boolean) => void, +}; + +function SettingLanguage(props: Props) { + const [isFetching, setIsFetching] = useState(false); + + const { language, languages, showToast, setClientSetting } = props; + + function onLanguageChange(e) { + const { value } = e.target; + setIsFetching(true); + + // this should match the behavior/logic in the static index-XXX.html files + fetch('https://lbry.com/i18n/get/lbry-desktop/app-strings/' + value + '.json') + .then(r => r.json()) + .then(j => { + window.i18n_messages[value] = j; + }) + .then(() => { + setIsFetching(false); + window.localStorage.setItem(SETTINGS.LANGUAGE, value); + setClientSetting(SETTINGS.LANGUAGE, value); + }) + .catch(e => { + console.log(e); + showToast({ + message: __('Failed to load translations.'), + error: true, + }); + setIsFetching(false); + }); + } + + return ( + + + {Object.keys(languages).map(language => ( + + ))} + + {isFetching && } + + ); +} + +export default SettingLanguage; diff --git a/src/ui/component/walletSendTip/view.jsx b/src/ui/component/walletSendTip/view.jsx index 1d91f3397..c202e0ff6 100644 --- a/src/ui/component/walletSendTip/view.jsx +++ b/src/ui/component/walletSendTip/view.jsx @@ -81,10 +81,11 @@ class WalletSendTip extends React.PureComponent { autoFocus name="tip-input" label={ - (tipAmount && - tipAmount !== 0 && - `${isSupport ? __('Support') : __('Tip')} ${tipAmount.toFixed(8).replace(/\.?0+$/, '')} LBC`) || - __('Amount') + tipAmount && tipAmount !== 0 + ? __(isSupport ? 'Support %amount% LBC' : 'Tip %amount% LBC', { + amount: tipAmount.toFixed(8).replace(/\.?0+$/, ''), + }) + : __('Amount') } className="form-field--price-amount" error={tipError} @@ -100,7 +101,6 @@ class WalletSendTip extends React.PureComponent { label={__('Send')} disabled={isPending || tipError || !tipAmount} onClick={this.handleSendButtonClicked} - type="submit" /> } helper={ diff --git a/src/ui/constants/action_types.js b/src/ui/constants/action_types.js index 4adf95aef..d3065d3f7 100644 --- a/src/ui/constants/action_types.js +++ b/src/ui/constants/action_types.js @@ -157,10 +157,6 @@ export const CLAIM_REWARD_FAILURE = 'CLAIM_REWARD_FAILURE'; export const CLAIM_REWARD_CLEAR_ERROR = 'CLAIM_REWARD_CLEAR_ERROR'; export const FETCH_REWARD_CONTENT_COMPLETED = 'FETCH_REWARD_CONTENT_COMPLETED'; -// Language -export const DOWNLOAD_LANGUAGE_SUCCEEDED = 'DOWNLOAD_LANGUAGE_SUCCEEDED'; -export const DOWNLOAD_LANGUAGE_FAILED = 'DOWNLOAD_LANGUAGE_FAILED'; - // ShapeShift export const GET_SUPPORTED_COINS_START = 'GET_SUPPORTED_COINS_START'; export const GET_SUPPORTED_COINS_SUCCESS = 'GET_SUPPORTED_COINS_SUCCESS'; diff --git a/src/ui/i18n.js b/src/ui/i18n.js new file mode 100644 index 000000000..de217cc12 --- /dev/null +++ b/src/ui/i18n.js @@ -0,0 +1,64 @@ +// @if TARGET='app' +let fs = require('fs'); +// @endif + +const isProduction = process.env.NODE_ENV === 'production'; +let knownMessages = null; + +window.i18n_messages = window.i18n_messages || {}; + +// @if TARGET='app' +function saveMessage(message) { + const messagesFilePath = __static + '/app-strings.json'; + + if (knownMessages === null) { + try { + knownMessages = JSON.parse(fs.readFileSync(messagesFilePath, 'utf-8')); + } catch (err) { + throw 'Error parsing i18n messages file: ' + messagesFilePath + ' err: ' + err; + } + } + + if (!knownMessages[message]) { + knownMessages[message] = message; + fs.writeFile(messagesFilePath, JSON.stringify(knownMessages, null, 2), 'utf-8', err => { + if (err) { + throw err; + } + }); + } +} +// @endif + +/* + I dislike the below code (and note that it ships all the way to the distributed app), + but this seems better than silently having this limitation and future devs not knowing. + */ +// @if TARGET='web' +function saveMessage(message) { + if (!isProduction && knownMessages === null) { + console.log('Note that i18n messages are not saved in web dev mode.'); + knownMessages = {}; + } +} +// @endif + +export function __(message, tokens) { + const language = window.localStorage.getItem('language') || 'en'; + + if (!isProduction) { + saveMessage(message); + } + + const translatedMessage = window.i18n_messages[language] + ? window.i18n_messages[language][message] || message + : message; + + if (!tokens) { + return translatedMessage; + } + + return translatedMessage.replace(/%([^%]+)%/g, function($1, $2) { + return tokens[$2] || $2; + }); +} diff --git a/src/ui/i18n/__.js b/src/ui/i18n/__.js deleted file mode 100644 index fbda9af90..000000000 --- a/src/ui/i18n/__.js +++ /dev/null @@ -1,3 +0,0 @@ -import i18n from './index'; - -export default i18n.__; diff --git a/src/ui/i18n/__n.js b/src/ui/i18n/__n.js deleted file mode 100644 index a005ee154..000000000 --- a/src/ui/i18n/__n.js +++ /dev/null @@ -1,3 +0,0 @@ -import i18n from './index.js'; - -export default i18n.__n; diff --git a/src/ui/i18n/index.js b/src/ui/i18n/index.js deleted file mode 100644 index bcfffda57..000000000 --- a/src/ui/i18n/index.js +++ /dev/null @@ -1,22 +0,0 @@ -// @if TARGET='app' -import y18n from 'y18n'; -import path from 'path'; - -const isProduction = process.env.NODE_ENV === 'production'; - -const i18n = y18n({ - directory: path.join(isProduction ? __dirname : __static, `locales`), - updateFiles: true, - locale: 'en', -}); -// @endif -// @if TARGET='web' -const i18n = { - setLocale: () => {}, - getLocale: () => null, - __: x => x, - __n: x => x, -}; -// @endif - -export default i18n; diff --git a/src/ui/index.jsx b/src/ui/index.jsx index c7c62822a..9a30e57a6 100644 --- a/src/ui/index.jsx +++ b/src/ui/index.jsx @@ -13,7 +13,7 @@ import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { doConditionalAuthNavigate, doDaemonReady, doAutoUpdate, doOpenModal, doHideModal } from 'redux/actions/app'; import { Lbry, doToast, isURIValid, setSearchApi } from 'lbry-redux'; -import { doInitLanguage, doUpdateIsNightAsync } from 'redux/actions/settings'; +import { doUpdateIsNightAsync } from 'redux/actions/settings'; import { doAuthenticate, Lbryio, @@ -228,7 +228,6 @@ function AppWrapper() { if (readyToLaunch) { app.store.dispatch(doUpdateIsNightAsync()); app.store.dispatch(doDaemonReady()); - app.store.dispatch(doInitLanguage()); app.store.dispatch(doBlackListedOutpointsSubscribe()); app.store.dispatch(doFilteredOutpointsSubscribe()); window.sessionStorage.setItem('loaded', 'y'); diff --git a/src/ui/page/settings/index.js b/src/ui/page/settings/index.js index fbf7f3b10..41eab4c95 100644 --- a/src/ui/page/settings/index.js +++ b/src/ui/page/settings/index.js @@ -1,20 +1,9 @@ import { connect } from 'react-redux'; import * as SETTINGS from 'constants/settings'; import { doClearCache, doNotifyEncryptWallet, doNotifyDecryptWallet } from 'redux/actions/app'; -import { - doSetDaemonSetting, - doSetClientSetting, - doGetThemes, - doChangeLanguage, - doSetDarkTime, -} from 'redux/actions/settings'; +import { doSetDaemonSetting, doSetClientSetting, doGetThemes, doSetDarkTime } from 'redux/actions/settings'; import { doSetPlayingUri } from 'redux/actions/content'; -import { - makeSelectClientSetting, - selectDaemonSettings, - selectLanguages, - selectosNotificationsEnabled, -} from 'redux/selectors/settings'; +import { makeSelectClientSetting, selectDaemonSettings, selectosNotificationsEnabled } from 'redux/selectors/settings'; import { doWalletStatus, selectWalletIsEncrypted, selectBlockedChannelsCount } from 'lbry-redux'; import SettingsPage from './view'; @@ -25,8 +14,6 @@ const select = state => ({ instantPurchaseMax: makeSelectClientSetting(SETTINGS.INSTANT_PURCHASE_MAX)(state), currentTheme: makeSelectClientSetting(SETTINGS.THEME)(state), themes: makeSelectClientSetting(SETTINGS.THEMES)(state), - currentLanguage: makeSelectClientSetting(SETTINGS.LANGUAGE)(state), - languages: selectLanguages(state), automaticDarkModeEnabled: makeSelectClientSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED)(state), autoplay: makeSelectClientSetting(SETTINGS.AUTOPLAY)(state), walletEncrypted: selectWalletIsEncrypted(state), @@ -44,7 +31,6 @@ const perform = dispatch => ({ clearCache: () => dispatch(doClearCache()), setClientSetting: (key, value) => dispatch(doSetClientSetting(key, value)), getThemes: () => dispatch(doGetThemes()), - changeLanguage: newLanguage => dispatch(doChangeLanguage(newLanguage)), encryptWallet: () => dispatch(doNotifyEncryptWallet()), decryptWallet: () => dispatch(doNotifyDecryptWallet()), updateWalletStatus: () => dispatch(doWalletStatus()), diff --git a/src/ui/page/settings/view.jsx b/src/ui/page/settings/view.jsx index 9e542eabd..ac2c97195 100644 --- a/src/ui/page/settings/view.jsx +++ b/src/ui/page/settings/view.jsx @@ -7,7 +7,9 @@ import * as PAGES from 'constants/pages'; import * as React from 'react'; import { FormField, FormFieldPrice, Form } from 'component/common/form'; import Button from 'component/button'; +import I18nMessage from 'component/i18nMessage'; import Page from 'component/page'; +import SettingLanguage from 'component/settingLanguage'; import FileSelector from 'component/common/file-selector'; type Price = { @@ -45,14 +47,11 @@ type Props = { showNsfw: boolean, instantPurchaseEnabled: boolean, instantPurchaseMax: Price, - currentLanguage: string, - languages: {}, currentTheme: string, themes: Array, automaticDarkModeEnabled: boolean, autoplay: boolean, autoDownload: boolean, - changeLanguage: string => void, encryptWallet: () => void, decryptWallet: () => void, updateWalletStatus: () => void, @@ -85,7 +84,6 @@ class SettingsPage extends React.PureComponent { (this: any).onInstantPurchaseMaxChange = this.onInstantPurchaseMaxChange.bind(this); (this: any).onThemeChange = this.onThemeChange.bind(this); (this: any).onAutomaticDarkModeChange = this.onAutomaticDarkModeChange.bind(this); - (this: any).onLanguageChange = this.onLanguageChange.bind(this); (this: any).clearCache = this.clearCache.bind(this); (this: any).onChangeTime = this.onChangeTime.bind(this); } @@ -118,11 +116,6 @@ class SettingsPage extends React.PureComponent { this.props.setClientSetting(SETTINGS.THEME, value); } - onLanguageChange(event: SyntheticInputEvent<*>) { - const { value } = event.target; - this.props.changeLanguage(value); - } - onAutomaticDarkModeChange(value: boolean) { this.props.setClientSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, value); } @@ -182,8 +175,6 @@ class SettingsPage extends React.PureComponent { instantPurchaseEnabled, instantPurchaseMax, currentTheme, - currentLanguage, - languages, themes, automaticDarkModeEnabled, autoplay, @@ -217,6 +208,12 @@ class SettingsPage extends React.PureComponent { ) : (
+
+

{__('Language')}

+
+ + +
{/* @if TARGET='app' */}

{__('Download Directory')}

@@ -521,13 +518,19 @@ class SettingsPage extends React.PureComponent { checked={supportOption} label={__('Enable claim support')} helper={ - - {__('This will add a Support button along side tipping. Similar to tips, supports help ')} -