Merge remote-tracking branch 'origin/develop' into dbkr/udd_no_auto_show

This commit is contained in:
David Baker 2017-11-16 15:59:16 +00:00
commit 196eafdc7f
58 changed files with 645 additions and 336 deletions

View File

@ -29,6 +29,10 @@ module.exports = {
// so we replace it with a version that is class property aware
"babel/no-invalid-this": "error",
// We appear to follow this most of the time, so let's enforce it instead
// of occasionally following it (or catching it in review)
"keyword-spacing": "error",
/** react **/
// This just uses the react plugin to help eslint known when
// variables have been used in JSX

View File

@ -32,7 +32,7 @@ const walk = require('walk');
const flowParser = require('flow-parser');
const estreeWalker = require('estree-walker');
const TRANSLATIONS_FUNCS = ['_t', '_td', '_tJsx'];
const TRANSLATIONS_FUNCS = ['_t', '_td'];
const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json';
const OUTPUT_FILE = 'src/i18n/strings/en_EN.json';
@ -126,7 +126,7 @@ function getTranslationsJs(file) {
if (tKey === null) return;
// check the format string against the args
// We only check _t: _tJsx is much more complex and _td has no args
// We only check _t: _td has no args
if (node.callee.name === '_t') {
try {
const placeholders = getFormatStrings(tKey);
@ -139,6 +139,22 @@ function getTranslationsJs(file) {
throw new Error(`No value found for placeholder '${placeholder}'`);
}
}
// Validate tag replacements
if (node.arguments.length > 2) {
const tagMap = node.arguments[2];
for (const prop of tagMap.properties) {
if (prop.key.type === 'Literal') {
const tag = prop.key.value;
// RegExp same as in src/languageHandler.js
const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`);
if (!tKey.match(regexp)) {
throw new Error(`No match for ${regexp} in ${tKey}`);
}
}
}
}
} catch (e) {
console.log();
console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`);

View File

@ -19,6 +19,10 @@ const DEBUG = 0;
// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue]
function colorToRgb(color) {
if (!color) {
return [0, 0, 0];
}
if (color[0] === '#') {
color = color.slice(1);
if (color.length === 3) {
@ -31,13 +35,14 @@ function colorToRgb(color) {
const g = (val >> 8) & 255;
const b = val & 255;
return [r, g, b];
}
else {
let match = color.match(/rgb\((.*?),(.*?),(.*?)\)/);
} else {
const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/);
if (match) {
return [ parseInt(match[1]),
return [
parseInt(match[1]),
parseInt(match[2]),
parseInt(match[3]) ];
parseInt(match[3]),
];
}
}
return [0, 0, 0];
@ -72,6 +77,7 @@ class Tinter {
"#EAF5F0", // Vector Light Green
"#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
"#FFFFFF", // white highlights of the SVGs (for switching to dark theme)
"#000000", // black lowlights of the SVGs (for switching to dark theme)
];
// track the replacement colours actually being used
@ -81,6 +87,7 @@ class Tinter {
this.keyHex[1],
this.keyHex[2],
this.keyHex[3],
this.keyHex[4],
];
// track the most current tint request inputs (which may differ from the
@ -90,6 +97,7 @@ class Tinter {
undefined,
undefined,
undefined,
undefined,
];
this.cssFixups = [
@ -152,9 +160,11 @@ class Tinter {
this.calcCssFixups();
if (DEBUG) console.log("Tinter.tint(" + primaryColor + ", " +
if (DEBUG) {
console.log("Tinter.tint(" + primaryColor + ", " +
secondaryColor + ", " +
tertiaryColor + ")");
}
if (!primaryColor) {
primaryColor = this.keyRgb[0];
@ -194,9 +204,11 @@ class Tinter {
this.colors[1] = secondaryColor;
this.colors[2] = tertiaryColor;
if (DEBUG) console.log("Tinter.tint final: (" + primaryColor + ", " +
if (DEBUG) {
console.log("Tinter.tint final: (" + primaryColor + ", " +
secondaryColor + ", " +
tertiaryColor + ")");
}
// go through manually fixing up the stylesheets.
this.applyCssFixups();
@ -223,6 +235,22 @@ class Tinter {
});
}
tintSvgBlack(blackColor) {
this.currentTint[4] = blackColor;
if (!blackColor) {
blackColor = this.colors[4];
}
if (this.colors[4] === blackColor) {
return;
}
this.colors[4] = blackColor;
this.tintables.forEach(function(tintable) {
tintable();
});
}
setTheme(theme) {
console.trace("setTheme " + theme);
this.theme = theme;
@ -230,18 +258,15 @@ class Tinter {
// update keyRgb from the current theme CSS itself, if it defines it
if (document.getElementById('mx_theme_accentColor')) {
this.keyRgb[0] = window.getComputedStyle(
document.getElementById('mx_theme_accentColor')
).color;
document.getElementById('mx_theme_accentColor')).color;
}
if (document.getElementById('mx_theme_secondaryAccentColor')) {
this.keyRgb[1] = window.getComputedStyle(
document.getElementById('mx_theme_secondaryAccentColor')
).color;
document.getElementById('mx_theme_secondaryAccentColor')).color;
}
if (document.getElementById('mx_theme_tertiaryAccentColor')) {
this.keyRgb[2] = window.getComputedStyle(
document.getElementById('mx_theme_tertiaryAccentColor')
).color;
document.getElementById('mx_theme_tertiaryAccentColor')).color;
}
this.calcCssFixups();
@ -253,8 +278,10 @@ class Tinter {
// abuse the tinter to change all the SVG's #fff to #2d2d2d
// XXX: obviously this shouldn't be hardcoded here.
this.tintSvgWhite('#2d2d2d');
this.tintSvgBlack('#dddddd');
} else {
this.tintSvgWhite('#ffffff');
this.tintSvgBlack('#000000');
}
}
@ -262,9 +289,11 @@ class Tinter {
// cache our fixups
if (this.cssFixups[this.theme]) return;
if (DEBUG) console.debug("calcCssFixups start for " + this.theme + " (checking " +
if (DEBUG) {
console.debug("calcCssFixups start for " + this.theme + " (checking " +
document.styleSheets.length +
" stylesheets)");
}
this.cssFixups[this.theme] = [];
@ -322,21 +351,24 @@ class Tinter {
}
}
}
if (DEBUG) console.log("calcCssFixups end (" +
if (DEBUG) {
console.log("calcCssFixups end (" +
this.cssFixups[this.theme].length +
" fixups)");
}
}
applyCssFixups() {
if (DEBUG) console.log("applyCssFixups start (" +
if (DEBUG) {
console.log("applyCssFixups start (" +
this.cssFixups[this.theme].length +
" fixups)");
}
for (let i = 0; i < this.cssFixups[this.theme].length; i++) {
const cssFixup = this.cssFixups[this.theme][i];
try {
cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index];
}
catch (e) {
} catch (e) {
// Firefox Quantum explodes if you manually edit the CSS in the
// inspector and then try to do a tint, as apparently all the
// fixups are then stale.
@ -358,7 +390,7 @@ class Tinter {
if (DEBUG) console.log("calcSvgFixups start for " + svgs);
const fixups = [];
for (let i = 0; i < svgs.length; i++) {
var svgDoc;
let svgDoc;
try {
svgDoc = svgs[i].contentDocument;
} catch (e) {
@ -369,7 +401,7 @@ class Tinter {
if (e.stack) {
msg += ' | stack: ' + e.stack;
}
console.error(e);
console.error(msg);
}
if (!svgDoc) continue;
const tags = svgDoc.getElementsByTagName("*");
@ -379,8 +411,7 @@ class Tinter {
const attr = this.svgAttrs[k];
for (let l = 0; l < this.keyHex.length; l++) {
if (tag.getAttribute(attr) &&
tag.getAttribute(attr).toUpperCase() === this.keyHex[l])
{
tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) {
fixups.push({
node: tag,
attr: attr,

View File

@ -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 <div className="mx_FilePanel mx_RoomView_messageListWrapper">
<div className="mx_RoomView_empty">
{ _tJsx("You must <a>register</a> to use this functionality", /<a>(.*?)<\/a>/, (sub) => <a href="#/register" key="sub">{ sub }</a>) }
{ _t("You must <a>register</a> to use this functionality",
{},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
}
</div>
</div>;
} else if (this.noRoom) {

View File

@ -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.<br />' +
'Click here to open settings and give it one!',
[/<br \/>/],
[(sub) => <br />])
}
{},
{ 'br': <br /> },
) }
</div>;
}
const groupDescEditingClasses = classnames({

View File

@ -888,7 +888,7 @@ module.exports = React.createClass({
*/
_onSetTheme: function(theme) {
if (!theme) {
theme = this.props.config.default_theme || 'light';
theme = SettingsStore.getValueAt(SettingLevel.DEFAULT, "theme");
}
// look for the stylesheet elements.

View File

@ -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({
<div className="mx_MyGroups_headerCard_header">
{ _t('Join an existing community') }
</div>
{ _tJsx(
{ _t(
'To join an existing community you\'ll have to '+
'know its community identifier; this will look '+
'something like <i>+example:matrix.org</i>.',
/<i>(.*)<\/i>/,
(sub) => <i>{ sub }</i>,
) }
{},
{ 'i': (sub) => <i>{ sub }</i> })
}
</div>
</div>
</div>

View File

@ -16,8 +16,8 @@ limitations under the License.
*/
import React from 'react';
import { _t, _tJsx } from '../../languageHandler';
import Matrix from 'matrix-js-sdk';
import { _t } from '../../languageHandler';
import sdk from '../../index';
import WhoIsTyping from '../../WhoIsTyping';
import MatrixClientPeg from '../../MatrixClientPeg';
@ -25,7 +25,6 @@ import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend';
import { showUnknownDeviceDialogForMessages } from '../../cryptodevices';
const HIDE_DEBOUNCE_MS = 10000;
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
@ -286,13 +285,13 @@ module.exports = React.createClass({
if (hasUDE) {
title = _t("Message not sent due to unknown devices being present");
content = _tJsx(
"<a>Show devices</a> or <a>cancel all</a>.",
[/<a>(.*?)<\/a>/, /<a>(.*?)<\/a>/],
[
(sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
(sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
],
content = _t(
"<showDevicesText>Show devices</showDevicesText> or <cancelText>cancel all</cancelText>.",
{},
{
'showDevicesText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
} else {
if (
@ -305,14 +304,15 @@ module.exports = React.createClass({
} else {
title = _t("Some of your messages have not been sent.");
}
content = _tJsx(
"<a>Resend all</a> or <a>cancel all</a> now. "+
content = _t("<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " +
"You can also select individual messages to resend or cancel.",
[/<a>(.*?)<\/a>/, /<a>(.*?)<\/a>/],
[
(sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
(sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
],
{},
{
'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
'cancelText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
}
@ -391,12 +391,15 @@ module.exports = React.createClass({
if (this.props.sentMessageAndIsAlone) {
return (
<div className="mx_RoomStatusBar_isAlone">
{ _tJsx("There's no one else here! Would you like to <a>invite others</a> or <a>stop warning about the empty room</a>?",
[/<a>(.*?)<\/a>/, /<a>(.*?)<\/a>/],
[
(sub) => <a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
(sub) => <a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
],
{ _t("There's no one else here! Would you like to <inviteText>invite others</inviteText> " +
"or <nowarnText>stop warning about the empty room</nowarnText>?",
{},
{
'inviteText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
'nowarnText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
},
) }
</div>
);

View File

@ -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});
},

View File

@ -613,8 +613,7 @@ 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);
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
this.setState({
language: newLang,
});

View File

@ -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';
@ -113,7 +113,7 @@ module.exports = React.createClass({
<div className="mx_Login_smallError">
{ _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?:\/\//, ''),
})
}
</div>
@ -272,17 +272,19 @@ module.exports = React.createClass({
!this.state.enteredHomeserverUrl.startsWith("http"))
) {
errorText = <span>
{ _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 <a>enable unsafe scripts</a>.",
/<a>(.*?)<\/a>/,
(sub) => { return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">{ sub }</a>; },
{},
{ 'a': (sub) => { return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">{ sub }</a>; } },
) }
</span>;
} else {
errorText = <span>
{ _tJsx("Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.",
/<a>(.*?)<\/a>/,
(sub) => { return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>; },
{
_t("Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.",
{},
{ 'a': (sub) => { return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>; } },
) }
</span>;
}
@ -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 (
<PasswordLogin
@ -309,12 +311,14 @@ module.exports = React.createClass({
hsUrl={this.state.enteredHomeserverUrl}
/>
);
case 'm.login.cas':
}
case 'm.login.cas': {
const CasLogin = sdk.getComponent('login.CasLogin');
return (
<CasLogin onSubmit={this.onCasLogin} />
);
default:
}
default: {
if (!step) {
return;
}
@ -324,6 +328,7 @@ module.exports = React.createClass({
</div>
);
}
}
},
_onLanguageChange: function(newLang) {
@ -388,8 +393,7 @@ module.exports = React.createClass({
const theme = SettingsStore.getValue("theme");
if (theme !== "status") {
header = <h2>{ _t('Sign in') }</h2>;
}
else {
} else {
if (!this.state.errorText) {
header = <h2>{ _t('Sign in to get started') }</h2>;
}

View File

@ -399,8 +399,7 @@ module.exports = React.createClass({
// FIXME: remove hardcoded Status team tweaks at some point
if (theme === 'status' && this.state.errorText) {
header = <div className="mx_Login_error">{ this.state.errorText }</div>;
}
else {
} else {
header = <h2>{ _t('Create an account') }</h2>;
if (this.state.errorText) {
errorText = <div className="mx_Login_error">{ this.state.errorText }</div>;

View File

@ -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 = (
<p>
{ _tJsx(
{ _t(
"Otherwise, <a>click here</a> to send a bug report.",
/<a>(.*?)<\/a>/, (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a>,
{},
{ 'a': (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a> },
) }
</p>
);

View File

@ -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({
</div>
{ usernameIndicator }
<p>
{ _tJsx(
{ _t(
'This will be your account name on the <span></span> ' +
'homeserver, or you can pick a <a>different server</a>.',
[
/<span><\/span>/,
/<a>(.*?)<\/a>/,
],
[
(sub) => <span>{ this.props.homeserverUrl }</span>,
(sub) => <a href="#" onClick={this.props.onDifferentServerClicked}>{ sub }</a>,
],
{},
{
'span': <span>{ this.props.homeserverUrl }</span>,
'a': (sub) => <a href="#" onClick={this.props.onDifferentServerClicked}>{ sub }</a>,
},
) }
</p>
<p>
{ _tJsx(
{ _t(
'If you already have a Matrix account you can <a>log in</a> instead.',
/<a>(.*?)<\/a>/,
[(sub) => <a href="#" onClick={this.props.onLoginClick}>{ sub }</a>],
{},
{ 'a': (sub) => <a href="#" onClick={this.props.onLoginClick}>{ sub }</a> },
) }
</p>
{ auth }

View File

@ -22,6 +22,7 @@ import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import PlatformPeg from '../../../PlatformPeg';
import ScalarAuthClient from '../../../ScalarAuthClient';
import TintableSvgButton from './TintableSvgButton';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
import { _t, _td } from '../../../languageHandler';
@ -371,8 +372,8 @@ export default React.createClass({
// editing is done in scalar
const showEditButton = Boolean(this._scalarClient && this._canUserModify());
const deleteWidgetLabel = this._deleteWidgetLabel();
let deleteIcon = 'img/cancel.svg';
let deleteClasses = 'mx_filterFlipColor mx_AppTileMenuBarWidget';
let deleteIcon = 'img/cancel_green.svg';
let deleteClasses = 'mx_AppTileMenuBarWidget';
if (this._canUserModify()) {
deleteIcon = 'img/icon-delete-pink.svg';
deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
@ -384,22 +385,23 @@ export default React.createClass({
<b>{ this.formatAppTileName() }</b>
<span className="mx_AppTileMenuBarWidgets">
{ /* Edit widget */ }
{ showEditButton && <img
{ showEditButton && <TintableSvgButton
src="img/edit_green.svg"
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
width="8" height="8"
alt={_t('Edit')}
title={_t('Edit')}
onClick={this._onEditClick}
width="10"
height="10"
/> }
{ /* Delete widget */ }
<img src={deleteIcon}
<TintableSvgButton
src={deleteIcon}
className={deleteClasses}
width="8" height="8"
alt={_t(deleteWidgetLabel)}
title={_t(deleteWidgetLabel)}
onClick={this._onDeleteClick}
width="10"
height="10"
/>
</span>
</div>

View File

@ -304,7 +304,7 @@ module.exports = React.createClass({
return items[0];
} else if (remaining > 0) {
items = items.slice(0, itemLimit);
return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } )
return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } );
} else {
const lastItem = items.pop();
return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });

View File

@ -0,0 +1,61 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import TintableSvg from './TintableSvg';
export default class TintableSvgButton extends React.Component {
constructor(props) {
super(props);
}
render() {
let classes = "mx_TintableSvgButton";
if (this.props.className) {
classes += " " + this.props.className;
}
return (
<span
width={this.props.width}
height={this.props.height}
className={classes}>
<TintableSvg
src={this.props.src}
width={this.props.width}
height={this.props.height}
></TintableSvg>
<span
title={this.props.title}
onClick={this.props.onClick} />
</span>
);
}
}
TintableSvgButton.propTypes = {
src: PropTypes.string,
title: PropTypes.string,
className: PropTypes.string,
width: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
onClick: PropTypes.func,
};
TintableSvgButton.defaultProps = {
onClick: function() {},
};

View File

@ -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 <a>web browser</a>",
/<a>(.*?)<\/a>/,
(sub) => { return <a href='https://riot.im/app'>{ sub }</a>; }), warning);
{},
{ 'a': (sub) => { return <a href='https://riot.im/app'>{ sub }</a>; }}), warning);
this.refs.recaptchaContainer.appendChild(warning);
} else {
const scriptTag = document.createElement('script');

View File

@ -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 (
<div>
<p>{ _tJsx("An email has been sent to %(emailAddress)s", /%\(emailAddress\)s/, (sub) => <i>{this.props.inputs.emailAddress}</i>) }</p>
<p>{ _t("An email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <i>{ this.props.inputs.emailAddress }</i> },
) }
</p>
<p>{ _t("Please check your email to continue registration.") }</p>
</div>
);
@ -370,7 +373,10 @@ export const MsisdnAuthEntry = React.createClass({
});
return (
<div>
<p>{ _tJsx("A text message has been sent to %(msisdn)s", /%\(msisdn\)s/, (sub) => <i>{this._msisdn}</i>) }</p>
<p>{ _t("A text message has been sent to %(msisdn)s",
{ msisdn: <i>this._msisdn</i> },
) }
</p>
<p>{ _t("Please enter the code it contains:") }</p>
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
<form onSubmit={this._onFormSubmit}>

View File

@ -146,7 +146,7 @@ class PasswordLogin extends React.Component {
onChange={this.onUsernameChanged}
placeholder={SdkConfig.get().disable_custom_urls ?
_t("Username on %(hs)s", {
hs: this.props.hsUrl.replace(/^https?:\/\//, '')
hs: this.props.hsUrl.replace(/^https?:\/\//, ''),
}) : _t("User name")}
value={this.state.username}
autoFocus

View File

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { ContentRepo } from 'matrix-js-sdk';
import { _t, _tJsx } from '../../../languageHandler';
import { _t } from '../../../languageHandler';
import sdk from '../../../index';
import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton';
@ -67,24 +67,17 @@ module.exports = React.createClass({
'crop',
);
// it sucks that _tJsx doesn't support normal _t substitutions :((
return (
<div className="mx_RoomAvatarEvent">
{ _tJsx('%(senderDisplayName)s changed the room avatar to <img/>',
[
/%\(senderDisplayName\)s/,
/<img\/>/,
],
[
(sub) => senderDisplayName,
(sub) =>
{ _t('%(senderDisplayName)s changed the room avatar to <img/>',
{ senderDisplayName: senderDisplayName },
{
'img': () =>
<AccessibleButton key="avatar" className="mx_RoomAvatarEvent_avatar"
onClick={this.onAvatarClick.bind(this, name)}>
<BaseAvatar width={14} height={14} url={url}
name={name} />
<BaseAvatar width={14} height={14} url={url} name={name} />
</AccessibleButton>,
],
)
})
}
</div>
);

View File

@ -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 = '';
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 ? <span className='mx_SenderProfile_aux'>{ p1 }</span> : null,
nameElem,
p2 ? <span className='mx_SenderProfile_aux'>{ p2 }</span> : null,
]);
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 (
<div className="mx_SenderProfile" dir="auto" onClick={props.onClick}>
{ content }
{ content.props.children[0] ?
<span className='mx_SenderProfile_aux'>{ content.props.children[0] }</span> : ''
}
{ content.props.children[1] }
{ content.props.children[2] ?
<span className='mx_SenderProfile_aux'>{ content.props.children[2] }</span> : ''
}
</div>
);
}

View File

@ -17,7 +17,7 @@ limitations under the License.
const React = require('react');
const sdk = require("../../../index");
import { _t, _tJsx } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
@ -42,11 +42,11 @@ module.exports = React.createClass({
let previewsForAccount = null;
if (SettingsStore.getValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled")) {
previewsForAccount = (
_tJsx("You have <a>enabled</a> URL previews by default.", /<a>(.*?)<\/a>/, (sub)=><a href="#/settings">{ sub }</a>)
_t("You have <a>enabled</a> URL previews by default.", {}, { 'a': (sub)=><a href="#/settings">{ sub }</a> })
);
} else {
previewsForAccount = (
_tJsx("You have <a>disabled</a> URL previews by default.", /<a>(.*?)<\/a>/, (sub)=><a href="#/settings">{ sub }</a>)
_t("You have <a>disabled</a> URL previews by default.", {}, { 'a': (sub)=><a href="#/settings">{ sub }</a> })
);
}
@ -63,14 +63,14 @@ module.exports = React.createClass({
</label>
);
} else {
let str = "URL previews are enabled by default for participants in this room.";
let str = _td("URL previews are enabled by default for participants in this room.");
if (!SettingsStore.getValueAt(SettingLevel.ROOM, "urlPreviewsEnabled")) {
str = "URL previews are disabled by default for participants in this room.";
str = _td("URL previews are disabled by default for participants in this room.");
}
previewsForRoom = (<label>{ _t(str) }</label>);
}
let previewsForRoomAccount = (
const previewsForRoomAccount = (
<SettingsFlag name="urlPreviewsEnabled"
level={SettingLevel.ROOM_ACCOUNT}
roomId={this.props.room.roomId}

View File

@ -21,7 +21,7 @@ import sdk from '../../../index';
import dis from "../../../dispatcher";
import ObjectUtils from '../../../ObjectUtils';
import AppsDrawer from './AppsDrawer';
import { _t, _tJsx} from '../../../languageHandler';
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
@ -99,13 +99,13 @@ module.exports = React.createClass({
supportedText = _t(" (unsupported)");
} else {
joinNode = (<span>
{ _tJsx(
{ _t(
"Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
[/<voiceText>(.*?)<\/voiceText>/, /<videoText>(.*?)<\/videoText>/],
[
(sub) => <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice');}} href="#">{ sub }</a>,
(sub) => <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video');}} href="#">{ sub }</a>,
],
{},
{
'voiceText': (sub) => <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice');}} href="#">{ sub }</a>,
'videoText': (sub) => <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video');}} href="#">{ sub }</a>,
},
) }
</span>);
}

View File

@ -33,22 +33,30 @@ const ObjectUtils = require('../../../ObjectUtils');
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
'm.room.member': 'messages.TextualEvent',
'm.call.invite': 'messages.TextualEvent',
'm.call.answer': 'messages.TextualEvent',
'm.call.hangup': 'messages.TextualEvent',
};
const stateEventTileTypes = {
'm.room.member': 'messages.TextualEvent',
'm.room.name': 'messages.TextualEvent',
'm.room.avatar': 'messages.RoomAvatarEvent',
'm.room.topic': 'messages.TextualEvent',
'm.room.third_party_invite': 'messages.TextualEvent',
'm.room.history_visibility': 'messages.TextualEvent',
'm.room.encryption': 'messages.TextualEvent',
'm.room.topic': 'messages.TextualEvent',
'm.room.power_levels': 'messages.TextualEvent',
'm.room.pinned_events': 'messages.TextualEvent',
'im.vector.modular.widgets': 'messages.TextualEvent',
};
function getHandlerTile(ev) {
const type = ev.getType();
return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type];
}
const MAX_READ_AVATARS = 5;
// Our component structure for EventTiles on the timeline is:
@ -433,7 +441,7 @@ module.exports = withMatrixClient(React.createClass({
// Info messages are basically information about commands processed on a room
const isInfoMessage = (eventType !== 'm.room.message');
const EventTileType = sdk.getComponent(eventTileTypes[eventType]);
const EventTileType = sdk.getComponent(getHandlerTile(this.props.mxEvent));
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!EventTileType) {
@ -600,8 +608,10 @@ module.exports = withMatrixClient(React.createClass({
module.exports.haveTileForEvent = function(e) {
// Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && e.getType() !== 'm.room.message') return false;
if (eventTileTypes[e.getType()] == undefined) return false;
if (eventTileTypes[e.getType()] == 'messages.TextualEvent') {
const handler = getHandlerTile(e);
if (handler === undefined) return false;
if (handler === 'messages.TextualEvent') {
return TextForEvent.textForEvent(e) !== '';
} else {
return true;

View File

@ -18,12 +18,10 @@ limitations under the License.
'use strict';
const React = require("react");
const ReactDOM = require("react-dom");
import { _t, _tJsx } from '../../../languageHandler';
import { _t } from '../../../languageHandler';
const GeminiScrollbar = require('react-gemini-scrollbar');
const MatrixClientPeg = require("../../../MatrixClientPeg");
const CallHandler = require('../../../CallHandler');
const RoomListSorter = require("../../../RoomListSorter");
const Unread = require('../../../Unread');
const dis = require("../../../dispatcher");
const sdk = require('../../../index');
const rate_limited_func = require('../../../ratelimitedfunc');
@ -486,28 +484,25 @@ module.exports = React.createClass({
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
switch (section) {
case 'im.vector.fake.direct':
return <div className="mx_RoomList_emptySubListTip">
{ _tJsx(
{ _t(
"Press <StartChatButton> to start a chat with someone",
[/<StartChatButton>/],
[
(sub) => <StartChatButton size="16" callout={true} />,
],
{},
{ 'StartChatButton': <StartChatButton size="16" callout={true} /> },
) }
</div>;
case 'im.vector.fake.recent':
return <div className="mx_RoomList_emptySubListTip">
{ _tJsx(
{ _t(
"You're not in any rooms yet! Press <CreateRoomButton> to make a room or"+
" <RoomDirectoryButton> to browse the directory",
[/<CreateRoomButton>/, /<RoomDirectoryButton>/],
[
(sub) => <CreateRoomButton size="16" callout={true} />,
(sub) => <RoomDirectoryButton size="16" callout={true} />,
],
{},
{
'CreateRoomButton': <CreateRoomButton size="16" callout={true} />,
'RoomDirectoryButton': <RoomDirectoryButton size="16" callout={true} />,
},
) }
</div>;
}

View File

@ -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}) }
</div>
<div className="mx_RoomPreviewBar_join_text">
{ _tJsx(
{ _t(
'Would you like to <acceptText>accept</acceptText> or <declineText>decline</declineText> this invitation?',
[/<acceptText>(.*?)<\/acceptText>/, /<declineText>(.*?)<\/declineText>/],
[
(sub) => <a onClick={this.props.onJoinClick}>{ sub }</a>,
(sub) => <a onClick={this.props.onRejectClick}>{ sub }</a>,
],
{},
{
'acceptText': (sub) => <a onClick={this.props.onJoinClick}>{ sub }</a>,
'declineText': (sub) => <a onClick={this.props.onRejectClick}>{ sub }</a>,
},
) }
</div>
{ emailMatchBlock }
@ -211,9 +211,9 @@ module.exports = React.createClass({
<div className="mx_RoomPreviewBar_join_text">
{ name ? _t('You are trying to access %(roomName)s.', {roomName: name}) : _t('You are trying to access a room.') }
<br />
{ _tJsx("<a>Click here</a> to join the discussion!",
/<a>(.*?)<\/a>/,
(sub) => <a onClick={this.props.onJoinClick}><b>{ sub }</b></a>,
{ _t("<a>Click here</a> to join the discussion!",
{},
{ 'a': (sub) => <a onClick={this.props.onJoinClick}><b>{ sub }</b></a> },
) }
</div>
</div>

View File

@ -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 = (
<SettingsFlag name="blacklistUnverifiedDevices"
level={SettingLevel.ROOM_DEVICE}
roomId={this.props.room.roomId}
@ -628,9 +628,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;
@ -749,9 +747,9 @@ module.exports = React.createClass({
}
});
var tagsSection = null;
let tagsSection = null;
if (canSetTag || self.state.tags) {
var tagsSection =
tagsSection =
<div className="mx_RoomSettings_tags">
{ _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 =
<div className="mx_RoomSettings_warning">
{ _tJsx(
{ _t(
'To link to a room it must have <a>an address</a>.',
/<a>(.*?)<\/a>/,
(sub) => <a href="#addresses">{ sub }</a>,
{},
{ 'a': (sub) => <a href="#addresses">{ sub }</a> },
) }
</div>;
}
@ -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 <eventType/>, you must be a", /<eventType\/>/, () => <code>{ event_type }</code>);
else label = _t("To send events of type <eventType/>, you must be a", {}, { 'eventType': <code>{ event_type }</code> });
return (
<div className="mx_RoomSettings_powerLevel" key={event_type}>
<span className="mx_RoomSettings_powerLevelKey">{ label } </span>

View File

@ -460,6 +460,8 @@
"New community ID (e.g. +foo:%(localDomain)s)": "New community ID (e.g. +foo:%(localDomain)s)",
"You have <a>enabled</a> URL previews by default.": "You have <a>enabled</a> URL previews by default.",
"You have <a>disabled</a> URL previews by default.": "You have <a>disabled</a> 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",
"<a>Show devices</a> or <a>cancel all</a>.": "<a>Show devices</a> or <a>cancel all</a>.",
"<showDevicesText>Show devices</showDevicesText> or <cancelText>cancel all</cancelText>.": "<showDevicesText>Show devices</showDevicesText> or <cancelText>cancel all</cancelText>.",
"Some of your messages have not been sent.": "Some of your messages have not been sent.",
"<a>Resend all</a> or <a>cancel all</a> now. You can also select individual messages to resend or cancel.": "<a>Resend all</a> or <a>cancel all</a> now. You can also select individual messages to resend or cancel.",
"<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.": "<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> 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 <a>invite others</a> or <a>stop warning about the empty room</a>?": "There's no one else here! Would you like to <a>invite others</a> or <a>stop warning about the empty room</a>?",
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
"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",

View File

@ -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 <a href=''>here</a> now",
* /<a href=''>(.*?)<\/a>/,
* (sub) => { return <a href=''>{ sub }</a>; }
* );
* 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 <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* @param {string} jsxText The untranslated stringified JSX e.g "click <a href=''>here</a> 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. "<a>Click here!</a>"), otherwise
* you will end up with literal "<a>" 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 <span> component containing the generated text
* @return a React <span> 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 <a>here</a> now to %(foo)s".
* @param {object} variables Variable substitutions, e.g { foo: 'bar' }
* @param {object} tags Tag substitutions e.g. { 'a': (sub) => <a>{sub}</a> }
*
* 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 <span> 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 <span> 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}`);
}
const capturedGroups = match.slice(1);
continue;
}
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);
}
// 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 <span>.
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);
}
}
if (shouldWrapInSpan) {
return React.createElement('span', null, ...output);
} else {
return output.join('');
}
}
// Allow overriding the text displayed when no translation exists

View File

@ -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

View File

@ -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;
}
}

View File

@ -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'];
}

View File

@ -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];

View File

@ -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'];
}

View File

@ -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'];
}

View File

@ -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 <StartChatButton> 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 = '<a>Click here</a> 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: () => <i>foo</i> }))
.toEqual((<span>You are now ignoring <i>foo</i></span>));
});
it('variable substitution with plain React component', function() {
const text = 'You are now ignoring %(userId)s';
expect(languageHandler._t(text, { userId: <i>foo</i> }))
.toEqual((<span>You are now ignoring <i>foo</i></span>));
});
it('tag substitution with React component', function() {
const text = 'Press <StartChatButton> to start a chat with someone';
expect(languageHandler._t(text, {}, { 'StartChatButton': () => <i>foo</i> }))
.toEqual(<span>Press <i>foo</i> to start a chat with someone</span>);
});
});