);
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 0cc4c6410c..123bd54797 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -299,7 +299,7 @@ module.exports = React.createClass({
// Check if user has previously chosen to hide the app drawer for this
// room. If so, do not show apps
- let hideWidgetDrawer = localStorage.getItem(
+ const hideWidgetDrawer = localStorage.getItem(
room.roomId + "_hide_widget_drawer");
if (hideWidgetDrawer === "true") {
@@ -704,7 +704,7 @@ module.exports = React.createClass({
return;
}
- const joinedMembers = room.currentState.getMembers().filter(m => m.membership === "join" || m.membership === "invite");
+ const joinedMembers = room.currentState.getMembers().filter((m) => m.membership === "join" || m.membership === "invite");
this.setState({isAlone: joinedMembers.length === 1});
},
@@ -1060,7 +1060,7 @@ module.exports = React.createClass({
}
if (this.state.searchScope === 'All') {
- if(roomId != lastRoomId) {
+ if (roomId != lastRoomId) {
const room = cli.getRoom(roomId);
// XXX: if we've left the room, we might not know about
@@ -1371,13 +1371,13 @@ module.exports = React.createClass({
*/
handleScrollKey: function(ev) {
let panel;
- if(this.refs.searchResultsPanel) {
+ if (this.refs.searchResultsPanel) {
panel = this.refs.searchResultsPanel;
- } else if(this.refs.messagePanel) {
+ } else if (this.refs.messagePanel) {
panel = this.refs.messagePanel;
}
- if(panel) {
+ if (panel) {
panel.handleScrollKey(ev);
}
},
@@ -1396,7 +1396,7 @@ module.exports = React.createClass({
// otherwise react calls it with null on each update.
_gatherTimelinePanelRef: function(r) {
this.refs.messagePanel = r;
- if(r) {
+ if (r) {
console.log("updateTint from RoomView._gatherTimelinePanelRef");
this.updateTint();
}
diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js
index cda60c606f..dfc6b0f7a1 100644
--- a/src/components/structures/ScrollPanel.js
+++ b/src/components/structures/ScrollPanel.js
@@ -573,7 +573,7 @@ module.exports = React.createClass({
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")");
- if(scrollDelta != 0) {
+ if (scrollDelta != 0) {
this._setScrollTop(scrollNode.scrollTop + scrollDelta);
}
},
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js
index 56661b0d26..aeb6cce6c3 100644
--- a/src/components/structures/TimelinePanel.js
+++ b/src/components/structures/TimelinePanel.js
@@ -310,7 +310,7 @@ var TimelinePanel = React.createClass({
return Promise.resolve(false);
}
- if(!this._timelineWindow.canPaginate(dir)) {
+ if (!this._timelineWindow.canPaginate(dir)) {
debuglog("TimelinePanel: can't", dir, "paginate any further");
this.setState({[canPaginateKey]: false});
return Promise.resolve(false);
@@ -440,7 +440,7 @@ var TimelinePanel = React.createClass({
var callback = null;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
updatedState.readMarkerVisible = true;
- } else if(lastEv && this.getReadMarkerPosition() === 0) {
+ } else if (lastEv && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
@@ -657,7 +657,7 @@ var TimelinePanel = React.createClass({
// the read-marker should become invisible, so that if the user scrolls
// down, they don't see it.
- if(this.state.readMarkerVisible) {
+ if (this.state.readMarkerVisible) {
this.setState({
readMarkerVisible: false,
});
diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js
index 692dd4e01d..933f90523a 100644
--- a/src/components/structures/UserSettings.js
+++ b/src/components/structures/UserSettings.js
@@ -612,9 +612,8 @@ module.exports = React.createClass({
},
onLanguageChange: function(newLang) {
- if(this.state.language !== newLang) {
- // We intentionally promote this to the account level at this point
- SettingsStore.setValue("language", null, SettingLevel.ACCOUNT, newLang);
+ if (this.state.language !== newLang) {
+ SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
this.setState({
language: newLang,
});
diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js
index 8a2714d96a..43753bfd38 100644
--- a/src/components/structures/login/ForgotPassword.js
+++ b/src/components/structures/login/ForgotPassword.js
@@ -154,7 +154,7 @@ module.exports = React.createClass({
},
render: function() {
- const LoginPage = sdk.getComponent("login.LoginPage");
+ const LoginPage = sdk.getComponent("login.LoginPage");
const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig");
diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js
index bd0afcb335..baa2064277 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';
@@ -96,7 +96,7 @@ module.exports = React.createClass({
).then((data) => {
this.props.onLoggedIn(data);
}, (error) => {
- if(this._unmounted) {
+ if (this._unmounted) {
return;
}
let errorText;
@@ -113,14 +113,14 @@ module.exports = React.createClass({
{ _t('Please note you are logging into the %(hs)s server, not matrix.org.',
{
- hs: this.props.defaultHsUrl.replace(/^https?:\/\//, '')
+ hs: this.props.defaultHsUrl.replace(/^https?:\/\//, ''),
})
}
);
} else {
- errorText = _t('Incorrect username and/or password.');
+ errorText = _t('Incorrect username and/or password.');
}
} else {
// other errors, not specific to doing a password login
@@ -136,7 +136,7 @@ module.exports = React.createClass({
loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403,
});
}).finally(() => {
- if(this._unmounted) {
+ if (this._unmounted) {
return;
}
this.setState({
@@ -272,17 +272,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 }; } },
) }
;
}
@@ -293,7 +295,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;
}
@@ -323,11 +327,12 @@ module.exports = React.createClass({
{ _t('Sorry, this homeserver is using a login which is not recognised ') }({ step })
);
+ }
}
},
_onLanguageChange: function(newLang) {
- if(languageHandler.getCurrentLanguage() !== newLang) {
+ if (languageHandler.getCurrentLanguage() !== newLang) {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload();
}
@@ -388,8 +393,7 @@ module.exports = React.createClass({
const theme = SettingsStore.getValue("theme");
if (theme !== "status") {
header =
);
diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js
index 057609b344..6fc1d77682 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("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:") }
);
diff --git a/src/components/views/messages/SenderProfile.js b/src/components/views/messages/SenderProfile.js
index afdb97272f..10e3082938 100644
--- a/src/components/views/messages/SenderProfile.js
+++ b/src/components/views/messages/SenderProfile.js
@@ -19,7 +19,7 @@
import React from 'react';
import sdk from '../../../index';
import Flair from '../elements/Flair.js';
-import { _tJsx } from '../../../languageHandler';
+import { _t, substitute } from '../../../languageHandler';
export default function SenderProfile(props) {
const EmojiText = sdk.getComponent('elements.EmojiText');
@@ -42,22 +42,28 @@ export default function SenderProfile(props) {
: null,
];
- let content = '';
-
- if(props.text) {
- // Replace senderName, and wrap surrounding text in spans with the right class
- content = _tJsx(props.text, /^(.*)\%\(senderName\)s(.*)$/m, (p1, p2) => [
- p1 ? { p1 } : null,
- nameElem,
- p2 ? { p2 } : null,
- ]);
+ let content;
+ if (props.text) {
+ content = _t(props.text, { senderName: () => nameElem });
} else {
- content = nameElem;
+ // There is nothing to translate here, so call substitute() instead
+ content = substitute('%(senderName)s', { senderName: () => nameElem });
}
+ // The text surrounding the user name must be wrapped in order for it to have the correct opacity.
+ // It is not possible to wrap the whole thing, because the user name might contain flair which should
+ // be shown at full opacity. Sadly CSS does not make it possible to "reset" opacity so we have to do it
+ // in parts like this. Sometimes CSS makes me a sad panda :-(
+ // XXX: This could be avoided if the actual colour is set, rather than faking it with opacity
return (
- { _tJsx(
+ { _t(
"You're not in any rooms yet! Press to make a room or"+
" to browse the directory",
- [//, //],
- [
- (sub) => ,
- (sub) => ,
- ],
+ {},
+ {
+ 'CreateRoomButton': ,
+ 'RoomDirectoryButton': ,
+ },
) }
;
}
diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js
index 0c0601a504..175a3ea552 100644
--- a/src/components/views/rooms/RoomPreviewBar.js
+++ b/src/components/views/rooms/RoomPreviewBar.js
@@ -21,7 +21,7 @@ const React = require('react');
const sdk = require('../../../index');
const MatrixClientPeg = require('../../../MatrixClientPeg');
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'RoomPreviewBar',
@@ -135,13 +135,13 @@ module.exports = React.createClass({
{ _t('You have been invited to join this room by %(inviterName)s', {inviterName: this.props.inviterName}) }
- { _tJsx(
+ { _t(
'Would you like to accept or decline this invitation?',
- [/(.*?)<\/acceptText>/, /(.*?)<\/declineText>/],
- [
- (sub) => { sub },
- (sub) => { sub },
- ],
+ {},
+ {
+ 'acceptText': (sub) => { sub },
+ 'declineText': (sub) => { sub },
+ },
) }
{ emailMatchBlock }
@@ -165,13 +165,13 @@ module.exports = React.createClass({
let actionText;
if (kicked) {
- if(roomName) {
+ if (roomName) {
actionText = _t("You have been kicked from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName});
} else {
actionText = _t("You have been kicked from this room by %(userName)s.", {userName: kickerName});
}
} else if (banned) {
- if(roomName) {
+ if (roomName) {
actionText = _t("You have been banned from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName});
} else {
actionText = _t("You have been banned from this room by %(userName)s.", {userName: kickerName});
@@ -211,9 +211,9 @@ module.exports = React.createClass({
{ name ? _t('You are trying to access %(roomName)s.', {roomName: name}) : _t('You are trying to access a room.') }
- { _tJsx("Click here to join the discussion!",
- /(.*?)<\/a>/,
- (sub) => { sub },
+ { _t("Click here to join the discussion!",
+ {},
+ { 'a': (sub) => { sub } },
) }
diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js
index 8ba18ee96e..4ac2da2030 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';
@@ -309,9 +309,9 @@ module.exports = React.createClass({
}
// url preview settings
- let ps = this.saveUrlPreviewSettings();
+ const ps = this.saveUrlPreviewSettings();
if (ps.length > 0) {
- ps.map(p => promises.push(p));
+ ps.map((p) => promises.push(p));
}
// related groups
@@ -584,7 +584,7 @@ module.exports = React.createClass({
const roomState = this.props.room.currentState;
const isEncrypted = cli.isRoomEncrypted(this.props.room.roomId);
- let settings = (
+ const settings = (
{ _t("Tagged as: ") }{ canSetTag ?
(tags.map(function(tag, i) {
@@ -781,10 +779,10 @@ module.exports = React.createClass({
if (this.state.join_rule === "public" && aliasCount == 0) {
addressWarning =
;
}
@@ -931,7 +929,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/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js
index 8034dd0fa6..743ec93da9 100644
--- a/src/components/views/rooms/RoomTile.js
+++ b/src/components/views/rooms/RoomTile.js
@@ -54,7 +54,7 @@ module.exports = React.createClass({
},
getInitialState: function() {
- return({
+ return ({
hover: false,
badgeHover: false,
menuDisplayed: false,
diff --git a/src/components/views/voip/VideoFeed.js b/src/components/views/voip/VideoFeed.js
index 953dbc866f..f955df62d9 100644
--- a/src/components/views/voip/VideoFeed.js
+++ b/src/components/views/voip/VideoFeed.js
@@ -39,7 +39,7 @@ module.exports = React.createClass({
},
onResize: function(e) {
- if(this.props.onResize) {
+ if (this.props.onResize) {
this.props.onResize(e);
}
},
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 05143e96b1..72fd4503b5 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -460,6 +460,8 @@
"New community ID (e.g. +foo:%(localDomain)s)": "New community ID (e.g. +foo:%(localDomain)s)",
"You have enabled URL previews by default.": "You have enabled URL previews by default.",
"You have disabled URL previews by default.": "You have disabled URL previews by default.",
+ "URL previews are enabled by default for participants in this room.": "URL previews are enabled by default for participants in this room.",
+ "URL previews are disabled by default for participants in this room.": "URL previews are disabled by default for participants in this room.",
"URL Previews": "URL Previews",
"Error decrypting audio": "Error decrypting audio",
"Error decrypting attachment": "Error decrypting attachment",
@@ -752,16 +754,16 @@
"You have no visible notifications": "You have no visible notifications",
"Scroll to bottom of page": "Scroll to bottom of page",
"Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present",
- "Show devices or cancel all.": "Show devices or cancel all.",
+ "Show devices or cancel all.": "Show devices or cancel all.",
"Some of your messages have not been sent.": "Some of your messages have not been sent.",
- "Resend all or cancel all now. You can also select individual messages to resend or cancel.": "Resend all or cancel all now. You can also select individual messages to resend or cancel.",
+ "Resend all or cancel all now. You can also select individual messages to resend or cancel.": "Resend all or cancel all now. You can also select individual messages to resend or cancel.",
"Warning": "Warning",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"%(count)s new messages|other": "%(count)s new messages",
"%(count)s new messages|one": "%(count)s new message",
"Active call": "Active call",
- "There's no one else here! Would you like to invite others or stop warning about the empty room?": "There's no one else here! Would you like to invite others or stop warning about the empty room?",
+ "There's no one else here! Would you like to invite others or stop warning about the empty room?": "There's no one else here! Would you like to invite others or stop warning about the empty room?",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
"Failed to upload file": "Failed to upload file",
diff --git a/src/languageHandler.js b/src/languageHandler.js
index 34b28e848f..b9b5371022 100644
--- a/src/languageHandler.js
+++ b/src/languageHandler.js
@@ -34,12 +34,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 safeCounterpartTranslate(...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
@@ -50,11 +47,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("safeCounterpartTranslate 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("safeCounterpartTranslate called with null interpolation name: " + k);
args[1][k] = 'null';
}
});
@@ -63,75 +60,136 @@ 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(...)
+ * In both variables and tags, the values to substitute with can be either simple strings, React components,
+ * 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.
+ * Use tag substitutions if you need to translate text between tags (e.g. "Click here!"), otherwise
+ * you will end up with literal "" in your output, rather than HTML. Note that you can also use variable
+ * substitution to insert React components, but you can't use it to translate text between tags.
*
- * @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 handle it ourselves so we can replace with React components
+ // However, still pass the variables to counterpart so that it can choose the correct plural if count is given
+ // It is enough to pass the count variable, but in the future counterpart might make use of other information too
+ 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 = safeCounterpartTranslate(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];
+
+ // If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
+ let shouldWrapInSpan = false;
+
+ for (const regexpString in mapping) {
+ // TODO: Cache regexps
+ 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]);
+ const match = inputText.match(regexp);
if (!match) {
- throw new Error(`_tJsx: translator error. expected translation to match regexp: ${patterns[i]}`);
+ output.push(inputText); // Push back input
+
+ // Missing matches is entirely possible because you might choose to show some variables only in the case
+ // of e.g. plurals. It's still a bit suspicious, and could be due to an error, so log it.
+ // However, not showing count is so common that it's not worth logging. And other commonly unused variables
+ // here, if there are any.
+ if (regexpString !== '%\\(count\\)s') {
+ console.log(`Could not find ${regexp} in ${inputText}`);
+ }
+ continue;
}
- 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));
- output.push(inputText.substr(match.index + match[0].length));
+
+ const head = inputText.substr(0, match.index);
+ if (head !== '') { // Don't push empty nodes, they are of no use
+ output.push(head);
+ }
+
+ let replaced;
+ // If substitution is a function, call it
+ if (mapping[regexpString] instanceof Function) {
+ replaced = mapping[regexpString].apply(null, capturedGroups);
+ } else {
+ replaced = mapping[regexpString];
+ }
+
+ // Here we also need to check that it actually is a string before comparing against one
+ // The head and tail are always strings
+ if (typeof replaced !== 'string' || replaced !== '') {
+ output.push(replaced);
+ }
+
+ if (typeof replaced === 'object') {
+ shouldWrapInSpan = true;
+ }
+
+ const tail = inputText.substr(match.index + match[0].length);
+ if (tail !== '') {
+ output.push(tail);
+ }
}
- // 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 (shouldWrapInSpan) {
+ return React.createElement('span', null, ...output);
+ } else {
+ return output.join('');
+ }
}
// Allow overriding the text displayed when no translation exists
diff --git a/src/settings/SettingsStore.js b/src/settings/SettingsStore.js
index b6343c4a96..d93a48005d 100644
--- a/src/settings/SettingsStore.js
+++ b/src/settings/SettingsStore.js
@@ -176,13 +176,21 @@ export default class SettingsStore {
* @return {*} The value, or null if not found
*/
static getValue(settingName, roomId = null, excludeDefault = false) {
- return SettingsStore.getValueAt(LEVEL_ORDER[0], settingName, roomId, false, excludeDefault);
+ // Verify that the setting is actually a setting
+ if (!SETTINGS[settingName]) {
+ throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
+ }
+
+ const setting = SETTINGS[settingName];
+ const levelOrder = (setting.supportedLevelsAreOrdered ? setting.supportedLevels : LEVEL_ORDER);
+
+ return SettingsStore.getValueAt(levelOrder[0], settingName, roomId, false, excludeDefault);
}
/**
* Gets a setting's value at a particular level, ignoring all levels that are more specific.
- * @param {"device"|"room-device"|"room-account"|"account"|"room"} level The level to
- * look at.
+ * @param {"device"|"room-device"|"room-account"|"account"|"room"|"config"|"default"} level The
+ * level to look at.
* @param {string} settingName The name of the setting to read.
* @param {String} roomId The room ID to read the setting value in, may be null.
* @param {boolean} explicit If true, this method will not consider other levels, just the one
diff --git a/src/settings/controllers/NotificationControllers.js b/src/settings/controllers/NotificationControllers.js
index 261e194d74..9dcf78e26b 100644
--- a/src/settings/controllers/NotificationControllers.js
+++ b/src/settings/controllers/NotificationControllers.js
@@ -15,12 +15,35 @@ limitations under the License.
*/
import SettingController from "./SettingController";
+import MatrixClientPeg from '../../MatrixClientPeg';
+
+// XXX: This feels wrong.
+import PushProcessor from "matrix-js-sdk/lib/pushprocessor";
+
+function isMasterRuleEnabled() {
+ // Return the value of the master push rule as a default
+ const processor = new PushProcessor(MatrixClientPeg.get());
+ const masterRule = processor.getPushRuleById(".m.rule.master");
+
+ if (!masterRule) {
+ console.warn("No master push rule! Notifications are disabled for this user.");
+ return false;
+ }
+
+ // Why enabled == false means "enabled" is beyond me.
+ return !masterRule.enabled;
+}
export class NotificationsEnabledController extends SettingController {
getValueOverride(level, roomId, calculatedValue) {
const Notifier = require('../../Notifier'); // avoids cyclical references
+ if (!Notifier.isPossible()) return false;
- return calculatedValue && Notifier.isPossible();
+ if (calculatedValue === null) {
+ return isMasterRuleEnabled();
+ }
+
+ return calculatedValue;
}
onChange(level, roomId, newValue) {
@@ -35,15 +58,22 @@ export class NotificationsEnabledController extends SettingController {
export class NotificationBodyEnabledController extends SettingController {
getValueOverride(level, roomId, calculatedValue) {
const Notifier = require('../../Notifier'); // avoids cyclical references
+ if (!Notifier.isPossible()) return false;
- return calculatedValue && Notifier.isEnabled();
+ if (calculatedValue === null) {
+ return isMasterRuleEnabled();
+ }
+
+ return calculatedValue;
}
}
export class AudioNotificationsEnabledController extends SettingController {
getValueOverride(level, roomId, calculatedValue) {
const Notifier = require('../../Notifier'); // avoids cyclical references
+ if (!Notifier.isPossible()) return false;
- return calculatedValue && Notifier.isEnabled();
+ // Note: Audio notifications are *not* enabled by default.
+ return calculatedValue;
}
}
diff --git a/src/settings/handlers/AccountSettingsHandler.js b/src/settings/handlers/AccountSettingsHandler.js
index fe0ad2a500..e50358a728 100644
--- a/src/settings/handlers/AccountSettingsHandler.js
+++ b/src/settings/handlers/AccountSettingsHandler.js
@@ -26,6 +26,9 @@ export default class AccountSettingHandler extends SettingsHandler {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {
const content = this._getSettings("org.matrix.preview_urls");
+
+ // Check to make sure that we actually got a boolean
+ if (typeof(content['disable']) !== "boolean") return null;
return !content['disable'];
}
diff --git a/src/settings/handlers/DeviceSettingsHandler.js b/src/settings/handlers/DeviceSettingsHandler.js
index 7b5ec6a5dd..22f6140a80 100644
--- a/src/settings/handlers/DeviceSettingsHandler.js
+++ b/src/settings/handlers/DeviceSettingsHandler.js
@@ -40,11 +40,17 @@ export default class DeviceSettingsHandler extends SettingsHandler {
// Special case notifications
if (settingName === "notificationsEnabled") {
- return localStorage.getItem("notifications_enabled") === "true";
+ const value = localStorage.getItem("notifications_enabled");
+ if (typeof(value) === "string") return value === "true";
+ return null; // wrong type or otherwise not set
} else if (settingName === "notificationBodyEnabled") {
- return localStorage.getItem("notifications_body_enabled") === "true";
+ const value = localStorage.getItem("notifications_body_enabled");
+ if (typeof(value) === "string") return value === "true";
+ return null; // wrong type or otherwise not set
} else if (settingName === "audioNotificationsEnabled") {
- return localStorage.getItem("audio_notifications_enabled") === "true";
+ const value = localStorage.getItem("audio_notifications_enabled");
+ if (typeof(value) === "string") return value === "true";
+ return null; // wrong type or otherwise not set
}
return this._getSettings()[settingName];
diff --git a/src/settings/handlers/RoomAccountSettingsHandler.js b/src/settings/handlers/RoomAccountSettingsHandler.js
index 503d5de6c4..e946581807 100644
--- a/src/settings/handlers/RoomAccountSettingsHandler.js
+++ b/src/settings/handlers/RoomAccountSettingsHandler.js
@@ -25,6 +25,9 @@ export default class RoomAccountSettingsHandler extends SettingsHandler {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {
const content = this._getSettings(roomId, "org.matrix.room.preview_urls");
+
+ // Check to make sure that we actually got a boolean
+ if (typeof(content['disable']) !== "boolean") return null;
return !content['disable'];
}
diff --git a/src/settings/handlers/RoomSettingsHandler.js b/src/settings/handlers/RoomSettingsHandler.js
index 3aee0dd6eb..cb3e836c7f 100644
--- a/src/settings/handlers/RoomSettingsHandler.js
+++ b/src/settings/handlers/RoomSettingsHandler.js
@@ -25,6 +25,9 @@ export default class RoomSettingsHandler extends SettingsHandler {
// Special case URL previews
if (settingName === "urlPreviewsEnabled") {
const content = this._getSettings(roomId, "org.matrix.room.preview_urls");
+
+ // Check to make sure that we actually got a boolean
+ if (typeof(content['disable']) !== "boolean") return null;
return !content['disable'];
}
diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js
index 11f9d86816..01c521da0c 100644
--- a/src/utils/MegolmExportEncryption.js
+++ b/src/utils/MegolmExportEncryption.js
@@ -116,7 +116,7 @@ export async function decryptMegolmKeyFile(data, password) {
aesKey,
ciphertext,
);
- } catch(e) {
+ } catch (e) {
throw friendlyError('subtleCrypto.decrypt failed: ' + e, cryptoFailMsg());
}
diff --git a/src/utils/createMatrixClient.js b/src/utils/createMatrixClient.js
index 2d294e262b..77b8cdb120 100644
--- a/src/utils/createMatrixClient.js
+++ b/src/utils/createMatrixClient.js
@@ -23,7 +23,7 @@ const localStorage = window.localStorage;
let indexedDB;
try {
indexedDB = window.indexedDB;
-} catch(e) {}
+} catch (e) {}
/**
* Create a new matrix client, with the persistent stores set up appropriately
diff --git a/test/i18n-test/languageHandler-test.js b/test/i18n-test/languageHandler-test.js
new file mode 100644
index 0000000000..9c08916235
--- /dev/null
+++ b/test/i18n-test/languageHandler-test.js
@@ -0,0 +1,68 @@
+const React = require('react');
+const expect = require('expect');
+import * as languageHandler from '../../src/languageHandler';
+
+const testUtils = require('../test-utils');
+
+describe('languageHandler', function() {
+ let sandbox;
+
+ beforeEach(function(done) {
+ testUtils.beforeEach(this);
+ sandbox = testUtils.stubClient();
+
+ languageHandler.setLanguage('en').done(done);
+ });
+
+ afterEach(function() {
+ sandbox.restore();
+ });
+
+ it('translates a string to german', function() {
+ languageHandler.setLanguage('de').then(function() {
+ const translated = languageHandler._t('Rooms');
+ expect(translated).toBe('Räume');
+ });
+ });
+
+ it('handles plurals', function() {
+ const text = 'and %(count)s others...';
+ expect(languageHandler._t(text, { count: 1 })).toBe('and one other...');
+ expect(languageHandler._t(text, { count: 2 })).toBe('and 2 others...');
+ });
+
+ it('handles simple variable subsitutions', function() {
+ const text = 'You are now ignoring %(userId)s';
+ expect(languageHandler._t(text, { userId: 'foo' })).toBe('You are now ignoring foo');
+ });
+
+ it('handles simple tag substitution', function() {
+ const text = 'Press to start a chat with someone';
+ expect(languageHandler._t(text, {}, { 'StartChatButton': () => 'foo' }))
+ .toBe('Press foo to start a chat with someone');
+ });
+
+ it('handles text in tags', function() {
+ const text = 'Click here to join the discussion!';
+ expect(languageHandler._t(text, {}, { 'a': (sub) => `x${sub}x` }))
+ .toBe('xClick herex to join the discussion!');
+ });
+
+ it('variable substitution with React component', function() {
+ const text = 'You are now ignoring %(userId)s';
+ expect(languageHandler._t(text, { userId: () => foo }))
+ .toEqual((You are now ignoring foo));
+ });
+
+ it('variable substitution with plain React component', function() {
+ const text = 'You are now ignoring %(userId)s';
+ expect(languageHandler._t(text, { userId: foo }))
+ .toEqual((You are now ignoring foo));
+ });
+
+ it('tag substitution with React component', function() {
+ const text = 'Press to start a chat with someone';
+ expect(languageHandler._t(text, {}, { 'StartChatButton': () => foo }))
+ .toEqual(Press foo to start a chat with someone);
+ });
+});