diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js
index 23feb4cf30..ffa5e45249 100644
--- a/src/components/structures/FilePanel.js
+++ b/src/components/structures/FilePanel.js
@@ -19,7 +19,7 @@ import React from 'react';
import Matrix from 'matrix-js-sdk';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
-import { _t, _tJsx } from '../../languageHandler';
+import { _t } from '../../languageHandler';
/*
* Component which shows the filtered file using a TimelinePanel
@@ -92,7 +92,10 @@ const FilePanel = React.createClass({
if (MatrixClientPeg.get().isGuest()) {
return
;
} else if (this.noRoom) {
diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js
index 1b5ebb6b36..cba030c1cc 100644
--- a/src/components/structures/GroupView.js
+++ b/src/components/structures/GroupView.js
@@ -22,7 +22,7 @@ import MatrixClientPeg from '../../MatrixClientPeg';
import sdk from '../../index';
import dis from '../../dispatcher';
import { sanitizedHtmlNode } from '../../HtmlUtils';
-import { _t, _td, _tJsx } from '../../languageHandler';
+import { _t, _td } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton';
import Modal from '../../Modal';
import classnames from 'classnames';
@@ -932,12 +932,12 @@ export default React.createClass({
className="mx_GroupView_groupDesc_placeholder"
onClick={this._onEditClick}
>
- { _tJsx(
+ { _t(
'Your community hasn\'t got a Long Description, a HTML page to show to community members.
' +
'Click here to open settings and give it one!',
- [/
/],
- [(sub) =>
])
- }
+ {},
+ { 'br': () =>
},
+ ) }
;
}
const groupDescEditingClasses = classnames({
diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js
index cc4783fdac..c669d7dd73 100644
--- a/src/components/structures/MyGroups.js
+++ b/src/components/structures/MyGroups.js
@@ -19,7 +19,7 @@ import PropTypes from 'prop-types';
import GeminiScrollbar from 'react-gemini-scrollbar';
import {MatrixClient} from 'matrix-js-sdk';
import sdk from '../../index';
-import { _t, _tJsx } from '../../languageHandler';
+import { _t } from '../../languageHandler';
import withMatrixClient from '../../wrappers/withMatrixClient';
import AccessibleButton from '../views/elements/AccessibleButton';
import dis from '../../dispatcher';
@@ -165,13 +165,13 @@ export default withMatrixClient(React.createClass({
{ _t('Join an existing community') }
- { _tJsx(
+ { _t(
'To join an existing community you\'ll have to '+
'know its community identifier; this will look '+
'something like +example:matrix.org.',
- /(.*)<\/i>/,
- (sub) => { sub },
- ) }
+ {},
+ { 'i': (sub) => { sub } })
+ }
diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js
index cad55351d1..03859f522e 100644
--- a/src/components/structures/RoomStatusBar.js
+++ b/src/components/structures/RoomStatusBar.js
@@ -15,13 +15,12 @@ limitations under the License.
*/
import React from 'react';
-import { _t, _tJsx } from '../../languageHandler';
+import { _t } from '../../languageHandler';
import sdk from '../../index';
import WhoIsTyping from '../../WhoIsTyping';
import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar';
-const HIDE_DEBOUNCE_MS = 10000;
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
@@ -272,12 +271,16 @@ module.exports = React.createClass({
{ this.props.unsentMessageError }
- { _tJsx("
Resend all or
cancel all now. You can also select individual messages to resend or cancel.",
- [/
(.*?)<\/a>/, /(.*?)<\/a>/],
- [
- (sub) => { sub },
- (sub) =>
{ sub },
- ],
+ {
+ _t("
Resend all or
cancel all now. " +
+ "You can also select individual messages to resend or cancel.",
+ {},
+ {
+ 'resendText': (sub) =>
+
{ sub },
+ 'cancelText': (sub) =>
+
{ sub },
+ },
) }
@@ -322,12 +325,15 @@ module.exports = React.createClass({
if (this.props.sentMessageAndIsAlone) {
return (
- { _tJsx("There's no one else here! Would you like to
invite others or
stop warning about the empty room?",
- [/
(.*?)<\/a>/, /(.*?)<\/a>/],
- [
- (sub) => { sub },
- (sub) =>
{ sub },
- ],
+ { _t("There's no one else here! Would you like to
invite others " +
+ "or
stop warning about the empty room?",
+ {},
+ {
+ 'inviteText': (sub) =>
+
{ sub },
+ 'nowarnText': (sub) =>
+
{ sub },
+ },
) }
);
diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js
index 8ee6eafad4..3b68234abd 100644
--- a/src/components/structures/login/Login.js
+++ b/src/components/structures/login/Login.js
@@ -18,7 +18,7 @@ limitations under the License.
'use strict';
import React from 'react';
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
import * as languageHandler from '../../../languageHandler';
import sdk from '../../../index';
import Login from '../../../Login';
@@ -256,17 +256,19 @@ module.exports = React.createClass({
!this.state.enteredHomeserverUrl.startsWith("http"))
) {
errorText =
- { _tJsx("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
+ {
+ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or enable unsafe scripts.",
- /(.*?)<\/a>/,
- (sub) => { return { sub }; },
+ {},
+ { 'a': (sub) => { return { sub }; } },
) }
;
} else {
errorText =
- { _tJsx("Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.",
- /(.*?)<\/a>/,
- (sub) => { return { sub }; },
+ {
+ _t("Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.",
+ {},
+ { 'a': (sub) => { return { sub }; } },
) }
;
}
@@ -277,7 +279,7 @@ module.exports = React.createClass({
componentForStep: function(step) {
switch (step) {
- case 'm.login.password':
+ case 'm.login.password': {
const PasswordLogin = sdk.getComponent('login.PasswordLogin');
return (
);
- case 'm.login.cas':
+ }
+ case 'm.login.cas': {
const CasLogin = sdk.getComponent('login.CasLogin');
return (
);
- default:
+ }
+ default: {
if (!step) {
return;
}
@@ -307,6 +311,7 @@ module.exports = React.createClass({
{ _t('Sorry, this homeserver is using a login which is not recognised ') }({ step })
);
+ }
}
},
diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js
index f404bdd975..75ae0eda17 100644
--- a/src/components/views/dialogs/SessionRestoreErrorDialog.js
+++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js
@@ -18,7 +18,7 @@ import React from 'react';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
export default React.createClass({
@@ -45,9 +45,10 @@ export default React.createClass({
if (SdkConfig.get().bug_report_endpoint_url) {
bugreport = (
- { _tJsx(
+ { _t(
"Otherwise, click here to send a bug report.",
- /(.*?)<\/a>/, (sub) => { sub },
+ {},
+ { 'a': (sub) => { sub } },
) }
);
diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js
index 057609b344..53fdee20ff 100644
--- a/src/components/views/dialogs/SetMxIdDialog.js
+++ b/src/components/views/dialogs/SetMxIdDialog.js
@@ -21,7 +21,7 @@ import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import classnames from 'classnames';
import KeyCode from '../../../KeyCode';
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
// The amount of time to wait for further changes to the input username before
// sending a request to the server
@@ -267,24 +267,21 @@ export default React.createClass({
{ usernameIndicator }
- { _tJsx(
+ { _t(
'This will be your account name on the ' +
'homeserver, or you can pick a different server.',
- [
- /<\/span>/,
- /(.*?)<\/a>/,
- ],
- [
- (sub) => { this.props.homeserverUrl },
- (sub) => { sub },
- ],
+ {},
+ {
+ 'span': () => { this.props.homeserverUrl },
+ 'a': (sub) => { sub },
+ },
) }
- { _tJsx(
+ { _t(
'If you already have a Matrix account you can log in instead.',
- /(.*?)<\/a>/,
- [(sub) => { sub }],
+ {},
+ { 'a': (sub) => { sub } },
) }
{ auth }
diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js
index cf814b0a6e..21e5094b28 100644
--- a/src/components/views/login/CaptchaForm.js
+++ b/src/components/views/login/CaptchaForm.js
@@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
const DIV_ID = 'mx_recaptcha';
@@ -67,10 +67,10 @@ module.exports = React.createClass({
// * jumping straight to a hosted captcha page (but we don't support that yet)
// * embedding the captcha in an iframe (if that works)
// * using a better captcha lib
- ReactDOM.render(_tJsx(
+ ReactDOM.render(_t(
"Robot check is currently unavailable on desktop - please use a web browser",
- /(.*?)<\/a>/,
- (sub) => { return { sub }; }), warning);
+ {},
+ { 'a': (sub) => { return { sub }; }}), warning);
this.refs.recaptchaContainer.appendChild(warning);
} else {
const scriptTag = document.createElement('script');
diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js
index 5f5a74ccd1..de8746230c 100644
--- a/src/components/views/login/InteractiveAuthEntryComponents.js
+++ b/src/components/views/login/InteractiveAuthEntryComponents.js
@@ -20,7 +20,7 @@ import url from 'url';
import classnames from 'classnames';
import sdk from '../../../index';
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@@ -256,7 +256,10 @@ export const EmailIdentityAuthEntry = React.createClass({
} else {
return (
-
{ _tJsx("An email has been sent to %(emailAddress)s", /%\(emailAddress\)s/, (sub) => {this.props.inputs.emailAddress}) }
+
{ _t("An email has been sent to %(emailAddress)s",
+ { emailAddress: (sub) => { this.props.inputs.emailAddress } },
+ ) }
+
{ _t("Please check your email to continue registration.") }
);
@@ -370,7 +373,10 @@ export const MsisdnAuthEntry = React.createClass({
});
return (
-
{ _tJsx("A text message has been sent to %(msisdn)s", /%\(msisdn\)s/, (sub) => {this._msisdn}) }
+
{ _t("A text message has been sent to %(msisdn)s",
+ { msisdn: () => this._msisdn },
+ ) }
+
{ _t("Please enter the code it contains:") }
- { _tJsx(
+ { _t(
'Would you like to
accept or
decline this invitation?',
- [/
(.*?)<\/acceptText>/, /(.*?)<\/declineText>/],
- [
- (sub) => { sub },
- (sub) => { sub },
- ],
+ {},
+ {
+ 'acceptText': (sub) => { sub },
+ 'declineText': (sub) => { sub },
+ },
) }
{ emailMatchBlock }
@@ -211,9 +211,9 @@ module.exports = React.createClass({
diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js
index c7e839ab40..be5fb0fe2f 100644
--- a/src/components/views/rooms/RoomSettings.js
+++ b/src/components/views/rooms/RoomSettings.js
@@ -17,7 +17,7 @@ limitations under the License.
import Promise from 'bluebird';
import React from 'react';
-import { _t, _tJsx, _td } from '../../../languageHandler';
+import { _t, _td } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index';
import Modal from '../../../Modal';
@@ -637,9 +637,7 @@ module.exports = React.createClass({
const ColorSettings = sdk.getComponent("room_settings.ColorSettings");
const UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings");
const RelatedGroupSettings = sdk.getComponent("room_settings.RelatedGroupSettings");
- const EditableText = sdk.getComponent('elements.EditableText');
const PowerSelector = sdk.getComponent('elements.PowerSelector');
- const Loader = sdk.getComponent("elements.Spinner");
const cli = MatrixClientPeg.get();
const roomState = this.props.room.currentState;
@@ -760,7 +758,7 @@ module.exports = React.createClass({
var tagsSection = null;
if (canSetTag || self.state.tags) {
- var tagsSection =
+ tagsSection =
{ _t("Tagged as: ") }{ canSetTag ?
(tags.map(function(tag, i) {
@@ -790,10 +788,10 @@ module.exports = React.createClass({
if (this.state.join_rule === "public" && aliasCount == 0) {
addressWarning =
;
}
@@ -940,7 +938,7 @@ module.exports = React.createClass({
{ Object.keys(events_levels).map(function(event_type, i) {
let label = plEventsToLabels[event_type];
if (label) label = _t(label);
- else label = _tJsx("To send events of type
, you must be a", /
/, () =>
{ event_type }
);
+ else label = _t("To send events of type
, you must be a", {}, { 'eventType': () =>
{ event_type }
});
return (
{ label }
diff --git a/src/languageHandler.js b/src/languageHandler.js
index da62bfee56..33ae229185 100644
--- a/src/languageHandler.js
+++ b/src/languageHandler.js
@@ -35,12 +35,9 @@ export function _td(s) {
return s;
}
-// The translation function. This is just a simple wrapper to counterpart,
-// but exists mostly because we must use the same counterpart instance
-// between modules (ie. here (react-sdk) and the app (riot-web), and if we
-// just import counterpart and use it directly, we end up using a different
-// instance.
-export function _t(...args) {
+// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
+//Takes the same arguments as counterpart.translate()
+function safe_counterpart_translate(...args) {
// Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else
@@ -51,11 +48,11 @@ export function _t(...args) {
if (args[1] && typeof args[1] === 'object') {
Object.keys(args[1]).forEach((k) => {
if (args[1][k] === undefined) {
- console.warn("_t called with undefined interpolation name: " + k);
+ console.warn("safe_counterpart_translate called with undefined interpolation name: " + k);
args[1][k] = 'undefined';
}
if (args[1][k] === null) {
- console.warn("_t called with null interpolation name: " + k);
+ console.warn("safe_counterpart_translate called with null interpolation name: " + k);
args[1][k] = 'null';
}
});
@@ -64,75 +61,112 @@ export function _t(...args) {
}
/*
- * Translates stringified JSX into translated JSX. E.g
- * _tJsx(
- * "click
here now",
- * /
(.*?)<\/a>/,
- * (sub) => { return { sub }; }
- * );
+ * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
+ * @param {string} text The untranslated text, e.g "click
here now to %(foo)s".
+ * @param {object} variables Variable substitutions, e.g { foo: 'bar' }
+ * @param {object} tags Tag substitutions e.g. { 'a': (sub) =>
{sub} }
*
- * @param {string} jsxText The untranslated stringified JSX e.g "click
here now".
- * This will be translated by passing the string through to _t(...)
+ * The values to substitute with can be either simple strings, or functions that return the value to use in
+ * the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
+ * the argument the text inside the element corresponding to the tag.
*
- * @param {RegExp|RegExp[]} patterns A regexp to match against the translated text.
- * The captured groups from the regexp will be fed to 'sub'.
- * Only the captured groups will be included in the output, the match itself is discarded.
- * If multiple RegExps are provided, the function at the same position will be called. The
- * match will always be done from left to right, so the 2nd RegExp will be matched against the
- * remaining text from the first RegExp.
- *
- * @param {Function|Function[]} subs A function which will be called
- * with multiple args, each arg representing a captured group of the matching regexp.
- * This function must return a JSX node.
- *
- * @return a React
component containing the generated text
+ * @return a React component if any non-strings were used in substitutions, otherwise a string
*/
-export function _tJsx(jsxText, patterns, subs) {
- // convert everything to arrays
- if (patterns instanceof RegExp) {
- patterns = [patterns];
- }
- if (subs instanceof Function) {
- subs = [subs];
- }
- // sanity checks
- if (subs.length !== patterns.length || subs.length < 1) {
- throw new Error(`_tJsx: programmer error. expected number of RegExps == number of Functions: ${subs.length} != ${patterns.length}`);
- }
- for (let i = 0; i < subs.length; i++) {
- if (!(patterns[i] instanceof RegExp)) {
- throw new Error(`_tJsx: programmer error. expected RegExp for text: ${jsxText}`);
- }
- if (!(subs[i] instanceof Function)) {
- throw new Error(`_tJsx: programmer error. expected Function for text: ${jsxText}`);
- }
- }
+export function _t(text, variables, tags) {
+ // Don't do subsitutions in counterpart. We hanle it ourselves so we can replace with React components
+ const args = Object.assign({ interpolate: false }, variables);
// The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
- const tJsxText = _t(jsxText, {interpolate: false});
- const output = [tJsxText];
+ const translated = safe_counterpart_translate(text, args);
+
+ return substitute(translated, variables, tags);
+}
+
+/*
+ * Similar to _t(), except only does substitutions, and no translation
+ * @param {string} text The text, e.g "click here now to %(foo)s".
+ * @param {object} variables Variable substitutions, e.g { foo: 'bar' }
+ * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} }
+ *
+ * The values to substitute with can be either simple strings, or functions that return the value to use in
+ * the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
+ * the argument the text inside the element corresponding to the tag.
+ *
+ * @return a React component if any non-strings were used in substitutions, otherwise a string
+ */
+export function substitute(text, variables, tags) {
+ const regexpMapping = {};
+
+ if(variables !== undefined) {
+ for (const variable in variables) {
+ regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
+ }
+ }
+
+ if(tags !== undefined) {
+ for (const tag in tags) {
+ regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
+ }
+ }
+ return replaceByRegexes(text, regexpMapping);
+}
+
+/*
+ * Replace parts of a text using regular expressions
+ * @param {string} text The text on which to perform substitutions
+ * @param {object} mapping A mapping from regular expressions in string form to replacement string or a
+ * function which will receive as the argument the capture groups defined in the regexp. E.g.
+ * { 'Hello (.?) World': (sub) => sub.toUpperCase() }
+ *
+ * @return a React component if any non-strings were used in substitutions, otherwise a string
+ */
+export function replaceByRegexes(text, mapping) {
+ const output = [text];
+
+ let wrap = false; // Remember if the output needs to be wrapped later
+ for (const regexpString in mapping) {
+ const regexp = new RegExp(regexpString);
- for (let i = 0; i < patterns.length; i++) {
// convert the last element in 'output' into 3 elements (pre-text, sub function, post-text).
// Rinse and repeat for other patterns (using post-text).
const inputText = output.pop();
- const match = inputText.match(patterns[i]);
- if (!match) {
- throw new Error(`_tJsx: translator error. expected translation to match regexp: ${patterns[i]}`);
+ const match = inputText.match(regexp);
+ if(!match) {
+ output.push(inputText); // Push back input
+ continue; // Missing matches is entirely possible, because translation might change things
}
- const capturedGroups = match.slice(1);
+ const capturedGroups = match.slice(2);
// Return the raw translation before the *match* followed by the return value of sub() followed
// by the raw translation after the *match* (not captured group).
output.push(inputText.substr(0, match.index));
- output.push(subs[i].apply(null, capturedGroups));
+
+ let toPush;
+ // If substitution is a function, call it
+ if(mapping[regexpString] instanceof Function) {
+ toPush = mapping[regexpString].apply(null, capturedGroups);
+ } else {
+ toPush = mapping[regexpString];
+ }
+
+ output.push(toPush);
+
+ // Check if we need to wrap the output into a span at the end
+ if(typeof toPush === 'object') {
+ wrap = true;
+ }
+
output.push(inputText.substr(match.index + match[0].length));
}
- // this is a bit of a fudge to avoid the 'Each child in an array or iterator
- // should have a unique "key" prop' error: we explicitly pass the generated
- // nodes into React.createElement as children of a .
- return React.createElement('span', null, ...output);
+ if(wrap) {
+ // this is a bit of a fudge to avoid the 'Each child in an array or iterator
+ // should have a unique "key" prop' error: we explicitly pass the generated
+ // nodes into React.createElement as children of a .
+ return React.createElement('span', null, ...output);
+ } else {
+ return output.join('');
+ }
}
// Allow overriding the text displayed when no translation exists