diff --git a/.eslintrc.js b/.eslintrc.js
index fd4d1da631..c6aeb0d1be 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -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
diff --git a/docs/settings.md b/docs/settings.md
new file mode 100644
index 0000000000..d41aebad3c
--- /dev/null
+++ b/docs/settings.md
@@ -0,0 +1,151 @@
+# Settings Reference
+
+This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify different values for a setting at particular levels of interest. For example, a user may say that in a particular room they want URL previews off, but in all other rooms they want them enabled. The `SettingsStore` helps mask the complexity of dealing with the different levels and exposes easy to use getters and setters.
+
+
+## Levels
+
+Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in order of prioirty, are:
+* `device` - The current user's device
+* `room-device` - The current user's device, but only when in a specific room
+* `room-account` - The current user's account, but only when in a specific room
+* `account` - The current user's account
+* `room` - A specific room (setting for all members of the room)
+* `config` - Values are defined by `config.json`
+* `default` - The hardcoded default for the settings
+
+Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure that room administrators cannot force account-only settings upon participants.
+
+
+## Settings
+
+Settings are the different options a user may set or experience in the application. These are pre-defined in `src/settings/Settings.js` under the `SETTINGS` constant and have the following minimum requirements:
+```
+// The ID is used to reference the setting throughout the application. This must be unique.
+"theSettingId": {
+ // The levels this setting supports is required. In `src/settings/Settings.js` there are various pre-set arrays
+ // for this option - they should be used where possible to avoid copy/pasting arrays across settings.
+ supportedLevels: [...],
+
+ // The default for this setting serves two purposes: It provides a value if the setting is not defined at other
+ // levels, and it serves to demonstrate the expected type to other developers. The value isn't enforced, but it
+ // should be respected throughout the code. The default may be any data type.
+ default: false,
+
+ // The display name has two notations: string and object. The object notation allows for different translatable
+ // strings to be used for different levels, while the string notation represents the string for all levels.
+
+ displayName: _td("Change something"), // effectively `displayName: { "default": _td("Change something") }`
+ displayName: {
+ "room": _td("Change something for participants of this room"),
+
+ // Note: the default will be used if the level requested (such as `device`) does not have a string defined here.
+ "default": _td("Change something"),
+ }
+}
+```
+
+### Getting values for a setting
+
+After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always be supplied where possible, even if the setting does not have a per-room level value. This is to ensure that the value returned is best represented in the room, particularly if the setting ever gets a per-room level in the future.
+
+In settings pages it is often desired to have the value at a particular level instead of getting the calculated value. Call `SettingsStore.getValueAt` to get the value of a setting at a particular level, and optionally make it explicitly at that level. By default `getValueAt` will traverse the tree starting at the provided level; making it explicit means it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the target level.
+
+### Setting values for a setting
+
+Values are defined at particular levels and should be done in a safe manner. There are two checks to perform to ensure a clean save: is the level supported and can the user actually set the value. In most cases, neither should be an issue although there are circumstances where this changes. An example of a safe call is:
+```javascript
+const isSupported = SettingsStore.isLevelSupported(SettingLevel.ROOM);
+if (isSupported) {
+ const canSetValue = SettingsStore.canSetValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM);
+ if (canSetValue) {
+ SettingsStore.setValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM, newValue);
+ }
+}
+```
+
+These checks may also be performed in different areas of the application to avoid the verbose example above. For instance, the component which allows changing the setting may be hidden conditionally on the above conditions.
+
+##### `SettingsFlag` component
+
+Where possible, the `SettingsFlag` component should be used to set simple "flip-a-bit" (true/false) settings. The `SettingsFlag` also supports simple radio button options, such as the theme the user would like to use.
+```html
+
+```
+
+### Getting the display name for a setting
+
+Simply call `SettingsStore.getDisplayName`. The appropriate display name will be returned and automatically translated for you. If a display name cannot be found, it will return `null`.
+
+
+## Features
+
+Occasionally some parts of the application may be undergoing testing and are not quite production ready. These are commonly known to be behind a "labs flag". Features behind lab flags must go through the granular settings system, and look and act very much normal settings. The exception is that they must supply `isFeature: true` as part of the setting definition and should go through the helper functions on `SettingsStore`.
+
+### Determining if a feature is enabled
+
+A simple call to `SettingsStore.isFeatureEnabled` will tell you if the feature is enabled. This will perform all the required calculations to determine if the feature is enabled based upon the configuration and user selection.
+
+### Enabling a feature
+
+Features can only be enabled if the feature is in the `labs` state, otherwise this is a no-op. To find the current set of features in the `labs` state, call `SettingsStore.getLabsFeatures`. To set the value, call `SettingsStore.setFeatureEnabled`.
+
+
+## Setting controllers
+
+Settings may have environmental factors that affect their value or need additional code to be called when they are modified. A setting controller is able to override the calculated value for a setting and react to changes in that setting. Controllers are not a replacement for the level handlers and should only be used to ensure the environment is kept up to date with the setting where it is otherwise not possible. An example of this is the notification settings: they can only be considered enabled if the platform supports notifications, and enabling notifications requires additional steps to actually enable notifications.
+
+For more information, see `src/settings/controllers/SettingController.js`.
+
+
+## Local echo
+
+`SettingsStore` will perform local echo on all settings to ensure that immediately getting values does not cause a split-brain scenario. As mentioned in the "Setting values for a setting" section, the appropriate checks should be done to ensure that the user is allowed to set the value. The local echo system assumes that the user has permission and that the request will go through successfully. The local echo only takes effect until the request to save a setting has completed (either successfully or otherwise).
+
+```javascript
+SettingsStore.setValue(...).then(() => {
+ // The value has actually been stored at this point.
+});
+SettingsStore.getValue(...); // this will return the value set in `setValue` above.
+```
+
+
+
+# Maintainers Reference
+
+The granular settings system has a few complex parts to power it. This section is to document how the `SettingsStore` is supposed to work.
+
+### General information
+
+The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure. The array is checked from left to right, simulating the behaviour of overriding values from the higher levels. Each level should be defined in this array, including `default`.
+
+Handlers (`src/settings/handlers/SettingsHandler.js`) represent a single level and are responsible for getting and setting values at that level. Handlers also provide additional information to the `SettingsStore` such as if the level is supported or if the current user may set values at the level. The `SettingsStore` will use the handler to enforce checks and manipulate settings. Handlers are also responsible for dealing with migration patterns or legacy settings for their level (for example, a setting being renamed or using a different key from other settings in the underlying store). Handlers are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by only considering handlers that are supported on the platform.
+
+Local echo is achieved through `src/settings/handlers/LocalEchoWrapper.js` which acts as a wrapper around a given handler. This is automatically applied to all defined `LEVEL_HANDLERS` and proxies the calls to the wrapped handler where possible. The echo is achieved by a simple object cache stored within the class itself. The cache is invalidated immediately upon the proxied save call succeeding or failing.
+
+Controllers are notified of changes by the `SettingsStore`, and are given the opportunity to override values after the `SettingsStore` has deemed the value calculated. Controllers are invoked as the last possible step in the code.
+
+### Features
+
+Features automatically get considered as `disabled` if they are not listed in the `SdkConfig` or `enable_labs` is false/not set. Features are always checked against the configuration before going through the level order as they have the option of being forced-on or forced-off for the application. This is done by the `features` section and looks something like this:
+
+```
+"features": {
+ "feature_groups": "enable",
+ "feature_pinning": "disable", // the default
+ "feature_presence": "labs"
+}
+```
+
+If `enableLabs` is true in the configuration, the default for features becomes `"labs"`.
diff --git a/package.json b/package.json
index 5c81db2153..943c443c59 100644
--- a/package.json
+++ b/package.json
@@ -77,6 +77,8 @@
"querystring": "^0.2.0",
"react": "^15.4.0",
"react-addons-css-transition-group": "15.3.2",
+ "react-dnd": "^2.1.4",
+ "react-dnd-html5-backend": "^2.1.2",
"react-dom": "^15.4.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"sanitize-html": "^1.14.1",
diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js
index dd990b5210..fa9ccc8ed7 100755
--- a/scripts/gen-i18n.js
+++ b/scripts/gen-i18n.js
@@ -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}`);
diff --git a/src/CallHandler.js b/src/CallHandler.js
index dd9d93709f..fd56d7f1b1 100644
--- a/src/CallHandler.js
+++ b/src/CallHandler.js
@@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
+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.
@@ -58,6 +59,7 @@ import sdk from './index';
import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk';
import dis from './dispatcher';
+import { showUnknownDeviceDialogForCalls } from './cryptodevices';
global.mxCalls = {
//room_id: MatrixCall
@@ -97,19 +99,54 @@ function pause(audioId) {
}
}
+function _reAttemptCall(call) {
+ if (call.direction === 'outbound') {
+ dis.dispatch({
+ action: 'place_call',
+ room_id: call.roomId,
+ type: call.type,
+ });
+ } else {
+ call.answer();
+ }
+}
+
function _setCallListeners(call) {
call.on("error", function(err) {
console.error("Call error: %s", err);
console.error(err.stack);
- call.hangup();
- _setCallState(undefined, call.roomId, "ended");
- });
- call.on('send_event_error', function(err) {
- if (err.name === "UnknownDeviceError") {
- dis.dispatch({
- action: 'unknown_device_error',
- err: err,
- room: MatrixClientPeg.get().getRoom(call.roomId),
+ if (err.code === 'unknown_devices') {
+ const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+
+ Modal.createTrackedDialog('Call Failed', '', QuestionDialog, {
+ title: _t('Call Failed'),
+ description: _t(
+ "There are unknown devices in this room: "+
+ "if you proceed without verifying them, it will be "+
+ "possible for someone to eavesdrop on your call."
+ ),
+ button: _t('Review Devices'),
+ onFinished: function(confirmed) {
+ if (confirmed) {
+ const room = MatrixClientPeg.get().getRoom(call.roomId);
+ showUnknownDeviceDialogForCalls(
+ MatrixClientPeg.get(),
+ room,
+ () => {
+ _reAttemptCall(call);
+ },
+ call.direction === 'outbound' ? _t("Call Anyway") : _t("Answer Anyway"),
+ call.direction === 'outbound' ? _t("Call") : _t("Answer"),
+ );
+ }
+ },
+ });
+ } else {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+
+ Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
+ title: _t('Call Failed'),
+ description: err.message,
});
}
});
@@ -179,7 +216,6 @@ function _setCallState(call, roomId, status) {
function _onAction(payload) {
function placeCall(newCall) {
_setCallListeners(newCall);
- _setCallState(newCall, newCall.roomId, "ringback");
if (payload.type === 'voice') {
newCall.placeVoiceCall();
} else if (payload.type === 'video') {
diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js
index 839b496845..cdc5c61921 100644
--- a/src/CallMediaHandler.js
+++ b/src/CallMediaHandler.js
@@ -14,8 +14,8 @@
limitations under the License.
*/
-import UserSettingsStore from './UserSettingsStore';
import * as Matrix from 'matrix-js-sdk';
+import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
export default {
getDevices: function() {
@@ -43,22 +43,20 @@ export default {
},
loadDevices: function() {
- // this.getDevices().then((devices) => {
- const localSettings = UserSettingsStore.getLocalSettings();
- // // if deviceId is not found, automatic fallback is in spec
- // // recall previously stored inputs if any
- Matrix.setMatrixCallAudioInput(localSettings['webrtc_audioinput']);
- Matrix.setMatrixCallVideoInput(localSettings['webrtc_videoinput']);
- // });
+ const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
+ const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
+
+ Matrix.setMatrixCallAudioInput(audioDeviceId);
+ Matrix.setMatrixCallVideoInput(videoDeviceId);
},
setAudioInput: function(deviceId) {
- UserSettingsStore.setLocalSetting('webrtc_audioinput', deviceId);
+ SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioInput(deviceId);
},
setVideoInput: function(deviceId) {
- UserSettingsStore.setLocalSetting('webrtc_videoinput', deviceId);
+ SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallVideoInput(deviceId);
},
};
diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js
index 2fff3882b4..2757c5bd3d 100644
--- a/src/ComposerHistoryManager.js
+++ b/src/ComposerHistoryManager.js
@@ -61,7 +61,7 @@ export default class ComposerHistoryManager {
// TODO: Performance issues?
let item;
- for(; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
+ for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
this.history.push(
Object.assign(new HistoryItem(), JSON.parse(item)),
);
diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js
index c4b3744575..ef9010cbf2 100644
--- a/src/GroupAddressPicker.js
+++ b/src/GroupAddressPicker.js
@@ -114,7 +114,7 @@ function _onGroupInviteFinished(groupId, addrs) {
function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const matrixClient = MatrixClientPeg.get();
- const groupStore = GroupStoreCache.getGroupStore(matrixClient, groupId);
+ const groupStore = GroupStoreCache.getGroupStore(groupId);
const errorList = [];
return Promise.all(addrs.map((addr) => {
return groupStore
diff --git a/src/KeyCode.js b/src/Keyboard.js
similarity index 77%
rename from src/KeyCode.js
rename to src/Keyboard.js
index ec5595b71b..9c872e1c66 100644
--- a/src/KeyCode.js
+++ b/src/Keyboard.js
@@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
+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.
@@ -15,7 +16,7 @@ limitations under the License.
*/
/* a selection of key codes, as used in KeyboardEvent.keyCode */
-module.exports = {
+export const KeyCode = {
BACKSPACE: 8,
TAB: 9,
ENTER: 13,
@@ -58,3 +59,12 @@ module.exports = {
KEY_Y: 89,
KEY_Z: 90,
};
+
+export function isOnlyCtrlOrCmdKeyEvent(ev) {
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
+ if (isMac) {
+ return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
+ } else {
+ return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
+ }
+}
diff --git a/src/Lifecycle.js b/src/Lifecycle.js
index 4d8911f7a6..efd5c20d5c 100644
--- a/src/Lifecycle.js
+++ b/src/Lifecycle.js
@@ -389,6 +389,8 @@ function _persistCredentialsToLocalStorage(credentials) {
* Logs the current session out and transitions to the logged-out state
*/
export function logout() {
+ if (!MatrixClientPeg.get()) return;
+
if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session
@@ -436,6 +438,10 @@ function startMatrixClient() {
DMRoomMap.makeShared().start();
MatrixClientPeg.start();
+
+ // dispatch that we finished starting up to wire up any other bits
+ // of the matrix client that cannot be set prior to starting up.
+ dis.dispatch({action: 'client_started'});
}
/*
diff --git a/src/Login.js b/src/Login.js
index 55e996ce80..61a14959d8 100644
--- a/src/Login.js
+++ b/src/Login.js
@@ -204,6 +204,12 @@ export default class Login {
}
throw originalLoginError;
}).catch((error) => {
+ // We apparently squash case at login serverside these days:
+ // https://github.com/matrix-org/synapse/blob/1189be43a2479f5adf034613e8d10e3f4f452eb9/synapse/handlers/auth.py#L475
+ // so this wasn't needed after all. Keeping the code around in case the
+ // the situation changes...
+
+ /*
if (
error.httpStatus === 403 &&
loginParams.identifier.type === 'm.id.user' &&
@@ -211,6 +217,7 @@ export default class Login {
) {
return tryLowercaseUsername(originalLoginError);
}
+ */
throw originalLoginError;
}).catch((error) => {
console.log("Login failed", error);
diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js
index 6135a91dea..14dfa91fa4 100644
--- a/src/MatrixClientPeg.js
+++ b/src/MatrixClientPeg.js
@@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd.
+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.
@@ -21,6 +22,8 @@ import utils from 'matrix-js-sdk/lib/utils';
import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline';
import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set';
import createMatrixClient from './utils/createMatrixClient';
+import SettingsStore from './settings/SettingsStore';
+import MatrixActionCreators from './actions/MatrixActionCreators';
interface MatrixClientCreds {
homeserverUrl: string,
@@ -67,6 +70,8 @@ class MatrixClientPeg {
unset() {
this.matrixClient = null;
+
+ MatrixActionCreators.stop();
}
/**
@@ -84,7 +89,7 @@ class MatrixClientPeg {
if (this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto();
}
- } catch(e) {
+ } catch (e) {
// this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal.
console.warn("Unable to initialise e2e: " + e);
@@ -93,12 +98,13 @@ class MatrixClientPeg {
const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached";
+ opts.disablePresence = true; // we do this manually
try {
const promise = this.matrixClient.store.startup();
console.log(`MatrixClientPeg: waiting for MatrixClient store to initialise`);
await promise;
- } catch(err) {
+ } catch (err) {
// log any errors when starting up the database (if one exists)
console.error(`Error starting matrixclient store: ${err}`);
}
@@ -106,6 +112,9 @@ class MatrixClientPeg {
// regardless of errors, start the client. If we did error out, we'll
// just end up doing a full initial /sync.
+ // Connect the matrix client to the dispatcher
+ MatrixActionCreators.start(this.matrixClient);
+
console.log(`MatrixClientPeg: really starting MatrixClient`);
this.get().startClient(opts);
console.log(`MatrixClientPeg: MatrixClient started`);
@@ -136,9 +145,6 @@ class MatrixClientPeg {
}
_createClient(creds: MatrixClientCreds) {
- // XXX: This is here and as a require because apparently circular dependencies
- // are just broken in webpack (https://github.com/webpack/webpack/issues/1788)
- const UserSettingsStore = require('./UserSettingsStore');
const opts = {
baseUrl: creds.homeserverUrl,
idBaseUrl: creds.identityServerUrl,
@@ -146,7 +152,7 @@ class MatrixClientPeg {
userId: creds.userId,
deviceId: creds.deviceId,
timelineSupport: true,
- forceTURN: UserSettingsStore.getLocalSetting('webRtcForceTURN', false),
+ forceTURN: SettingsStore.getValue('webRtcForceTURN', false),
};
this.matrixClient = createMatrixClient(opts, this.indexedDbWorkerScript);
diff --git a/src/Notifier.js b/src/Notifier.js
index 93ef192fe0..75b698862c 100644
--- a/src/Notifier.js
+++ b/src/Notifier.js
@@ -25,6 +25,7 @@ import dis from './dispatcher';
import sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
+import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
/*
* Dispatches:
@@ -138,10 +139,8 @@ const Notifier = {
// make sure that we persist the current setting audio_enabled setting
// before changing anything
- if (global.localStorage) {
- if (global.localStorage.getItem('audio_notifications_enabled') === null) {
- this.setAudioEnabled(this.isEnabled());
- }
+ if (SettingsStore.isLevelSupported(SettingLevel.DEVICE)) {
+ SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, this.isEnabled());
}
if (enable) {
@@ -149,6 +148,7 @@ const Notifier = {
plaf.requestNotificationPermission().done((result) => {
if (result !== 'granted') {
// The permission request was dismissed or denied
+ // TODO: Support alternative branding in messaging
const description = result === 'denied'
? _t('Riot does not have permission to send you notifications - please check your browser settings')
: _t('Riot was not given permission to send notifications - please try again');
@@ -160,10 +160,6 @@ const Notifier = {
return;
}
- if (global.localStorage) {
- global.localStorage.setItem('notifications_enabled', 'true');
- }
-
if (callback) callback();
dis.dispatch({
action: "notifier_enabled",
@@ -174,8 +170,6 @@ const Notifier = {
// disabled again in the future, we will show the banner again.
this.setToolbarHidden(false);
} else {
- if (!global.localStorage) return;
- global.localStorage.setItem('notifications_enabled', 'false');
dis.dispatch({
action: "notifier_enabled",
value: false,
@@ -184,44 +178,24 @@ const Notifier = {
},
isEnabled: function() {
+ return this.isPossible() && SettingsStore.getValue("notificationsEnabled");
+ },
+
+ isPossible: function() {
const plaf = PlatformPeg.get();
if (!plaf) return false;
if (!plaf.supportsNotifications()) return false;
if (!plaf.maySendNotifications()) return false;
- if (!global.localStorage) return true;
-
- const enabled = global.localStorage.getItem('notifications_enabled');
- if (enabled === null) return true;
- return enabled === 'true';
- },
-
- setBodyEnabled: function(enable) {
- if (!global.localStorage) return;
- global.localStorage.setItem('notifications_body_enabled', enable ? 'true' : 'false');
+ return true; // possible, but not necessarily enabled
},
isBodyEnabled: function() {
- if (!global.localStorage) return true;
- const enabled = global.localStorage.getItem('notifications_body_enabled');
- // default to true if the popups are enabled
- if (enabled === null) return this.isEnabled();
- return enabled === 'true';
+ return this.isEnabled() && SettingsStore.getValue("notificationBodyEnabled");
},
- setAudioEnabled: function(enable) {
- if (!global.localStorage) return;
- global.localStorage.setItem('audio_notifications_enabled',
- enable ? 'true' : 'false');
- },
-
- isAudioEnabled: function(enable) {
- if (!global.localStorage) return true;
- const enabled = global.localStorage.getItem(
- 'audio_notifications_enabled');
- // default to true if the popups are enabled
- if (enabled === null) return this.isEnabled();
- return enabled === 'true';
+ isAudioEnabled: function() {
+ return this.isEnabled() && SettingsStore.getValue("audioNotificationsEnabled");
},
setToolbarHidden: function(hidden, persistent = true) {
@@ -238,16 +212,14 @@ const Notifier = {
// update the info to localStorage for persistent settings
if (persistent && global.localStorage) {
- global.localStorage.setItem('notifications_hidden', hidden);
+ global.localStorage.setItem("notifications_hidden", hidden);
}
},
isToolbarHidden: function() {
// Check localStorage for any such meta data
if (global.localStorage) {
- if (global.localStorage.getItem('notifications_hidden') === 'true') {
- return true;
- }
+ return global.localStorage.getItem("notifications_hidden") === "true";
}
return this.toolbarHidden;
diff --git a/src/Presence.js b/src/Presence.js
index fab518e1cb..2652c64c96 100644
--- a/src/Presence.js
+++ b/src/Presence.js
@@ -56,13 +56,27 @@ class Presence {
return this.state;
}
+ /**
+ * Get the current status message.
+ * @returns {String} the status message, may be null
+ */
+ getStatusMessage() {
+ return this.statusMessage;
+ }
+
/**
* Set the presence state.
* If the state has changed, the Home Server will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
+ * @param {String} statusMessage an optional status message for the presence
+ * @param {boolean} maintain true to have this status maintained by this tracker
*/
- setState(newState) {
- if (newState === this.state) {
+ setState(newState, statusMessage=null, maintain=false) {
+ if (this.maintain) {
+ // Don't update presence if we're maintaining a particular status
+ return;
+ }
+ if (newState === this.state && statusMessage === this.statusMessage) {
return;
}
if (PRESENCE_STATES.indexOf(newState) === -1) {
@@ -72,21 +86,37 @@ class Presence {
return;
}
const old_state = this.state;
+ const old_message = this.statusMessage;
this.state = newState;
+ this.statusMessage = statusMessage;
+ this.maintain = maintain;
if (MatrixClientPeg.get().isGuest()) {
return; // don't try to set presence when a guest; it won't work.
}
+ const updateContent = {
+ presence: this.state,
+ status_msg: this.statusMessage ? this.statusMessage : '',
+ };
+
const self = this;
- MatrixClientPeg.get().setPresence(this.state).done(function() {
+ MatrixClientPeg.get().setPresence(updateContent).done(function() {
console.log("Presence: %s", newState);
+
+ // We have to dispatch because the js-sdk is unreliable at telling us about our own presence
+ dis.dispatch({action: "self_presence_updated", statusInfo: updateContent});
}, function(err) {
console.error("Failed to set presence: %s", err);
self.state = old_state;
+ self.statusMessage = old_message;
});
}
+ stopMaintainingStatus() {
+ this.maintain = false;
+ }
+
/**
* Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
* @private
@@ -95,7 +125,8 @@ class Presence {
this.setState("unavailable");
}
- _onUserActivity() {
+ _onUserActivity(payload) {
+ if (payload.action === "sync_state" || payload.action === "self_presence_updated") return;
this._resetTimer();
}
diff --git a/src/Resend.js b/src/Resend.js
index 1fee5854ea..4eaee16d1b 100644
--- a/src/Resend.js
+++ b/src/Resend.js
@@ -44,13 +44,6 @@ module.exports = {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('Resend got send failure: ' + err.name + '('+err+')');
- if (err.name === "UnknownDeviceError") {
- dis.dispatch({
- action: 'unknown_device_error',
- err: err,
- room: room,
- });
- }
dis.dispatch({
action: 'message_send_failed',
@@ -60,9 +53,5 @@ module.exports = {
},
removeFromQueue: function(event) {
MatrixClientPeg.get().cancelPendingEvent(event);
- dis.dispatch({
- action: 'message_send_cancelled',
- event: event,
- });
},
};
diff --git a/src/RichText.js b/src/RichText.js
index b61ba0b9a4..12274ee9f3 100644
--- a/src/RichText.js
+++ b/src/RichText.js
@@ -68,7 +68,7 @@ function unicodeToEmojiUri(str) {
return unicodeChar;
} else {
// Remove variant selector VS16 (explicitly emoji) as it is unnecessary and leads to an incorrect URL below
- if(unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') {
+ if (unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') {
unicodeChar = unicodeChar[0];
}
diff --git a/src/Roles.js b/src/Roles.js
index 83d8192c67..438b6c1236 100644
--- a/src/Roles.js
+++ b/src/Roles.js
@@ -15,19 +15,20 @@ limitations under the License.
*/
import { _t } from './languageHandler';
-export function levelRoleMap() {
+export function levelRoleMap(usersDefault) {
return {
undefined: _t('Default'),
- 0: _t('User'),
+ 0: _t('Restricted'),
+ [usersDefault]: _t('Default'),
50: _t('Moderator'),
100: _t('Admin'),
};
}
-export function textualPowerLevel(level, userDefault) {
- const LEVEL_ROLE_MAP = this.levelRoleMap();
+export function textualPowerLevel(level, usersDefault) {
+ const LEVEL_ROLE_MAP = this.levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) {
- return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${userDefault})`);
+ return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`);
} else {
return level;
}
diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js
index c9d056f88e..7bd8603264 100644
--- a/src/ScalarAuthClient.js
+++ b/src/ScalarAuthClient.js
@@ -15,6 +15,7 @@ limitations under the License.
*/
import Promise from 'bluebird';
+import SettingsStore from "./settings/SettingsStore";
const request = require('browser-request');
const SdkConfig = require('./SdkConfig');
@@ -76,10 +77,40 @@ class ScalarAuthClient {
return defer.promise;
}
+ getScalarPageTitle(url) {
+ const defer = Promise.defer();
+
+ let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup';
+ scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
+ scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
+ request({
+ method: 'GET',
+ uri: scalarPageLookupUrl,
+ json: true,
+ }, (err, response, body) => {
+ if (err) {
+ defer.reject(err);
+ } else if (response.statusCode / 100 !== 2) {
+ defer.reject({statusCode: response.statusCode});
+ } else if (!body) {
+ defer.reject(new Error("Missing page title in response"));
+ } else {
+ let title = "";
+ if (body.page_title_cache_item && body.page_title_cache_item.cached_title) {
+ title = body.page_title_cache_item.cached_title;
+ }
+ defer.resolve(title);
+ }
+ });
+
+ return defer.promise;
+ }
+
getScalarInterfaceUrlForRoom(roomId, screen, id) {
let url = SdkConfig.get().integrations_ui_url;
url += "?scalar_token=" + encodeURIComponent(this.scalarToken);
url += "&room_id=" + encodeURIComponent(roomId);
+ url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme"));
if (id) {
url += '&integ_id=' + encodeURIComponent(id);
}
diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js
index 7698829647..3c164c6551 100644
--- a/src/ScalarMessaging.js
+++ b/src/ScalarMessaging.js
@@ -366,6 +366,22 @@ function getWidgets(event, roomId) {
sendResponse(event, widgetStateEvents);
}
+function getRoomEncState(event, roomId) {
+ const client = MatrixClientPeg.get();
+ if (!client) {
+ sendError(event, _t('You need to be logged in.'));
+ return;
+ }
+ const room = client.getRoom(roomId);
+ if (!room) {
+ sendError(event, _t('This room is not recognised.'));
+ return;
+ }
+ const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
+
+ sendResponse(event, roomIsEncrypted);
+}
+
function setPlumbingState(event, roomId, status) {
if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string');
@@ -541,8 +557,16 @@ const onMessage = function(event) {
//
// All strings start with the empty string, so for sanity return if the length
// of the event origin is 0.
+ //
+ // TODO -- Scalar postMessage API should be namespaced with event.data.api field
+ // Fix following "if" statement to respond only to specific API messages.
const url = SdkConfig.get().integrations_ui_url;
- if (event.origin.length === 0 || !url.startsWith(event.origin) || !event.data.action) {
+ if (
+ event.origin.length === 0 ||
+ !url.startsWith(event.origin) ||
+ !event.data.action ||
+ event.data.api // Ignore messages with specific API set
+ ) {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
}
@@ -593,6 +617,9 @@ const onMessage = function(event) {
} else if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
+ } else if (event.data.action === "get_room_enc_state") {
+ getRoomEncState(event, roomId);
+ return;
} else if (event.data.action === "can_send_event") {
canSendEvent(event, roomId);
return;
diff --git a/src/SdkConfig.js b/src/SdkConfig.js
index 48ebf011f2..8df725a913 100644
--- a/src/SdkConfig.js
+++ b/src/SdkConfig.js
@@ -26,7 +26,7 @@ const DEFAULTS = {
class SdkConfig {
static get() {
- return global.mxReactSdkConfig;
+ return global.mxReactSdkConfig || {};
}
static put(cfg) {
diff --git a/src/SlashCommands.js b/src/SlashCommands.js
index 82665cc2f3..344bac1ddb 100644
--- a/src/SlashCommands.js
+++ b/src/SlashCommands.js
@@ -20,6 +20,7 @@ import Tinter from "./Tinter";
import sdk from './index';
import { _t } from './languageHandler';
import Modal from './Modal';
+import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
class Command {
@@ -97,9 +98,7 @@ const commands = {
colorScheme.secondary_color = matches[4];
}
return success(
- MatrixClientPeg.get().setRoomAccountData(
- roomId, "org.matrix.room.color_scheme", colorScheme,
- ),
+ SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
);
}
}
diff --git a/src/TextForEvent.js b/src/TextForEvent.js
index 51e3eb8dc9..1bdf5ad90c 100644
--- a/src/TextForEvent.js
+++ b/src/TextForEvent.js
@@ -151,9 +151,9 @@ function textForCallHangupEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent();
let reason = "";
- if(!MatrixClientPeg.get().supportsVoip()) {
+ if (!MatrixClientPeg.get().supportsVoip()) {
reason = _t('(not supported by this browser)');
- } else if(eventContent.reason) {
+ } else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") {
reason = _t('(could not connect media)');
} else if (eventContent.reason === "invite_timeout") {
diff --git a/src/Tinter.js b/src/Tinter.js
index 6b23df8c9b..c7402c15be 100644
--- a/src/Tinter.js
+++ b/src/Tinter.js
@@ -1,5 +1,6 @@
/*
Copyright 2015 OpenMarket Ltd
+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.
@@ -14,148 +15,125 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-// FIXME: these vars should be bundled up and attached to
-// module.exports otherwise this will break when included by both
-// react-sdk and apps layered on top.
-
const DEBUG = 0;
-// The colour keys to be replaced as referred to in CSS
-const keyRgb = [
- "rgb(118, 207, 166)", // Vector Green
- "rgb(234, 245, 240)", // Vector Light Green
- "rgb(211, 239, 225)", // BottomLeftMenu overlay (20% Vector Green)
-];
+// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue]
+function colorToRgb(color) {
+ if (!color) {
+ return [0, 0, 0];
+ }
-// Some algebra workings for calculating the tint % of Vector Green & Light Green
-// x * 118 + (1 - x) * 255 = 234
-// x * 118 + 255 - 255 * x = 234
-// x * 118 - x * 255 = 234 - 255
-// (255 - 118) x = 255 - 234
-// x = (255 - 234) / (255 - 118) = 0.16
-
-// The colour keys to be replaced as referred to in SVGs
-const keyHex = [
- "#76CFA6", // Vector Green
- "#EAF5F0", // Vector Light Green
- "#D3EFE1", // BottomLeftMenu overlay (20% Vector Green overlaid on Vector Light Green)
- "#FFFFFF", // white highlights of the SVGs (for switching to dark theme)
-];
-
-// cache of our replacement colours
-// defaults to our keys.
-const colors = [
- keyHex[0],
- keyHex[1],
- keyHex[2],
- keyHex[3],
-];
-
-const cssFixups = [
- // {
- // style: a style object that should be fixed up taken from a stylesheet
- // attr: name of the attribute to be clobbered, e.g. 'color'
- // index: ordinal of primary, secondary or tertiary
- // }
-];
-
-// CSS attributes to be fixed up
-const cssAttrs = [
- "color",
- "backgroundColor",
- "borderColor",
- "borderTopColor",
- "borderBottomColor",
- "borderLeftColor",
-];
-
-const svgAttrs = [
- "fill",
- "stroke",
-];
-
-let cached = false;
-
-function calcCssFixups() {
- if (DEBUG) console.log("calcSvgFixups start");
- for (let i = 0; i < document.styleSheets.length; i++) {
- const ss = document.styleSheets[i];
- if (!ss) continue; // well done safari >:(
- // Chromium apparently sometimes returns null here; unsure why.
- // see $14534907369972FRXBx:matrix.org in HQ
- // ...ah, it's because there's a third party extension like
- // privacybadger inserting its own stylesheet in there with a
- // resource:// URI or something which results in a XSS error.
- // See also #vector:matrix.org/$145357669685386ebCfr:matrix.org
- // ...except some browsers apparently return stylesheets without
- // hrefs, which we have no choice but ignore right now
-
- // XXX seriously? we are hardcoding the name of vector's CSS file in
- // here?
- //
- // Why do we need to limit it to vector's CSS file anyway - if there
- // are other CSS files affecting the doc don't we want to apply the
- // same transformations to them?
- //
- // Iterating through the CSS looking for matches to hack on feels
- // pretty horrible anyway. And what if the application skin doesn't use
- // Vector Green as its primary color?
-
- if (ss.href && !ss.href.match(/\/bundle.*\.css$/)) continue;
-
- if (!ss.cssRules) continue;
- for (let j = 0; j < ss.cssRules.length; j++) {
- const rule = ss.cssRules[j];
- if (!rule.style) continue;
- for (let k = 0; k < cssAttrs.length; k++) {
- const attr = cssAttrs[k];
- for (let l = 0; l < keyRgb.length; l++) {
- if (rule.style[attr] === keyRgb[l]) {
- cssFixups.push({
- style: rule.style,
- attr: attr,
- index: l,
- });
- }
- }
- }
+ if (color[0] === '#') {
+ color = color.slice(1);
+ if (color.length === 3) {
+ color = color[0] + color[0] +
+ color[1] + color[1] +
+ color[2] + color[2];
+ }
+ const val = parseInt(color, 16);
+ const r = (val >> 16) & 255;
+ const g = (val >> 8) & 255;
+ const b = val & 255;
+ return [r, g, b];
+ } else {
+ const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/);
+ if (match) {
+ return [
+ parseInt(match[1]),
+ parseInt(match[2]),
+ parseInt(match[3]),
+ ];
}
}
- if (DEBUG) console.log("calcSvgFixups end");
+ return [0, 0, 0];
}
-function applyCssFixups() {
- if (DEBUG) console.log("applyCssFixups start");
- for (let i = 0; i < cssFixups.length; i++) {
- const cssFixup = cssFixups[i];
- cssFixup.style[cssFixup.attr] = colors[cssFixup.index];
- }
- if (DEBUG) console.log("applyCssFixups end");
-}
-
-function hexToRgb(color) {
- if (color[0] === '#') color = color.slice(1);
- if (color.length === 3) {
- color = color[0] + color[0] +
- color[1] + color[1] +
- color[2] + color[2];
- }
- const val = parseInt(color, 16);
- const r = (val >> 16) & 255;
- const g = (val >> 8) & 255;
- const b = val & 255;
- return [r, g, b];
-}
-
-function rgbToHex(rgb) {
+// utility to turn [red,green,blue] into #rrggbb
+function rgbToColor(rgb) {
const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
return '#' + (0x1000000 + val).toString(16).slice(1);
}
-// List of functions to call when the tint changes.
-const tintables = [];
+class Tinter {
+ constructor() {
+ // The default colour keys to be replaced as referred to in CSS
+ // (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor)
+ this.keyRgb = [
+ "rgb(118, 207, 166)", // Vector Green
+ "rgb(234, 245, 240)", // Vector Light Green
+ "rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
+ ];
+
+ // Some algebra workings for calculating the tint % of Vector Green & Light Green
+ // x * 118 + (1 - x) * 255 = 234
+ // x * 118 + 255 - 255 * x = 234
+ // x * 118 - x * 255 = 234 - 255
+ // (255 - 118) x = 255 - 234
+ // x = (255 - 234) / (255 - 118) = 0.16
+
+ // The colour keys to be replaced as referred to in SVGs
+ this.keyHex = [
+ "#76CFA6", // Vector Green
+ "#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
+ // defaults to our keys.
+ this.colors = [
+ this.keyHex[0],
+ this.keyHex[1],
+ this.keyHex[2],
+ this.keyHex[3],
+ this.keyHex[4],
+ ];
+
+ // track the most current tint request inputs (which may differ from the
+ // end result stored in this.colors
+ this.currentTint = [
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ ];
+
+ this.cssFixups = [
+ // { theme: {
+ // style: a style object that should be fixed up taken from a stylesheet
+ // attr: name of the attribute to be clobbered, e.g. 'color'
+ // index: ordinal of primary, secondary or tertiary
+ // },
+ // }
+ ];
+
+ // CSS attributes to be fixed up
+ this.cssAttrs = [
+ "color",
+ "backgroundColor",
+ "borderColor",
+ "borderTopColor",
+ "borderBottomColor",
+ "borderLeftColor",
+ ];
+
+ this.svgAttrs = [
+ "fill",
+ "stroke",
+ ];
+
+ // List of functions to call when the tint changes.
+ this.tintables = [];
+
+ // the currently loaded theme (if any)
+ this.theme = undefined;
+
+ // whether to force a tint (e.g. after changing theme)
+ this.forceTint = false;
+ }
-module.exports = {
/**
* Register a callback to fire when the tint changes.
* This is used to rewrite the tintable SVGs with the new tint.
@@ -167,79 +145,243 @@ module.exports = {
*
* @param {Function} tintable Function to call when the tint changes.
*/
- registerTintable: function(tintable) {
- tintables.push(tintable);
- },
+ registerTintable(tintable) {
+ this.tintables.push(tintable);
+ }
- tint: function(primaryColor, secondaryColor, tertiaryColor) {
- if (!cached) {
- calcCssFixups();
- cached = true;
+ getKeyRgb() {
+ return this.keyRgb;
+ }
+
+ tint(primaryColor, secondaryColor, tertiaryColor) {
+ this.currentTint[0] = primaryColor;
+ this.currentTint[1] = secondaryColor;
+ this.currentTint[2] = tertiaryColor;
+
+ this.calcCssFixups();
+
+ if (DEBUG) {
+ console.log("Tinter.tint(" + primaryColor + ", " +
+ secondaryColor + ", " +
+ tertiaryColor + ")");
}
if (!primaryColor) {
- primaryColor = "#76CFA6"; // Vector green
- secondaryColor = "#EAF5F0"; // Vector light green
+ primaryColor = this.keyRgb[0];
+ secondaryColor = this.keyRgb[1];
+ tertiaryColor = this.keyRgb[2];
}
if (!secondaryColor) {
const x = 0.16; // average weighting factor calculated from vector green & light green
- const rgb = hexToRgb(primaryColor);
+ const rgb = colorToRgb(primaryColor);
rgb[0] = x * rgb[0] + (1 - x) * 255;
rgb[1] = x * rgb[1] + (1 - x) * 255;
rgb[2] = x * rgb[2] + (1 - x) * 255;
- secondaryColor = rgbToHex(rgb);
+ secondaryColor = rgbToColor(rgb);
}
if (!tertiaryColor) {
const x = 0.19;
- const rgb1 = hexToRgb(primaryColor);
- const rgb2 = hexToRgb(secondaryColor);
+ const rgb1 = colorToRgb(primaryColor);
+ const rgb2 = colorToRgb(secondaryColor);
rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0];
rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1];
rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2];
- tertiaryColor = rgbToHex(rgb1);
+ tertiaryColor = rgbToColor(rgb1);
}
- if (colors[0] === primaryColor &&
- colors[1] === secondaryColor &&
- colors[2] === tertiaryColor) {
+ if (this.forceTint == false &&
+ this.colors[0] === primaryColor &&
+ this.colors[1] === secondaryColor &&
+ this.colors[2] === tertiaryColor) {
return;
}
- colors[0] = primaryColor;
- colors[1] = secondaryColor;
- colors[2] = tertiaryColor;
+ this.forceTint = false;
- if (DEBUG) console.log("Tinter.tint");
+ this.colors[0] = primaryColor;
+ this.colors[1] = secondaryColor;
+ this.colors[2] = tertiaryColor;
+
+ if (DEBUG) {
+ console.log("Tinter.tint final: (" + primaryColor + ", " +
+ secondaryColor + ", " +
+ tertiaryColor + ")");
+ }
// go through manually fixing up the stylesheets.
- applyCssFixups();
+ this.applyCssFixups();
// tell all the SVGs to go fix themselves up
// we don't do this as a dispatch otherwise it will visually lag
- tintables.forEach(function(tintable) {
+ this.tintables.forEach(function(tintable) {
tintable();
});
- },
+ }
+
+ tintSvgWhite(whiteColor) {
+ this.currentTint[3] = whiteColor;
- tintSvgWhite: function(whiteColor) {
if (!whiteColor) {
- whiteColor = colors[3];
+ whiteColor = this.colors[3];
}
- if (colors[3] === whiteColor) {
+ if (this.colors[3] === whiteColor) {
return;
}
- colors[3] = whiteColor;
- tintables.forEach(function(tintable) {
+ this.colors[3] = whiteColor;
+ this.tintables.forEach(function(tintable) {
tintable();
});
- },
+ }
+
+ 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;
+
+ // 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;
+ }
+ if (document.getElementById('mx_theme_secondaryAccentColor')) {
+ this.keyRgb[1] = window.getComputedStyle(
+ document.getElementById('mx_theme_secondaryAccentColor')).color;
+ }
+ if (document.getElementById('mx_theme_tertiaryAccentColor')) {
+ this.keyRgb[2] = window.getComputedStyle(
+ document.getElementById('mx_theme_tertiaryAccentColor')).color;
+ }
+
+ this.calcCssFixups();
+ this.forceTint = true;
+
+ this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]);
+
+ if (theme === 'dark') {
+ // 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');
+ }
+ }
+
+ calcCssFixups() {
+ // cache our fixups
+ if (this.cssFixups[this.theme]) return;
+
+ if (DEBUG) {
+ console.debug("calcCssFixups start for " + this.theme + " (checking " +
+ document.styleSheets.length +
+ " stylesheets)");
+ }
+
+ this.cssFixups[this.theme] = [];
+
+ for (let i = 0; i < document.styleSheets.length; i++) {
+ const ss = document.styleSheets[i];
+ if (!ss) continue; // well done safari >:(
+ // Chromium apparently sometimes returns null here; unsure why.
+ // see $14534907369972FRXBx:matrix.org in HQ
+ // ...ah, it's because there's a third party extension like
+ // privacybadger inserting its own stylesheet in there with a
+ // resource:// URI or something which results in a XSS error.
+ // See also #vector:matrix.org/$145357669685386ebCfr:matrix.org
+ // ...except some browsers apparently return stylesheets without
+ // hrefs, which we have no choice but ignore right now
+
+ // XXX seriously? we are hardcoding the name of vector's CSS file in
+ // here?
+ //
+ // Why do we need to limit it to vector's CSS file anyway - if there
+ // are other CSS files affecting the doc don't we want to apply the
+ // same transformations to them?
+ //
+ // Iterating through the CSS looking for matches to hack on feels
+ // pretty horrible anyway. And what if the application skin doesn't use
+ // Vector Green as its primary color?
+ // --richvdh
+
+ // Yes, tinting assumes that you are using the Riot skin for now.
+ // The right solution will be to move the CSS over to react-sdk.
+ // And yes, the default assets for the base skin might as well use
+ // Vector Green as any other colour.
+ // --matthew
+
+ if (ss.href && !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue;
+ if (ss.disabled) continue;
+ if (!ss.cssRules) continue;
+
+ if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href);
+
+ for (let j = 0; j < ss.cssRules.length; j++) {
+ const rule = ss.cssRules[j];
+ if (!rule.style) continue;
+ if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue;
+ for (let k = 0; k < this.cssAttrs.length; k++) {
+ const attr = this.cssAttrs[k];
+ for (let l = 0; l < this.keyRgb.length; l++) {
+ if (rule.style[attr] === this.keyRgb[l]) {
+ this.cssFixups[this.theme].push({
+ style: rule.style,
+ attr: attr,
+ index: l,
+ });
+ }
+ }
+ }
+ }
+ }
+ if (DEBUG) {
+ console.log("calcCssFixups end (" +
+ this.cssFixups[this.theme].length +
+ " fixups)");
+ }
+ }
+
+ applyCssFixups() {
+ 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) {
+ // 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.
+ console.error("Failed to apply cssFixup in Tinter! ", e.name);
+ }
+ }
+ if (DEBUG) console.log("applyCssFixups end");
+ }
// XXX: we could just move this all into TintableSvg, but as it's so similar
// to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg)
// keeping it here for now.
- calcSvgFixups: function(svgs) {
+ calcSvgFixups(svgs) {
// go through manually fixing up SVG colours.
// we could do this by stylesheets, but keeping the stylesheets
// updated would be a PITA, so just brute-force search for the
@@ -248,10 +390,10 @@ module.exports = {
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) {
+ } catch (e) {
let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString();
if (e.message) {
msg += e.message;
@@ -259,16 +401,17 @@ module.exports = {
if (e.stack) {
msg += ' | stack: ' + e.stack;
}
- console.error(e);
+ console.error(msg);
}
if (!svgDoc) continue;
const tags = svgDoc.getElementsByTagName("*");
for (let j = 0; j < tags.length; j++) {
const tag = tags[j];
- for (let k = 0; k < svgAttrs.length; k++) {
- const attr = svgAttrs[k];
- for (let l = 0; l < keyHex.length; l++) {
- if (tag.getAttribute(attr) && tag.getAttribute(attr).toUpperCase() === keyHex[l]) {
+ for (let k = 0; k < this.svgAttrs.length; k++) {
+ 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]) {
fixups.push({
node: tag,
attr: attr,
@@ -282,14 +425,19 @@ module.exports = {
if (DEBUG) console.log("calcSvgFixups end");
return fixups;
- },
+ }
- applySvgFixups: function(fixups) {
+ applySvgFixups(fixups) {
if (DEBUG) console.log("applySvgFixups start for " + fixups);
for (let i = 0; i < fixups.length; i++) {
const svgFixup = fixups[i];
- svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]);
+ svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]);
}
if (DEBUG) console.log("applySvgFixups end");
- },
-};
+ }
+}
+
+if (global.singletonTinter === undefined) {
+ global.singletonTinter = new Tinter();
+}
+export default global.singletonTinter;
diff --git a/src/UnknownDeviceErrorHandler.js b/src/UnknownDeviceErrorHandler.js
deleted file mode 100644
index e7d77b3b66..0000000000
--- a/src/UnknownDeviceErrorHandler.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
-Copyright 2017 Vector Creations 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 dis from './dispatcher';
-import sdk from './index';
-import Modal from './Modal';
-
-let isDialogOpen = false;
-
-const onAction = function(payload) {
- if (payload.action === 'unknown_device_error' && !isDialogOpen) {
- const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
- isDialogOpen = true;
- Modal.createTrackedDialog('Unknown Device Error', '', UnknownDeviceDialog, {
- devices: payload.err.devices,
- room: payload.room,
- onFinished: (r) => {
- isDialogOpen = false;
- // XXX: temporary logging to try to diagnose
- // https://github.com/vector-im/riot-web/issues/3148
- console.log('UnknownDeviceDialog closed with '+r);
- },
- }, 'mx_Dialog_unknownDevice');
- }
-};
-
-let ref = null;
-
-export function startListening() {
- ref = dis.register(onAction);
-}
-
-export function stopListening() {
- if (ref) {
- dis.unregister(ref);
- ref = null;
- }
-}
diff --git a/src/Unread.js b/src/Unread.js
index 20e876ad88..383b5c2e5a 100644
--- a/src/Unread.js
+++ b/src/Unread.js
@@ -15,7 +15,6 @@ limitations under the License.
*/
const MatrixClientPeg = require('./MatrixClientPeg');
-import UserSettingsStore from './UserSettingsStore';
import shouldHideEvent from './shouldHideEvent';
const sdk = require('./index');
@@ -64,7 +63,6 @@ module.exports = {
// we have and the read receipt. We could fetch more history to try & find out,
// but currently we just guess.
- const syncedSettings = UserSettingsStore.getSyncedSettings();
// Loop through messages, starting with the most recent...
for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i];
@@ -74,7 +72,7 @@ module.exports = {
// that counts and we can stop looking because the user's read
// this and everything before.
return false;
- } else if (!shouldHideEvent(ev, syncedSettings) && this.eventTriggersUnreadCount(ev)) {
+ } else if (!shouldHideEvent(ev) && this.eventTriggersUnreadCount(ev)) {
// We've found a message that counts before we hit
// the read marker, so this room is definitely unread.
return true;
diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js
index 34f9a28d57..5d2af3715f 100644
--- a/src/UserSettingsStore.js
+++ b/src/UserSettingsStore.js
@@ -17,54 +17,11 @@ limitations under the License.
import Promise from 'bluebird';
import MatrixClientPeg from './MatrixClientPeg';
-import Notifier from './Notifier';
-import { _t, _td } from './languageHandler';
-import SdkConfig from './SdkConfig';
/*
* TODO: Make things use this. This is all WIP - see UserSettings.js for usage.
*/
-
-const FEATURES = [
- {
- id: 'feature_pinning',
- name: _td("Message Pinning"),
- },
-];
-
export default {
- getLabsFeatures() {
- const featuresConfig = SdkConfig.get()['features'] || {};
-
- // The old flag: honoured for backwards compatibility
- const enableLabs = SdkConfig.get()['enableLabs'];
-
- let labsFeatures;
- if (enableLabs) {
- labsFeatures = FEATURES;
- } else {
- labsFeatures = FEATURES.filter((f) => {
- const sdkConfigValue = featuresConfig[f.id];
- if (sdkConfigValue === 'labs') {
- return true;
- }
- });
- }
- return labsFeatures.map((f) => {
- return f.id;
- });
- },
-
- translatedNameForFeature(featureId) {
- const feature = FEATURES.filter((f) => {
- return f.id === featureId;
- })[0];
-
- if (feature === undefined) return null;
-
- return _t(feature.name);
- },
-
loadProfileInfo: function() {
const cli = MatrixClientPeg.get();
return cli.getProfileInfo(cli.credentials.userId);
@@ -87,36 +44,6 @@ export default {
// TODO
},
- getEnableNotifications: function() {
- return Notifier.isEnabled();
- },
-
- setEnableNotifications: function(enable) {
- if (!Notifier.supportsDesktopNotifications()) {
- return;
- }
- Notifier.setEnabled(enable);
- },
-
- getEnableNotificationBody: function() {
- return Notifier.isBodyEnabled();
- },
-
- setEnableNotificationBody: function(enable) {
- if (!Notifier.supportsDesktopNotifications()) {
- return;
- }
- Notifier.setBodyEnabled(enable);
- },
-
- getEnableAudioNotifications: function() {
- return Notifier.isAudioEnabled();
- },
-
- setEnableAudioNotifications: function(enable) {
- Notifier.setAudioEnabled(enable);
- },
-
changePassword: function(oldPassword, newPassword) {
const cli = MatrixClientPeg.get();
@@ -163,83 +90,4 @@ export default {
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
});
},
-
- getUrlPreviewsDisabled: function() {
- const event = MatrixClientPeg.get().getAccountData('org.matrix.preview_urls');
- return (event && event.getContent().disable);
- },
-
- setUrlPreviewsDisabled: function(disabled) {
- // FIXME: handle errors
- return MatrixClientPeg.get().setAccountData('org.matrix.preview_urls', {
- disable: disabled,
- });
- },
-
- getSyncedSettings: function() {
- const event = MatrixClientPeg.get().getAccountData('im.vector.web.settings');
- return event ? event.getContent() : {};
- },
-
- getSyncedSetting: function(type, defaultValue = null) {
- const settings = this.getSyncedSettings();
- return settings.hasOwnProperty(type) ? settings[type] : defaultValue;
- },
-
- setSyncedSetting: function(type, value) {
- const settings = this.getSyncedSettings();
- settings[type] = value;
- // FIXME: handle errors
- return MatrixClientPeg.get().setAccountData('im.vector.web.settings', settings);
- },
-
- getLocalSettings: function() {
- const localSettingsString = localStorage.getItem('mx_local_settings') || '{}';
- return JSON.parse(localSettingsString);
- },
-
- getLocalSetting: function(type, defaultValue = null) {
- const settings = this.getLocalSettings();
- return settings.hasOwnProperty(type) ? settings[type] : defaultValue;
- },
-
- setLocalSetting: function(type, value) {
- const settings = this.getLocalSettings();
- settings[type] = value;
- // FIXME: handle errors
- localStorage.setItem('mx_local_settings', JSON.stringify(settings));
- },
-
- isFeatureEnabled: function(featureId: string): boolean {
- const featuresConfig = SdkConfig.get()['features'];
-
- // The old flag: honoured for backwards compatibility
- const enableLabs = SdkConfig.get()['enableLabs'];
-
- let sdkConfigValue = enableLabs ? 'labs' : 'disable';
- if (featuresConfig && featuresConfig[featureId] !== undefined) {
- sdkConfigValue = featuresConfig[featureId];
- }
-
- if (sdkConfigValue === 'enable') {
- return true;
- } else if (sdkConfigValue === 'disable') {
- return false;
- } else if (sdkConfigValue === 'labs') {
- if (!MatrixClientPeg.get().isGuest()) {
- // Make it explicit that guests get the defaults (although they shouldn't
- // have been able to ever toggle the flags anyway)
- const userValue = localStorage.getItem(`mx_labs_feature_${featureId}`);
- return userValue === 'true';
- }
- return false;
- } else {
- console.warn(`Unknown features config for ${featureId}: ${sdkConfigValue}`);
- return false;
- }
- },
-
- setFeatureEnabled: function(featureId: string, enabled: boolean) {
- localStorage.setItem(`mx_labs_feature_${featureId}`, enabled);
- },
};
diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js
new file mode 100644
index 0000000000..0f23413b5f
--- /dev/null
+++ b/src/WidgetMessaging.js
@@ -0,0 +1,326 @@
+/*
+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.
+*/
+
+/*
+Listens for incoming postMessage requests from embedded widgets. The following API is exposed:
+{
+ api: "widget",
+ action: "content_loaded",
+ widgetId: $WIDGET_ID,
+ data: {}
+ // additional request fields
+}
+
+The complete request object is returned to the caller with an additional "response" key like so:
+{
+ api: "widget",
+ action: "content_loaded",
+ widgetId: $WIDGET_ID,
+ data: {},
+ // additional request fields
+ response: { ... }
+}
+
+The "api" field is required to use this API, and must be set to "widget" in all requests.
+
+The "action" determines the format of the request and response. All actions can return an error response.
+
+Additional data can be sent as additional, abritrary fields. However, typically the data object should be used.
+
+A success response is an object with zero or more keys.
+
+An error response is a "response" object which consists of a sole "error" key to indicate an error.
+They look like:
+{
+ error: {
+ message: "Unable to invite user into room.",
+ _error:
+ }
+}
+The "message" key should be a human-friendly string.
+
+ACTIONS
+=======
+** All actions must include an "api" field with valie "widget".**
+All actions can return an error response instead of the response outlined below.
+
+content_loaded
+--------------
+Indicates that widget contet has fully loaded
+
+Request:
+ - widgetId is the unique ID of the widget instance in riot / matrix state.
+ - No additional fields.
+Response:
+{
+ success: true
+}
+Example:
+{
+ api: "widget",
+ action: "content_loaded",
+ widgetId: $WIDGET_ID
+}
+
+
+api_version
+-----------
+Get the current version of the widget postMessage API
+
+Request:
+ - No additional fields.
+Response:
+{
+ api_version: "0.0.1"
+}
+Example:
+{
+ api: "widget",
+ action: "api_version",
+}
+
+supported_api_versions
+----------------------
+Get versions of the widget postMessage API that are currently supported
+
+Request:
+ - No additional fields.
+Response:
+{
+ api: "widget"
+ supported_versions: ["0.0.1"]
+}
+Example:
+{
+ api: "widget",
+ action: "supported_api_versions",
+}
+
+*/
+
+import URL from 'url';
+
+const WIDGET_API_VERSION = '0.0.1'; // Current API version
+const SUPPORTED_WIDGET_API_VERSIONS = [
+ '0.0.1',
+];
+
+import dis from './dispatcher';
+
+if (!global.mxWidgetMessagingListenerCount) {
+ global.mxWidgetMessagingListenerCount = 0;
+}
+if (!global.mxWidgetMessagingMessageEndpoints) {
+ global.mxWidgetMessagingMessageEndpoints = [];
+}
+
+
+/**
+ * Register widget message event listeners
+ */
+function startListening() {
+ if (global.mxWidgetMessagingListenerCount === 0) {
+ window.addEventListener("message", onMessage, false);
+ }
+ global.mxWidgetMessagingListenerCount += 1;
+}
+
+/**
+ * De-register widget message event listeners
+ */
+function stopListening() {
+ global.mxWidgetMessagingListenerCount -= 1;
+ if (global.mxWidgetMessagingListenerCount === 0) {
+ window.removeEventListener("message", onMessage);
+ }
+ if (global.mxWidgetMessagingListenerCount < 0) {
+ // Make an error so we get a stack trace
+ const e = new Error(
+ "WidgetMessaging: mismatched startListening / stopListening detected." +
+ " Negative count",
+ );
+ console.error(e);
+ }
+}
+
+/**
+ * Register a widget endpoint for trusted postMessage communication
+ * @param {string} widgetId Unique widget identifier
+ * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
+ */
+function addEndpoint(widgetId, endpointUrl) {
+ const u = URL.parse(endpointUrl);
+ if (!u || !u.protocol || !u.host) {
+ console.warn("Invalid origin:", endpointUrl);
+ return;
+ }
+
+ const origin = u.protocol + '//' + u.host;
+ const endpoint = new WidgetMessageEndpoint(widgetId, origin);
+ if (global.mxWidgetMessagingMessageEndpoints) {
+ if (global.mxWidgetMessagingMessageEndpoints.some(function(ep) {
+ return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
+ })) {
+ // Message endpoint already registered
+ console.warn("Endpoint already registered");
+ return;
+ }
+ global.mxWidgetMessagingMessageEndpoints.push(endpoint);
+ }
+}
+
+/**
+ * De-register a widget endpoint from trusted communication sources
+ * @param {string} widgetId Unique widget identifier
+ * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
+ * @return {boolean} True if endpoint was successfully removed
+ */
+function removeEndpoint(widgetId, endpointUrl) {
+ const u = URL.parse(endpointUrl);
+ if (!u || !u.protocol || !u.host) {
+ console.warn("Invalid origin");
+ return;
+ }
+
+ const origin = u.protocol + '//' + u.host;
+ if (global.mxWidgetMessagingMessageEndpoints && global.mxWidgetMessagingMessageEndpoints.length > 0) {
+ const length = global.mxWidgetMessagingMessageEndpoints.length;
+ global.mxWidgetMessagingMessageEndpoints = global.mxWidgetMessagingMessageEndpoints.filter(function(endpoint) {
+ return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin);
+ });
+ return (length > global.mxWidgetMessagingMessageEndpoints.length);
+ }
+ return false;
+}
+
+
+/**
+ * Handle widget postMessage events
+ * @param {Event} event Event to handle
+ * @return {undefined}
+ */
+function onMessage(event) {
+ if (!event.origin) { // Handle chrome
+ event.origin = event.originalEvent.origin;
+ }
+
+ // Event origin is empty string if undefined
+ if (
+ event.origin.length === 0 ||
+ !trustedEndpoint(event.origin) ||
+ event.data.api !== "widget" ||
+ !event.data.widgetId
+ ) {
+ return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
+ }
+
+ const action = event.data.action;
+ const widgetId = event.data.widgetId;
+ if (action === 'content_loaded') {
+ dis.dispatch({
+ action: 'widget_content_loaded',
+ widgetId: widgetId,
+ });
+ sendResponse(event, {success: true});
+ } else if (action === 'supported_api_versions') {
+ sendResponse(event, {
+ api: "widget",
+ supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
+ });
+ } else if (action === 'api_version') {
+ sendResponse(event, {
+ api: "widget",
+ version: WIDGET_API_VERSION,
+ });
+ } else {
+ console.warn("Widget postMessage event unhandled");
+ sendError(event, {message: "The postMessage was unhandled"});
+ }
+}
+
+/**
+ * Check if message origin is registered as trusted
+ * @param {string} origin PostMessage origin to check
+ * @return {boolean} True if trusted
+ */
+function trustedEndpoint(origin) {
+ if (!origin) {
+ return false;
+ }
+
+ return global.mxWidgetMessagingMessageEndpoints.some((endpoint) => {
+ return endpoint.endpointUrl === origin;
+ });
+}
+
+/**
+ * Send a postmessage response to a postMessage request
+ * @param {Event} event The original postMessage request event
+ * @param {Object} res Response data
+ */
+function sendResponse(event, res) {
+ const data = JSON.parse(JSON.stringify(event.data));
+ data.response = res;
+ event.source.postMessage(data, event.origin);
+}
+
+/**
+ * Send an error response to a postMessage request
+ * @param {Event} event The original postMessage request event
+ * @param {string} msg Error message
+ * @param {Error} nestedError Nested error event (optional)
+ */
+function sendError(event, msg, nestedError) {
+ console.error("Action:" + event.data.action + " failed with message: " + msg);
+ const data = JSON.parse(JSON.stringify(event.data));
+ data.response = {
+ error: {
+ message: msg,
+ },
+ };
+ if (nestedError) {
+ data.response.error._error = nestedError;
+ }
+ event.source.postMessage(data, event.origin);
+}
+
+/**
+ * Represents mapping of widget instance to URLs for trusted postMessage communication.
+ */
+class WidgetMessageEndpoint {
+ /**
+ * Mapping of widget instance to URL for trusted postMessage communication.
+ * @param {string} widgetId Unique widget identifier
+ * @param {string} endpointUrl Widget wurl origin.
+ */
+ constructor(widgetId, endpointUrl) {
+ if (!widgetId) {
+ throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
+ }
+ if (!endpointUrl) {
+ throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
+ }
+ this.widgetId = widgetId;
+ this.endpointUrl = endpointUrl;
+ }
+}
+
+export default {
+ startListening: startListening,
+ stopListening: stopListening,
+ addEndpoint: addEndpoint,
+ removeEndpoint: removeEndpoint,
+};
diff --git a/src/actions/GroupActions.js b/src/actions/GroupActions.js
new file mode 100644
index 0000000000..006c2da5b8
--- /dev/null
+++ b/src/actions/GroupActions.js
@@ -0,0 +1,34 @@
+/*
+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 { asyncAction } from './actionCreators';
+
+const GroupActions = {};
+
+/**
+ * Creates an action thunk that will do an asynchronous request to fetch
+ * the groups to which a user is joined.
+ *
+ * @param {MatrixClient} matrixClient the matrix client to query.
+ * @returns {function} an action thunk that will dispatch actions
+ * indicating the status of the request.
+ * @see asyncAction
+ */
+GroupActions.fetchJoinedGroups = function(matrixClient) {
+ return asyncAction('GroupActions.fetchJoinedGroups', () => matrixClient.getJoinedGroups());
+};
+
+export default GroupActions;
diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js
new file mode 100644
index 0000000000..33bdb53799
--- /dev/null
+++ b/src/actions/MatrixActionCreators.js
@@ -0,0 +1,108 @@
+/*
+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 dis from '../dispatcher';
+
+// TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events
+// become dispatches in the same place.
+/**
+ * Create a MatrixActions.sync action that represents a MatrixClient `sync` event,
+ * each parameter mapping to a key-value in the action.
+ *
+ * @param {MatrixClient} matrixClient the matrix client
+ * @param {string} state the current sync state.
+ * @param {string} prevState the previous sync state.
+ * @returns {Object} an action of type MatrixActions.sync.
+ */
+function createSyncAction(matrixClient, state, prevState) {
+ return {
+ action: 'MatrixActions.sync',
+ state,
+ prevState,
+ matrixClient,
+ };
+}
+
+/**
+ * @typedef AccountDataAction
+ * @type {Object}
+ * @property {string} action 'MatrixActions.accountData'.
+ * @property {MatrixEvent} event the MatrixEvent that triggered the dispatch.
+ * @property {string} event_type the type of the MatrixEvent, e.g. "m.direct".
+ * @property {Object} event_content the content of the MatrixEvent.
+ */
+
+/**
+ * Create a MatrixActions.accountData action that represents a MatrixClient `accountData`
+ * matrix event.
+ *
+ * @param {MatrixClient} matrixClient the matrix client.
+ * @param {MatrixEvent} accountDataEvent the account data event.
+ * @returns {AccountDataAction} an action of type MatrixActions.accountData.
+ */
+function createAccountDataAction(matrixClient, accountDataEvent) {
+ return {
+ action: 'MatrixActions.accountData',
+ event: accountDataEvent,
+ event_type: accountDataEvent.getType(),
+ event_content: accountDataEvent.getContent(),
+ };
+}
+
+/**
+ * This object is responsible for dispatching actions when certain events are emitted by
+ * the given MatrixClient.
+ */
+export default {
+ // A list of callbacks to call to unregister all listeners added
+ _matrixClientListenersStop: [],
+
+ /**
+ * Start listening to certain events from the MatrixClient and dispatch actions when
+ * they are emitted.
+ * @param {MatrixClient} matrixClient the MatrixClient to listen to events from
+ */
+ start(matrixClient) {
+ this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
+ this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
+ },
+
+ /**
+ * Start listening to events of type eventName on matrixClient and when they are emitted,
+ * dispatch an action created by the actionCreator function.
+ * @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
+ * @param {string} eventName the event to listen to on MatrixClient.
+ * @param {function} actionCreator a function that should return an action to dispatch
+ * when given the MatrixClient as an argument as well as
+ * arguments emitted in the MatrixClient event.
+ */
+ _addMatrixClientListener(matrixClient, eventName, actionCreator) {
+ const listener = (...args) => {
+ dis.dispatch(actionCreator(matrixClient, ...args));
+ };
+ matrixClient.on(eventName, listener);
+ this._matrixClientListenersStop.push(() => {
+ matrixClient.removeListener(eventName, listener);
+ });
+ },
+
+ /**
+ * Stop listening to events.
+ */
+ stop() {
+ this._matrixClientListenersStop.forEach((stopListener) => stopListener());
+ },
+};
diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js
new file mode 100644
index 0000000000..60946ea7f1
--- /dev/null
+++ b/src/actions/TagOrderActions.js
@@ -0,0 +1,47 @@
+/*
+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 Analytics from '../Analytics';
+import { asyncAction } from './actionCreators';
+import TagOrderStore from '../stores/TagOrderStore';
+
+const TagOrderActions = {};
+
+/**
+ * Creates an action thunk that will do an asynchronous request to
+ * commit TagOrderStore.getOrderedTags() to account data and dispatch
+ * actions to indicate the status of the request.
+ *
+ * @param {MatrixClient} matrixClient the matrix client to set the
+ * account data on.
+ * @returns {function} an action thunk that will dispatch actions
+ * indicating the status of the request.
+ * @see asyncAction
+ */
+TagOrderActions.commitTagOrdering = function(matrixClient) {
+ return asyncAction('TagOrderActions.commitTagOrdering', () => {
+ // Only commit tags if the state is ready, i.e. not null
+ const tags = TagOrderStore.getOrderedTags();
+ if (!tags) {
+ return;
+ }
+
+ Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
+ return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags});
+ });
+};
+
+export default TagOrderActions;
diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js
new file mode 100644
index 0000000000..bddfbc7c63
--- /dev/null
+++ b/src/actions/actionCreators.js
@@ -0,0 +1,41 @@
+/*
+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.
+*/
+
+/**
+ * Create an action thunk that will dispatch actions indicating the current
+ * status of the Promise returned by fn.
+ *
+ * @param {string} id the id to give the dispatched actions. This is given a
+ * suffix determining whether it is pending, successful or
+ * a failure.
+ * @param {function} fn a function that returns a Promise.
+ * @returns {function} an action thunk - a function that uses its single
+ * argument as a dispatch function to dispatch the
+ * following actions:
+ * `${id}.pending` and either
+ * `${id}.success` or
+ * `${id}.failure`.
+ */
+export function asyncAction(id, fn) {
+ return (dispatch) => {
+ dispatch({action: id + '.pending'});
+ fn().then((result) => {
+ dispatch({action: id + '.success', result});
+ }).catch((err) => {
+ dispatch({action: id + '.failure', err});
+ });
+ };
+}
diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js
index 9f1f40dbe7..f4e576ea0f 100644
--- a/src/autocomplete/EmojiProvider.js
+++ b/src/autocomplete/EmojiProvider.js
@@ -26,7 +26,7 @@ import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter';
import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy';
-import UserSettingsStore from '../UserSettingsStore';
+import SettingsStore from "../settings/SettingsStore";
import EmojiData from '../stripped-emoji.json';
@@ -96,7 +96,7 @@ export default class EmojiProvider extends AutocompleteProvider {
}
async getCompletions(query: string, selection: SelectionRange) {
- if (UserSettingsStore.getSyncedSetting("MessageComposerInput.dontSuggestEmoji")) {
+ if (SettingsStore.getValue("MessageComposerInput.dontSuggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them
}
diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js
index 8b43964b1a..794f507d21 100644
--- a/src/autocomplete/UserProvider.js
+++ b/src/autocomplete/UserProvider.js
@@ -53,8 +53,10 @@ export default class UserProvider extends AutocompleteProvider {
}
destroy() {
- MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound);
- MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound);
+ if (MatrixClientPeg.get()) {
+ MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound);
+ MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound);
+ }
}
_onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
@@ -126,7 +128,7 @@ export default class UserProvider extends AutocompleteProvider {
const events = this.room.getLiveTimeline().getEvents();
const lastSpoken = {};
- for(const event of events) {
+ for (const event of events) {
lastSpoken[event.getSender()] = event.getTs();
}
diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js
index c3ad7f9cd1..3c2308e6a7 100644
--- a/src/components/structures/ContextualMenu.js
+++ b/src/components/structures/ContextualMenu.js
@@ -33,6 +33,7 @@ module.exports = {
menuHeight: React.PropTypes.number,
chevronOffset: React.PropTypes.number,
menuColour: React.PropTypes.string,
+ chevronFace: React.PropTypes.string, // top, bottom, left, right
},
getOrCreateContainer: function() {
@@ -58,12 +59,30 @@ module.exports = {
}
};
- const position = {
- top: props.top,
- };
+ const position = {};
+ let chevronFace = null;
+
+ if (props.top) {
+ position.top = props.top;
+ } else {
+ position.bottom = props.bottom;
+ }
+
+ if (props.left) {
+ position.left = props.left;
+ chevronFace = 'left';
+ } else {
+ position.right = props.right;
+ chevronFace = 'right';
+ }
const chevronOffset = {};
- if (props.chevronOffset) {
+ if (props.chevronFace) {
+ chevronFace = props.chevronFace;
+ }
+ if (chevronFace === 'top' || chevronFace === 'bottom') {
+ chevronOffset.left = props.chevronOffset;
+ } else {
chevronOffset.top = props.chevronOffset;
}
@@ -74,28 +93,27 @@ module.exports = {
.mx_ContextualMenu_chevron_left:after {
border-right-color: ${props.menuColour};
}
-
.mx_ContextualMenu_chevron_right:after {
border-left-color: ${props.menuColour};
}
+ .mx_ContextualMenu_chevron_top:after {
+ border-left-color: ${props.menuColour};
+ }
+ .mx_ContextualMenu_chevron_bottom:after {
+ border-left-color: ${props.menuColour};
+ }
`;
}
- let chevron = null;
- if (props.left) {
- chevron = ;
- position.left = props.left;
- } else {
- chevron = ;
- position.right = props.right;
- }
-
+ const chevron = ;
const className = 'mx_ContextualMenu_wrapper';
const menuClasses = classNames({
'mx_ContextualMenu': true,
- 'mx_ContextualMenu_left': props.left,
- 'mx_ContextualMenu_right': !props.left,
+ 'mx_ContextualMenu_left': chevronFace === 'left',
+ 'mx_ContextualMenu_right': chevronFace === 'right',
+ 'mx_ContextualMenu_top': chevronFace === 'top',
+ 'mx_ContextualMenu_bottom': chevronFace === 'bottom',
});
const menuStyle = {};
diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js
index 23feb4cf30..ffa5e45249 100644
--- a/src/components/structures/FilePanel.js
+++ b/src/components/structures/FilePanel.js
@@ -19,7 +19,7 @@ import React from 'react';
import Matrix from 'matrix-js-sdk';
import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
-import { _t, _tJsx } from '../../languageHandler';
+import { _t } from '../../languageHandler';
/*
* Component which shows the filtered file using a TimelinePanel
@@ -92,7 +92,10 @@ const FilePanel = React.createClass({
if (MatrixClientPeg.get().isGuest()) {
return
;
- },
-
_getLongDescriptionNode: function() {
const summary = this.state.summary;
let description = null;
@@ -932,12 +913,12 @@ export default React.createClass({
className="mx_GroupView_groupDesc_placeholder"
onClick={this._onEditClick}
>
- { _tJsx(
+ { _t(
'Your community hasn\'t got a Long Description, a HTML page to show to community members. ' +
'Click here to open settings and give it one!',
- [/ /],
- [(sub) => ])
- }
+ {},
+ { 'br': },
+ ) }
;
}
const groupDescEditingClasses = classnames({
@@ -976,7 +957,6 @@ export default React.createClass({
let shortDescNode;
const bodyNodes = [
this._getMembershipSection(),
- this.state.editing ? this._getMemberSettingsSection() : null,
this._getGroupSection(),
];
const rightButtons = [];
diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js
index 08120d9508..38b7634edb 100644
--- a/src/components/structures/LoggedInView.js
+++ b/src/components/structures/LoggedInView.js
@@ -18,9 +18,10 @@ limitations under the License.
import * as Matrix from 'matrix-js-sdk';
import React from 'react';
+import { DragDropContext } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
-import UserSettingsStore from '../../UserSettingsStore';
-import KeyCode from '../../KeyCode';
+import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import Notifier from '../../Notifier';
import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler';
@@ -28,6 +29,7 @@ import sdk from '../../index';
import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg';
+import SettingsStore from "../../settings/SettingsStore";
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
@@ -38,7 +40,7 @@ import MatrixClientPeg from '../../MatrixClientPeg';
*
* Components mounted below us can access the matrix client via the react context.
*/
-export default React.createClass({
+const LoggedInView = React.createClass({
displayName: 'LoggedInView',
propTypes: {
@@ -74,7 +76,7 @@ export default React.createClass({
getInitialState: function() {
return {
// use compact timeline view
- useCompactLayout: UserSettingsStore.getSyncedSetting('useCompactLayout'),
+ useCompactLayout: SettingsStore.getValue('useCompactLayout'),
};
},
@@ -153,13 +155,7 @@ export default React.createClass({
*/
let handled = false;
- const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
- let ctrlCmdOnly;
- if (isMac) {
- ctrlCmdOnly = ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
- } else {
- ctrlCmdOnly = ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
- }
+ const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.keyCode) {
case KeyCode.UP:
@@ -213,6 +209,7 @@ export default React.createClass({
},
render: function() {
+ const TagPanel = sdk.getComponent('structures.TagPanel');
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel');
const RoomView = sdk.getComponent('structures.RoomView');
@@ -334,6 +331,7 @@ export default React.createClass({
{ topBar }
+ { SettingsStore.isFeatureEnabled("feature_tag_panel") ? : }
{
- if(loggedIn) {
+ if (loggedIn) {
this.props.onTokenLoginCompleted();
// don't do anything else until the page reloads - just stay in
@@ -357,7 +359,6 @@ module.exports = React.createClass({
componentWillUnmount: function() {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
- UDEHandler.stopListening();
window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize);
},
@@ -496,6 +497,11 @@ module.exports = React.createClass({
case 'view_create_room':
this._createRoom();
break;
+ case 'view_create_group': {
+ const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog");
+ Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
+ }
+ break;
case 'view_room_directory':
this._setPage(PageTypes.RoomDirectory);
this.notifyNewScreen('directory');
@@ -586,6 +592,9 @@ module.exports = React.createClass({
this._onWillStartClient();
});
break;
+ case 'client_started':
+ this._onClientStarted();
+ break;
case 'new_version':
this.onVersion(
payload.currentVersion, payload.newVersion,
@@ -883,7 +892,7 @@ module.exports = React.createClass({
*/
_onSetTheme: function(theme) {
if (!theme) {
- theme = 'light';
+ theme = SettingsStore.getValue("theme");
}
// look for the stylesheet elements.
@@ -906,18 +915,49 @@ module.exports = React.createClass({
// disable all of them first, then enable the one we want. Chrome only
// bothers to do an update on a true->false transition, so this ensures
// that we get exactly one update, at the right time.
+ //
+ // ^ This comment was true when we used to use alternative stylesheets
+ // for the CSS. Nowadays we just set them all as disabled in index.html
+ // and enable them as needed. It might be cleaner to disable them all
+ // at the same time to prevent loading two themes simultaneously and
+ // having them interact badly... but this causes a flash of unstyled app
+ // which is even uglier. So we don't.
- Object.values(styleElements).forEach((a) => {
- a.disabled = true;
- });
styleElements[theme].disabled = false;
- if (theme === 'dark') {
- // abuse the tinter to change all the SVG's #fff to #2d2d2d
- // XXX: obviously this shouldn't be hardcoded here.
- Tinter.tintSvgWhite('#2d2d2d');
- } else {
- Tinter.tintSvgWhite('#ffffff');
+ const switchTheme = function() {
+ // we re-enable our theme here just in case we raced with another
+ // theme set request as per https://github.com/vector-im/riot-web/issues/5601.
+ // We could alternatively lock or similar to stop the race, but
+ // this is probably good enough for now.
+ styleElements[theme].disabled = false;
+ Object.values(styleElements).forEach((a) => {
+ if (a == styleElements[theme]) return;
+ a.disabled = true;
+ });
+ Tinter.setTheme(theme);
+ };
+
+ // turns out that Firefox preloads the CSS for link elements with
+ // the disabled attribute, but Chrome doesn't.
+
+ let cssLoaded = false;
+
+ styleElements[theme].onload = () => {
+ switchTheme();
+ };
+
+ for (let i = 0; i < document.styleSheets.length; i++) {
+ const ss = document.styleSheets[i];
+ if (ss && ss.href === styleElements[theme].href) {
+ cssLoaded = true;
+ break;
+ }
+ }
+
+ if (cssLoaded) {
+ styleElements[theme].onload = undefined;
+ switchTheme();
}
},
@@ -1025,10 +1065,10 @@ module.exports = React.createClass({
// this if we are not scrolled up in the view. To find out, delegate to
// the timeline panel. If the timeline panel doesn't exist, then we assume
// it is safe to reset the timeline.
- if (!self.refs.loggedInView) {
+ if (!self._loggedInView) {
return true;
}
- return self.refs.loggedInView.canResetTimelineInRoom(roomId);
+ return self._loggedInView.getDecoratedComponentInstance().canResetTimelineInRoom(roomId);
});
cli.on('sync', function(state, prevState) {
@@ -1088,6 +1128,65 @@ module.exports = React.createClass({
cli.on("crypto.roomKeyRequestCancellation", (req) => {
krh.handleKeyRequestCancellation(req);
});
+ cli.on("Room", (room) => {
+ if (MatrixClientPeg.get().isCryptoEnabled()) {
+ const blacklistEnabled = SettingsStore.getValueAt(
+ SettingLevel.ROOM_DEVICE,
+ "blacklistUnverifiedDevices",
+ room.roomId,
+ /*explicit=*/true,
+ );
+ room.setBlacklistUnverifiedDevices(blacklistEnabled);
+ }
+ });
+ cli.on("crypto.warning", (type) => {
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ switch (type) {
+ case 'CRYPTO_WARNING_ACCOUNT_MIGRATED':
+ Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
+ title: _t('Cryptography data migrated'),
+ description: _t(
+ "A one-off migration of cryptography data has been performed. "+
+ "End-to-end encryption will not work if you go back to an older "+
+ "version of Riot. If you need to use end-to-end cryptography on "+
+ "an older version, log out of Riot first. To retain message history, "+
+ "export and re-import your keys.",
+ ),
+ });
+ break;
+ case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
+ Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
+ title: _t('Old cryptography data detected'),
+ description: _t(
+ "Data from an older version of Riot has been detected. "+
+ "This will have caused end-to-end cryptography to malfunction "+
+ "in the older version. End-to-end encrypted messages exchanged "+
+ "recently whilst using the older version may not be decryptable "+
+ "in this version. This may also cause messages exchanged with this "+
+ "version to fail. If you experience problems, log out and back in "+
+ "again. To retain message history, export and re-import your keys.",
+ ),
+ });
+ break;
+ }
+ });
+ },
+
+ /**
+ * Called shortly after the matrix client has started. Useful for
+ * setting up anything that requires the client to be started.
+ * @private
+ */
+ _onClientStarted: function() {
+ const cli = MatrixClientPeg.get();
+
+ if (cli.isCryptoEnabled()) {
+ const blacklistEnabled = SettingsStore.getValueAt(
+ SettingLevel.DEVICE,
+ "blacklistUnverifiedDevices",
+ );
+ cli.setGlobalBlacklistUnverifiedDevices(blacklistEnabled);
+ }
},
showScreen: function(screen, params) {
@@ -1327,13 +1426,6 @@ module.exports = React.createClass({
cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => {
dis.dispatch({action: 'message_sent'});
}, (err) => {
- if (err.name === 'UnknownDeviceError') {
- dis.dispatch({
- action: 'unknown_device_error',
- err: err,
- room: cli.getRoom(roomId),
- });
- }
dis.dispatch({action: 'message_send_failed'});
});
},
@@ -1395,6 +1487,10 @@ module.exports = React.createClass({
return this.props.makeRegistrationUrl(params);
},
+ _collectLoggedInView: function(ref) {
+ this._loggedInView = ref;
+ },
+
render: function() {
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
@@ -1427,7 +1523,7 @@ module.exports = React.createClass({
*/
const LoggedInView = sdk.getComponent('structures.LoggedInView');
return (
- {
- this.setState({profile});
- });
- },
-
- onClick: function(e) {
- e.preventDefault();
- dis.dispatch({
- action: 'view_group',
- group_id: this.props.groupId,
- });
- },
-
- render: function() {
- const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
- const profile = this.state.profile || {};
- const name = profile.name || this.props.groupId;
- const desc = profile.shortDescription;
- const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp(
- profile.avatarUrl, 50, 50, "crop",
- ) : null;
- return
-
- { _tJsx(
+ { _t(
'To join an existing community you\'ll have to '+
'know its community identifier; this will look '+
'something like +example:matrix.org.',
- /(.*)<\/i>/,
- (sub) => { sub },
- ) }
+ {},
+ { 'i': (sub) => { sub } })
+ }
diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js
index cad55351d1..77d506d9af 100644
--- a/src/components/structures/RoomStatusBar.js
+++ b/src/components/structures/RoomStatusBar.js
@@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
+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.
@@ -15,17 +16,26 @@ 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';
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;
+function getUnsentMessages(room) {
+ if (!room) { return []; }
+ return room.getPendingEvents().filter(function(ev) {
+ return ev.status === Matrix.EventStatus.NOT_SENT;
+ });
+};
+
module.exports = React.createClass({
displayName: 'RoomStatusBar',
@@ -36,9 +46,6 @@ module.exports = React.createClass({
// the number of messages which have arrived since we've been scrolled up
numUnreadMessages: React.PropTypes.number,
- // string to display when there are messages in the room which had errors on send
- unsentMessageError: React.PropTypes.string,
-
// this is true if we are fully scrolled-down, and are looking at
// the end of the live timeline.
atEndOfLiveTimeline: React.PropTypes.bool,
@@ -99,12 +106,14 @@ module.exports = React.createClass({
return {
syncState: MatrixClientPeg.get().getSyncState(),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
+ unsentMessages: getUnsentMessages(this.props.room),
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
+ MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize();
},
@@ -119,6 +128,7 @@ module.exports = React.createClass({
if (client) {
client.removeListener("sync", this.onSyncStateChange);
client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
+ client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
}
},
@@ -137,6 +147,26 @@ module.exports = React.createClass({
});
},
+ _onResendAllClick: function() {
+ Resend.resendUnsentEvents(this.props.room);
+ },
+
+ _onCancelAllClick: function() {
+ Resend.cancelUnsentEvents(this.props.room);
+ },
+
+ _onShowDevicesClick: function() {
+ showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room);
+ },
+
+ _onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
+ if (room.roomId !== this.props.room.roomId) return;
+
+ this.setState({
+ unsentMessages: getUnsentMessages(this.props.room),
+ });
+ },
+
// Check whether current size is greater than 0, if yes call props.onVisible
_checkSize: function() {
if (this.props.onVisible && this._getSize()) {
@@ -156,7 +186,7 @@ module.exports = React.createClass({
this.props.sentMessageAndIsAlone
) {
return STATUS_BAR_EXPANDED;
- } else if (this.props.unsentMessageError) {
+ } else if (this.state.unsentMessages.length > 0) {
return STATUS_BAR_EXPANDED_LARGE;
}
return STATUS_BAR_HIDDEN;
@@ -242,6 +272,61 @@ module.exports = React.createClass({
return avatars;
},
+ _getUnsentMessageContent: function() {
+ const unsentMessages = this.state.unsentMessages;
+ if (!unsentMessages.length) return null;
+
+ let title;
+ let content;
+
+ const hasUDE = unsentMessages.some((m) => {
+ return m.error && m.error.name === "UnknownDeviceError";
+ });
+
+ if (hasUDE) {
+ title = _t("Message not sent due to unknown devices being present");
+ content = _t(
+ "Show devices or cancel all.",
+ {},
+ {
+ 'showDevicesText': (sub) => { sub },
+ 'cancelText': (sub) => { sub },
+ },
+ );
+ } else {
+ if (
+ unsentMessages.length === 1 &&
+ unsentMessages[0].error &&
+ unsentMessages[0].error.data &&
+ unsentMessages[0].error.data.error
+ ) {
+ title = unsentMessages[0].error.data.error;
+ } else {
+ title = _t("Some of your messages have not been sent.");
+ }
+ content = _t("Resend all or cancel all now. " +
+ "You can also select individual messages to resend or cancel.",
+ {},
+ {
+ 'resendText': (sub) =>
+ { sub },
+ 'cancelText': (sub) =>
+ { sub },
+ },
+ );
+ }
+
+ return
+
+
+ { title }
+
+
+ { content }
+
+
;
+ },
+
// return suitable content for the main (text) part of the status bar.
_getContent: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
@@ -264,24 +349,8 @@ module.exports = React.createClass({
);
}
- if (this.props.unsentMessageError) {
- return (
-
- );
+ if (this.state.unsentMessages.length > 0) {
+ return this._getUnsentMessageContent();
}
// unread count trumps who is typing since the unread count is only
@@ -322,12 +391,15 @@ module.exports = React.createClass({
if (this.props.sentMessageAndIsAlone) {
return (
+ );
},
- _onPreviewsDisabledChanged: function(e) {
- UserSettingsStore.setUrlPreviewsDisabled(e.target.checked);
- },
-
- _renderSyncedSetting: function(setting) {
- // TODO: this ought to be a separate component so that we don't need
- // to rebind the onChange each time we render
-
- const onChange = (e) => {
- UserSettingsStore.setSyncedSetting(setting.id, e.target.checked);
- if (setting.fn) setting.fn(e.target.checked);
- };
-
- return
-
-
-
;
- },
-
- _renderThemeSelector: function(setting) {
- // TODO: this ought to be a separate component so that we don't need
- // to rebind the onChange each time we render
- const onChange = (e) => {
- if (e.target.checked) {
- this._syncedSettings[setting.id] = setting.value;
- UserSettingsStore.setSyncedSetting(setting.id, setting.value);
- }
- dis.dispatch({
- action: 'set_theme',
- value: setting.value,
- });
- };
- return
);
@@ -873,29 +765,16 @@ module.exports = React.createClass({
} else return ();
},
- _renderLocalSetting: function(setting) {
- // TODO: this ought to be a separate component so that we don't need
- // to rebind the onChange each time we render
- const onChange = (e) => {
- // XXX: awful, but at time of writing, granular settings has landed on
- // develop which will almost certainly mean we'll handle this differently.
- if (setting.id === 'webRtcForceTURN') {
- MatrixClientPeg.get().setForceTURN(e.target.checked);
- }
- UserSettingsStore.setLocalSetting(setting.id, e.target.checked);
- if (setting.fn) setting.fn(e.target.checked);
- };
-
- return
{ _t('Riot collects anonymous analytics to allow us to improve the application.') }
- { ANALYTICS_SETTINGS_LABELS.map( this._renderLocalSetting ) }
+ { ANALYTICS_SETTINGS.map( this._renderDeviceSetting ) }
;
},
_renderLabs: function() {
const features = [];
- UserSettingsStore.getLabsFeatures().forEach((featureId) => {
+ SettingsStore.getLabsFeatures().forEach((featureId) => {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = (e) => {
- UserSettingsStore.setFeatureEnabled(featureId, e.target.checked);
+ SettingsStore.setFeatureEnabled(featureId, e.target.checked);
this.forceUpdate();
};
@@ -953,10 +832,10 @@ module.exports = React.createClass({
type="checkbox"
id={featureId}
name={featureId}
- defaultChecked={UserSettingsStore.isFeatureEnabled(featureId)}
+ defaultChecked={SettingsStore.isFeatureEnabled(featureId)}
onChange={onChange}
/>
-
+
);
});
@@ -1049,6 +928,8 @@ module.exports = React.createClass({
const settings = this.state.electron_settings;
if (!settings) return;
+ // TODO: This should probably be a granular setting, but it only applies to electron
+ // and ends up being get/set outside of matrix anyways (local system setting).
return
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", { emailAddress: this.state.email }) }
+
{ _t('Your password has been reset') }.
{ _t('You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device') }.
+
);
},
});
diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js
index 2e08c05853..9ed710534b 100644
--- a/src/components/structures/login/Login.js
+++ b/src/components/structures/login/Login.js
@@ -18,12 +18,13 @@ 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';
-import UserSettingsStore from '../../../UserSettingsStore';
import PlatformPeg from '../../../PlatformPeg';
+import SdkConfig from '../../../SdkConfig';
+import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/;
@@ -103,7 +104,7 @@ module.exports = React.createClass({
).then((data) => {
this.props.onLoggedIn(data);
}, (error) => {
- if(this._unmounted) {
+ if (this._unmounted) {
return;
}
let errorText;
@@ -113,7 +114,22 @@ module.exports = React.createClass({
if (error.httpStatus == 400 && usingEmail) {
errorText = _t('This Home Server does not support login using email address.');
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
- errorText = _t('Incorrect username and/or password.');
+ if (SdkConfig.get().disable_custom_urls) {
+ errorText = (
+
+
{ _t('Incorrect username and/or password.') }
+
+ { _t('Please note you are logging into the %(hs)s server, not matrix.org.',
+ {
+ hs: this.props.defaultHsUrl.replace(/^https?:\/\//, ''),
+ })
+ }
+
+
+ );
+ } else {
+ errorText = _t('Incorrect username and/or password.');
+ }
} else {
// other errors, not specific to doing a password login
errorText = this._errorTextFromError(error);
@@ -128,7 +144,7 @@ module.exports = React.createClass({
loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403,
});
}).finally(() => {
- if(this._unmounted) {
+ if (this._unmounted) {
return;
}
this.setState({
@@ -290,17 +306,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 }; } },
) }
;
}
@@ -349,8 +367,8 @@ module.exports = React.createClass({
},
_onLanguageChange: function(newLang) {
- if(languageHandler.getCurrentLanguage() !== newLang) {
- UserSettingsStore.setLocalSetting('language', newLang);
+ if (languageHandler.getCurrentLanguage() !== newLang) {
+ SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload();
}
},
@@ -367,6 +385,7 @@ module.exports = React.createClass({
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
+ const LoginPage = sdk.getComponent("login.LoginPage");
const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig");
@@ -381,43 +400,68 @@ module.exports = React.createClass({
}
let returnToAppJsx;
+ /*
+ // with the advent of ILAG I don't think we need this any more
if (this.props.onCancelClick) {
returnToAppJsx =
{ _t('Return to app') }
;
}
+ */
+
+ let serverConfig;
+ let header;
+
+ if (!SdkConfig.get().disable_custom_urls) {
+ serverConfig = ;
+ }
+
+ // FIXME: remove status.im theme tweaks
+ const theme = SettingsStore.getValue("theme");
+ if (theme !== "status") {
+ header =
);
}
let returnToAppJsx;
+ /*
+ // with the advent of ILAG I don't think we need this any more
if (this.props.onCancelClick) {
returnToAppJsx = (
@@ -384,8 +392,31 @@ module.exports = React.createClass({
);
}
+ */
+
+ let header;
+ let errorText;
+ // FIXME: remove hardcoded Status team tweaks at some point
+ if (theme === 'status' && this.state.errorText) {
+ header =
- { _t("Are you sure you wish to remove (delete) this event? " +
- "Note that if you delete a room name or topic change, it could undo the change.") }
-
-
-
-
-
-
-
+
+
);
},
});
diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js
index b0dc0a304e..9c8be27c89 100644
--- a/src/components/views/dialogs/KeyShareDialog.js
+++ b/src/components/views/dialogs/KeyShareDialog.js
@@ -54,7 +54,7 @@ export default React.createClass({
const deviceInfo = r[userId][deviceId];
- if(!deviceInfo) {
+ if (!deviceInfo) {
console.warn(`No details found for device ${userId}:${deviceId}`);
this.props.onFinished(false);
diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js
index f404bdd975..75ae0eda17 100644
--- a/src/components/views/dialogs/SessionRestoreErrorDialog.js
+++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js
@@ -18,7 +18,7 @@ import React from 'react';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal';
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
export default React.createClass({
@@ -45,9 +45,10 @@ export default React.createClass({
if (SdkConfig.get().bug_report_endpoint_url) {
bugreport = (
);
diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js
index 057609b344..3ffafb0659 100644
--- a/src/components/views/dialogs/SetMxIdDialog.js
+++ b/src/components/views/dialogs/SetMxIdDialog.js
@@ -20,8 +20,8 @@ import React from 'react';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import classnames from 'classnames';
-import KeyCode from '../../../KeyCode';
-import { _t, _tJsx } from '../../../languageHandler';
+import { KeyCode } from '../../../Keyboard';
+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({
+ ;
+ },
+});
+
+export default GroupTile;
diff --git a/src/components/views/groups/GroupUserSettings.js b/src/components/views/groups/GroupUserSettings.js
new file mode 100644
index 0000000000..755d6aae8f
--- /dev/null
+++ b/src/components/views/groups/GroupUserSettings.js
@@ -0,0 +1,89 @@
+/*
+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 GeminiScrollbar from 'react-gemini-scrollbar';
+import sdk from '../../../index';
+import { MatrixClient } from 'matrix-js-sdk';
+import { _t } from '../../../languageHandler';
+
+export default React.createClass({
+ displayName: 'GroupUserSettings',
+
+ contextTypes: {
+ matrixClient: PropTypes.instanceOf(MatrixClient),
+ },
+
+ getInitialState() {
+ return {
+ error: null,
+ groups: null,
+ };
+ },
+
+ componentWillMount: function() {
+ this.context.matrixClient.getJoinedGroups().done((result) => {
+ this.setState({groups: result.groups || [], error: null});
+ }, (err) => {
+ console.error(err);
+ this.setState({groups: null, error: err});
+ });
+ },
+
+ _renderGroupPublicity() {
+ let text = "";
+ let scrollbox = ;
+ const groups = this.state.groups;
+
+ if (this.state.error) {
+ text = _t('Something went wrong when trying to get your communities.');
+ } else if (groups === null) {
+ text = _t('Loading...');
+ } else if (groups.length > 0) {
+ const GroupPublicityToggle = sdk.getComponent('groups.GroupPublicityToggle');
+ const groupPublicityToggles = groups.map((groupId, index) => {
+ return ;
+ });
+ text = _t('Display your community flair in rooms configured to show it.');
+ scrollbox =
+
+ { groupPublicityToggles }
+
+
;
+ } else {
+ text = _t("You're not currently a member of any communities.");
+ }
+
+ return
;
+ },
+});
diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js
index cf814b0a6e..21e5094b28 100644
--- a/src/components/views/login/CaptchaForm.js
+++ b/src/components/views/login/CaptchaForm.js
@@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
const DIV_ID = 'mx_recaptcha';
@@ -67,10 +67,10 @@ module.exports = React.createClass({
// * jumping straight to a hosted captcha page (but we don't support that yet)
// * embedding the captcha in an iframe (if that works)
// * using a better captcha lib
- ReactDOM.render(_tJsx(
+ ReactDOM.render(_t(
"Robot check is currently unavailable on desktop - please use a web browser",
- /(.*?)<\/a>/,
- (sub) => { return { sub }; }), warning);
+ {},
+ { 'a': (sub) => { return { sub }; }}), warning);
this.refs.recaptchaContainer.appendChild(warning);
} else {
const scriptTag = document.createElement('script');
diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js
index 5f5a74ccd1..e8997b4112 100644
--- a/src/components/views/login/InteractiveAuthEntryComponents.js
+++ b/src/components/views/login/InteractiveAuthEntryComponents.js
@@ -20,7 +20,7 @@ import url from 'url';
import classnames from 'classnames';
import sdk from '../../../index';
-import { _t, _tJsx } from '../../../languageHandler';
+import { _t } from '../../../languageHandler';
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@@ -256,7 +256,10 @@ export const EmailIdentityAuthEntry = React.createClass({
} else {
return (
-
{ _tJsx("An email has been sent to %(emailAddress)s", /%\(emailAddress\)s/, (sub) => {this.props.inputs.emailAddress}) }
+
{ _t("An email has been sent to %(emailAddress)s",
+ { emailAddress: (sub) => { this.props.inputs.emailAddress } },
+ ) }
+
{ _t("Please check your email to continue registration.") }
- { _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 c7e839ab40..4ac2da2030 100644
--- a/src/components/views/rooms/RoomSettings.js
+++ b/src/components/views/rooms/RoomSettings.js
@@ -17,14 +17,14 @@ 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';
import ObjectUtils from '../../../ObjectUtils';
import dis from '../../../dispatcher';
-import UserSettingsStore from '../../../UserSettingsStore';
import AccessibleButton from '../elements/AccessibleButton';
+import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
// parse a string as an integer; if the input is undefined, or cannot be parsed
@@ -311,7 +311,7 @@ module.exports = React.createClass({
// url preview settings
const ps = this.saveUrlPreviewSettings();
if (ps.length > 0) {
- promises.push(ps);
+ ps.map((p) => promises.push(p));
}
// related groups
@@ -363,26 +363,16 @@ module.exports = React.createClass({
},
saveBlacklistUnverifiedDevicesPerRoom: function() {
- if (!this.refs.blacklistUnverified) return;
- if (this._isRoomBlacklistUnverified() !== this.refs.blacklistUnverified.checked) {
- this._setRoomBlacklistUnverified(this.refs.blacklistUnverified.checked);
- }
- },
-
- _isRoomBlacklistUnverified: function() {
- const blacklistUnverifiedDevicesPerRoom = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevicesPerRoom;
- if (blacklistUnverifiedDevicesPerRoom) {
- return blacklistUnverifiedDevicesPerRoom[this.props.room.roomId];
- }
- return false;
- },
-
- _setRoomBlacklistUnverified: function(value) {
- const blacklistUnverifiedDevicesPerRoom = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevicesPerRoom || {};
- blacklistUnverifiedDevicesPerRoom[this.props.room.roomId] = value;
- UserSettingsStore.setLocalSetting('blacklistUnverifiedDevicesPerRoom', blacklistUnverifiedDevicesPerRoom);
-
- this.props.room.setBlacklistUnverifiedDevices(value);
+ if (!this.refs.blacklistUnverifiedDevices) return;
+ this.refs.blacklistUnverifiedDevices.save().then(() => {
+ const value = SettingsStore.getValueAt(
+ SettingLevel.ROOM_DEVICE,
+ "blacklistUnverifiedDevices",
+ this.props.room.roomId,
+ /*explicit=*/true,
+ );
+ this.props.room.setBlacklistUnverifiedDevices(value);
+ });
},
_hasDiff: function(strA, strB) {
@@ -588,19 +578,20 @@ module.exports = React.createClass({
},
_renderEncryptionSection: function() {
+ const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
+
const cli = MatrixClientPeg.get();
const roomState = this.props.room.currentState;
const isEncrypted = cli.isRoomEncrypted(this.props.room.roomId);
- const isGlobalBlacklistUnverified = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevices;
- const isRoomBlacklistUnverified = this._isRoomBlacklistUnverified();
- const settings =
- ;
+ const settings = (
+
+ );
if (!isEncrypted && roomState.mayClientSendStateEvent("m.room.encryption", cli)) {
return (
@@ -637,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;
@@ -758,9 +747,9 @@ module.exports = React.createClass({
}
});
- var tagsSection = null;
+ let tagsSection = null;
if (canSetTag || self.state.tags) {
- var tagsSection =
+ tagsSection =
{ _t('The default role for new room members is') }
-
+
{ _t('To send messages, you must be a') }
-
+
{ _t('To invite users into the room, you must be a') }
-
+
{ _t('To configure the room, you must be a') }
-
+
{ _t('To kick users, you must be a') }
-
+
{ _t('To ban users, you must be a') }
-
+
{ _t('To remove other users\' messages, you must be a') }
-
+
{ 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 (
);
@@ -168,13 +89,9 @@ export default class DevicesPanelEntry extends React.Component {
DevicesPanelEntry.propTypes = {
device: React.PropTypes.object.isRequired,
- onDeleted: React.PropTypes.func,
-};
-
-DevicesPanelEntry.contextTypes = {
- authCache: React.PropTypes.object,
+ onDeviceToggled: React.PropTypes.func,
};
DevicesPanelEntry.defaultProps = {
- onDeleted: function() {},
+ onDeviceToggled: function() {},
};
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/components/views/voip/VideoView.js b/src/components/views/voip/VideoView.js
index 748673f1a5..44e7a47f02 100644
--- a/src/components/views/voip/VideoView.js
+++ b/src/components/views/voip/VideoView.js
@@ -23,7 +23,7 @@ import classNames from 'classnames';
import sdk from '../../../index';
import dis from '../../../dispatcher';
-import UserSettingsStore from '../../../UserSettingsStore';
+import SettingsStore from "../../../settings/SettingsStore";
module.exports = React.createClass({
displayName: 'VideoView',
@@ -113,7 +113,7 @@ module.exports = React.createClass({
const maxVideoHeight = fullscreenElement ? null : this.props.maxHeight;
const localVideoFeedClasses = classNames("mx_VideoView_localVideoFeed",
{ "mx_VideoView_localVideoFeed_flipped":
- UserSettingsStore.getSyncedSetting('VideoView.flipVideoHorizontally', false),
+ SettingsStore.getValue('VideoView.flipVideoHorizontally'),
},
);
return (
diff --git a/src/cryptodevices.js b/src/cryptodevices.js
new file mode 100644
index 0000000000..c93e04253f
--- /dev/null
+++ b/src/cryptodevices.js
@@ -0,0 +1,104 @@
+/*
+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 Resend from './Resend';
+import sdk from './index';
+import Modal from './Modal';
+import { _t } from './languageHandler';
+
+/**
+ * Gets all crypto devices in a room that are marked neither known
+ * nor verified.
+ *
+ * @param {MatrixClient} matrixClient A MatrixClient
+ * @param {Room} room js-sdk room object representing the room
+ * @return {Promise} A promise which resolves to a map userId->deviceId->{@link
+ * module:crypto~DeviceInfo|DeviceInfo}.
+ */
+export function getUnknownDevicesForRoom(matrixClient, room) {
+ const roomMembers = room.getJoinedMembers().map((m) => {
+ return m.userId;
+ });
+ return matrixClient.downloadKeys(roomMembers, false).then((devices) => {
+ const unknownDevices = {};
+ // This is all devices in this room, so find the unknown ones.
+ Object.keys(devices).forEach((userId) => {
+ Object.keys(devices[userId]).map((deviceId) => {
+ const device = devices[userId][deviceId];
+
+ if (device.isUnverified() && !device.isKnown()) {
+ if (unknownDevices[userId] === undefined) {
+ unknownDevices[userId] = {};
+ }
+ unknownDevices[userId][deviceId] = device;
+ }
+ });
+ });
+ return unknownDevices;
+ });
+}
+
+/**
+ * Show the UnknownDeviceDialog for a given room. The dialog will inform the user
+ * that messages they sent to this room have not been sent due to unknown devices
+ * being present.
+ *
+ * @param {MatrixClient} matrixClient A MatrixClient
+ * @param {Room} room js-sdk room object representing the room
+ */
+export function showUnknownDeviceDialogForMessages(matrixClient, room) {
+ getUnknownDevicesForRoom(matrixClient, room).then((unknownDevices) => {
+ const onSendClicked = () => {
+ Resend.resendUnsentEvents(room);
+ };
+
+ const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
+ Modal.createTrackedDialog('Unknown Device Dialog', '', UnknownDeviceDialog, {
+ room: room,
+ devices: unknownDevices,
+ sendAnywayLabel: _t("Send anyway"),
+ sendLabel: _t("Send"),
+ onSend: onSendClicked,
+ }, 'mx_Dialog_unknownDevice');
+ });
+}
+
+/**
+ * Show the UnknownDeviceDialog for a given room. The dialog will inform the user
+ * that a call they tried to place or answer in the room couldn't be placed or
+ * answered due to unknown devices being present.
+ *
+ * @param {MatrixClient} matrixClient A MatrixClient
+ * @param {Room} room js-sdk room object representing the room
+ * @param {func} sendAnyway Function called when the 'call anyway' or 'call'
+ * button is pressed. This should attempt to place or answer the call again.
+ * @param {string} sendAnywayLabel Label for the button displayed to retry the call
+ * when unknown devices are still present (eg. "Call Anyway")
+ * @param {string} sendLabel Label for the button displayed to retry the call
+ * after all devices have been verified (eg. "Call")
+ */
+export function showUnknownDeviceDialogForCalls(matrixClient, room, sendAnyway, sendAnywayLabel, sendLabel) {
+ getUnknownDevicesForRoom(matrixClient, room).then((unknownDevices) => {
+ const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
+ Modal.createTrackedDialog('Unknown Device Dialog', '', UnknownDeviceDialog, {
+ room: room,
+ devices: unknownDevices,
+ sendAnywayLabel: sendAnywayLabel,
+ sendLabel: sendLabel,
+ onSend: sendAnyway,
+ }, 'mx_Dialog_unknownDevice');
+ });
+}
diff --git a/src/dispatcher.js b/src/dispatcher.js
index be74dc856e..48c8dc86e9 100644
--- a/src/dispatcher.js
+++ b/src/dispatcher.js
@@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
+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.
@@ -20,14 +21,24 @@ const flux = require("flux");
class MatrixDispatcher extends flux.Dispatcher {
/**
- * @param {Object} payload Required. The payload to dispatch.
- * Must contain at least an 'action' key.
+ * @param {Object|function} payload Required. The payload to dispatch.
+ * If an Object, must contain at least an 'action' key.
+ * If a function, must have the signature (dispatch) => {...}.
* @param {boolean=} sync Optional. Pass true to dispatch
* synchronously. This is useful for anything triggering
* an operation that the browser requires user interaction
* for.
*/
dispatch(payload, sync) {
+ // Allow for asynchronous dispatching by accepting payloads that have the
+ // type `function (dispatch) {...}`
+ if (typeof payload === 'function') {
+ payload((action) => {
+ this.dispatch(action, sync);
+ });
+ return;
+ }
+
if (sync) {
super.dispatch(payload);
} else {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index efb174c445..f28322398c 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2,6 +2,13 @@
"This email address is already in use": "This email address is already in use",
"This phone number is already in use": "This phone number is already in use",
"Failed to verify email address: make sure you clicked the link in the email": "Failed to verify email address: make sure you clicked the link in the email",
+ "Call Failed": "Call Failed",
+ "There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.": "There are unknown devices in this room: if you proceed without verifying them, it will be possible for someone to eavesdrop on your call.",
+ "Review Devices": "Review Devices",
+ "Call Anyway": "Call Anyway",
+ "Answer Anyway": "Answer Anyway",
+ "Call": "Call",
+ "Answer": "Answer",
"Call Timeout": "Call Timeout",
"The remote side failed to pick up": "The remote side failed to pick up",
"Unable to capture screen": "Unable to capture screen",
@@ -63,7 +70,7 @@
"This email address was not found": "This email address was not found",
"Your email address does not appear to be associated with a Matrix ID on this Homeserver.": "Your email address does not appear to be associated with a Matrix ID on this Homeserver.",
"Default": "Default",
- "User": "User",
+ "Restricted": "Restricted",
"Moderator": "Moderator",
"Admin": "Admin",
"Start a chat": "Start a chat",
@@ -150,19 +157,46 @@
"%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s",
"%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s",
"%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s",
- "Communities": "Communities",
- "Message Pinning": "Message Pinning",
"%(displayName)s is typing": "%(displayName)s is typing",
"%(names)s and %(count)s others are typing|other": "%(names)s and %(count)s others are typing",
"%(names)s and %(count)s others are typing|one": "%(names)s and one other is typing",
"%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing",
"Failure to create room": "Failure to create room",
"Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.",
+ "Send anyway": "Send anyway",
+ "Send": "Send",
"Unnamed Room": "Unnamed Room",
"Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions",
"Not a valid Riot keyfile": "Not a valid Riot keyfile",
"Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?",
"Failed to join room": "Failed to join room",
+ "Message Pinning": "Message Pinning",
+ "Presence Management": "Presence Management",
+ "Tag Panel": "Tag Panel",
+ "Disable Emoji suggestions while typing": "Disable Emoji suggestions while typing",
+ "Use compact timeline layout": "Use compact timeline layout",
+ "Hide removed messages": "Hide removed messages",
+ "Hide join/leave messages (invites/kicks/bans unaffected)": "Hide join/leave messages (invites/kicks/bans unaffected)",
+ "Hide avatar changes": "Hide avatar changes",
+ "Hide display name changes": "Hide display name changes",
+ "Hide read receipts": "Hide read receipts",
+ "Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)",
+ "Always show message timestamps": "Always show message timestamps",
+ "Autoplay GIFs and videos": "Autoplay GIFs and videos",
+ "Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting",
+ "Hide avatars in user and room mentions": "Hide avatars in user and room mentions",
+ "Disable big emoji in chat": "Disable big emoji in chat",
+ "Don't send typing notifications": "Don't send typing notifications",
+ "Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
+ "Mirror local video feed": "Mirror local video feed",
+ "Disable Peer-to-Peer for 1:1 calls": "Disable Peer-to-Peer for 1:1 calls",
+ "Opt out of analytics": "Opt out of analytics",
+ "Never send encrypted messages to unverified devices from this device": "Never send encrypted messages to unverified devices from this device",
+ "Never send encrypted messages to unverified devices in this room from this device": "Never send encrypted messages to unverified devices in this room from this device",
+ "Enable inline URL previews by default": "Enable inline URL previews by default",
+ "Enable URL previews for this room (only affects you)": "Enable URL previews for this room (only affects you)",
+ "Enable URL previews by default for participants in this room": "Enable URL previews by default for participants in this room",
+ "Room Colour": "Room Colour",
"Active call (%(roomName)s)": "Active call (%(roomName)s)",
"unknown caller": "unknown caller",
"Incoming voice call from %(name)s": "Incoming voice call from %(name)s",
@@ -194,13 +228,14 @@
"Change Password": "Change Password",
"Your home server does not support device management.": "Your home server does not support device management.",
"Unable to load device list": "Unable to load device list",
+ "Authentication": "Authentication",
+ "Delete %(count)s devices|other": "Delete %(count)s devices",
+ "Delete %(count)s devices|one": "Delete device",
"Device ID": "Device ID",
"Device Name": "Device Name",
"Last seen": "Last seen",
+ "Select devices": "Select devices",
"Failed to set display name": "Failed to set display name",
- "Authentication": "Authentication",
- "Failed to delete device": "Failed to delete device",
- "Delete": "Delete",
"Disable Notifications": "Disable Notifications",
"Enable Notifications": "Enable Notifications",
"Cannot add any more widgets": "Cannot add any more widgets",
@@ -295,10 +330,14 @@
"No pinned messages.": "No pinned messages.",
"Loading...": "Loading...",
"Pinned Messages": "Pinned Messages",
- "for %(amount)ss": "for %(amount)ss",
- "for %(amount)sm": "for %(amount)sm",
- "for %(amount)sh": "for %(amount)sh",
- "for %(amount)sd": "for %(amount)sd",
+ "%(duration)ss": "%(duration)ss",
+ "%(duration)sm": "%(duration)sm",
+ "%(duration)sh": "%(duration)sh",
+ "%(duration)sd": "%(duration)sd",
+ "Online for %(duration)s": "Online for %(duration)s",
+ "Idle for %(duration)s": "Idle for %(duration)s",
+ "Offline for %(duration)s": "Offline for %(duration)s",
+ "Unknown for %(duration)s": "Unknown for %(duration)s",
"Online": "Online",
"Idle": "Idle",
"Offline": "Offline",
@@ -370,7 +409,6 @@
"Devices will not yet be able to decrypt history from before they joined the room": "Devices will not yet be able to decrypt history from before they joined the room",
"Once encryption is enabled for a room it cannot be turned off again (for now)": "Once encryption is enabled for a room it cannot be turned off again (for now)",
"Encrypted messages will not be visible on clients that do not yet implement encryption": "Encrypted messages will not be visible on clients that do not yet implement encryption",
- "Never send encrypted messages to unverified devices in this room from this device": "Never send encrypted messages to unverified devices in this room from this device",
"Enable encryption": "Enable encryption",
"(warning: cannot be disabled again!)": "(warning: cannot be disabled again!)",
"Encryption is enabled in this room": "Encryption is enabled in this room",
@@ -396,7 +434,6 @@
"Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
"Members only (since they were invited)": "Members only (since they were invited)",
"Members only (since they joined)": "Members only (since they joined)",
- "Room Colour": "Room Colour",
"Permissions": "Permissions",
"The default role for new room members is": "The default role for new room members is",
"To send messages, you must be a": "To send messages, you must be a",
@@ -420,25 +457,22 @@
"not specified": "not specified",
"not set": "not set",
"Remote addresses for this room:": "Remote addresses for this room:",
+ "Addresses": "Addresses",
"The main address for this room is": "The main address for this room is",
"Local addresses for this room:": "Local addresses for this room:",
"This room has no local addresses": "This room has no local addresses",
"New address (e.g. #foo:%(localDomain)s)": "New address (e.g. #foo:%(localDomain)s)",
"Invalid community ID": "Invalid community ID",
"'%(groupId)s' is not a valid community ID": "'%(groupId)s' is not a valid community ID",
- "Related Communities": "Related Communities",
- "Related communities for this room:": "Related communities for this room:",
- "This room has no related communities": "This room has no related communities",
+ "Flair": "Flair",
+ "Showing flair for these communities:": "Showing flair for these communities:",
+ "This room is not showing flair for any communities": "This room is not showing flair for any communities",
"New community ID (e.g. +foo:%(localDomain)s)": "New community ID (e.g. +foo:%(localDomain)s)",
- "Disable URL previews by default for participants in this room": "Disable URL previews by default for participants in this room",
- "URL previews are %(globalDisableUrlPreview)s by default for participants in this room.": "URL previews are %(globalDisableUrlPreview)s by default for participants in this room.",
- "disabled": "disabled",
- "enabled": "enabled",
- "You have disabled URL previews by default.": "You have disabled URL previews by default.",
"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",
- "Enable URL previews for this room (affects only you)": "Enable URL previews for this room (affects only you)",
- "Disable URL previews for this room (affects only you)": "Disable URL previews for this room (affects only you)",
"Error decrypting audio": "Error decrypting audio",
"Error decrypting attachment": "Error decrypting attachment",
"Decrypt %(text)s": "Decrypt %(text)s",
@@ -475,6 +509,7 @@
"Please enter the code it contains:": "Please enter the code it contains:",
"Start authentication": "Start authentication",
"powered by Matrix": "powered by Matrix",
+ "Username on %(hs)s": "Username on %(hs)s",
"User name": "User name",
"Mobile phone number": "Mobile phone number",
"Forgot your password?": "Forgot your password?",
@@ -498,6 +533,8 @@
"Failed to withdraw invitation": "Failed to withdraw invitation",
"Failed to remove user from community": "Failed to remove user from community",
"Filter community members": "Filter community members",
+ "Flair will appear if enabled in room settings": "Flair will appear if enabled in room settings",
+ "Flair will not appear": "Flair will not appear",
"Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Are you sure you want to remove '%(roomName)s' from %(groupId)s?",
"Removing a room from the community will also remove it from the community page.": "Removing a room from the community will also remove it from the community page.",
"Remove": "Remove",
@@ -509,6 +546,9 @@
"Visible to everyone": "Visible to everyone",
"Only visible to community members": "Only visible to community members",
"Filter community rooms": "Filter community rooms",
+ "Something went wrong when trying to get your communities.": "Something went wrong when trying to get your communities.",
+ "Display your community flair in rooms configured to show it.": "Display your community flair in rooms configured to show it.",
+ "You're not currently a member of any communities.": "You're not currently a member of any communities.",
"Unknown Address": "Unknown Address",
"NOTE: Apps are not end-to-end encrypted": "NOTE: Apps are not end-to-end encrypted",
"Do you want to load widget from URL:": "Do you want to load widget from URL:",
@@ -524,6 +564,8 @@
"Unverify": "Unverify",
"Verify...": "Verify...",
"No results": "No results",
+ "Delete": "Delete",
+ "Communities": "Communities",
"Home": "Home",
"Integrations Error": "Integrations Error",
"Could not connect to the integration server": "Could not connect to the integration server",
@@ -580,6 +622,9 @@
"%(items)s and %(count)s others|other": "%(items)s and %(count)s others",
"%(items)s and %(count)s others|one": "%(items)s and one other",
"%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s",
+ "collapse": "collapse",
+ "expand": "expand",
+ "Custom of %(powerLevel)s": "Custom of %(powerLevel)s",
"Custom level": "Custom level",
"Room directory": "Room directory",
"Start chat": "Start chat",
@@ -659,7 +704,6 @@
"Room contains unknown devices": "Room contains unknown devices",
"\"%(RoomName)s\" contains devices that you haven't seen before.": "\"%(RoomName)s\" contains devices that you haven't seen before.",
"Unknown devices": "Unknown devices",
- "Send anyway": "Send anyway",
"Private Chat": "Private Chat",
"Public Chat": "Public Chat",
"Custom": "Custom",
@@ -702,8 +746,6 @@
"%(inviter)s has invited you to join this community": "%(inviter)s has invited you to join this community",
"You are an administrator of this community": "You are an administrator of this community",
"You are a member of this community": "You are a member of this community",
- "Community Member Settings": "Community Member Settings",
- "Publish this community on your profile": "Publish this community on your profile",
"Your community hasn't got a Long Description, a HTML page to show to community members. Click here to open settings and give it one!": "Your community hasn't got a Long Description, a HTML page to show to community members. Click here to open settings and give it one!",
"Long Description (HTML)": "Long Description (HTML)",
"Description": "Description",
@@ -717,9 +759,12 @@
"Failed to leave room": "Failed to leave room",
"Signed Out": "Signed Out",
"For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.",
+ "Cryptography data migrated": "Cryptography data migrated",
+ "A one-off migration of cryptography data has been performed. End-to-end encryption will not work if you go back to an older version of Riot. If you need to use end-to-end cryptography on an older version, log out of Riot first. To retain message history, export and re-import your keys.": "A one-off migration of cryptography data has been performed. End-to-end encryption will not work if you go back to an older version of Riot. If you need to use end-to-end cryptography on an older version, log out of Riot first. To retain message history, export and re-import your keys.",
+ "Old cryptography data detected": "Old cryptography data detected",
+ "Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.",
"Logout": "Logout",
"Your Communities": "Your Communities",
- "You're not currently a member of any communities.": "You're not currently a member of any communities.",
"Error whilst fetching joined communities": "Error whilst fetching joined communities",
"Create a new community": "Create a new community",
"Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.",
@@ -727,17 +772,19 @@
"To join an existing community you'll have to know its community identifier; this will look something like +example:matrix.org.": "To join an existing community you'll have to know its community identifier; this will look something like +example:matrix.org.",
"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.",
+ "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.",
+ "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.",
- "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.",
"%(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?",
- "Some of your messages have not been sent.": "Some of your messages have not been sent.",
- "Message not sent due to unknown devices being present": "Message not sent due to unknown devices being present",
"Failed to upload file": "Failed to upload file",
"Server may be unavailable, overloaded, or the file too big": "Server may be unavailable, overloaded, or the file too big",
"Search failed": "Search failed",
@@ -758,26 +805,9 @@
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
- "Autoplay GIFs and videos": "Autoplay GIFs and videos",
- "Hide read receipts": "Hide read receipts",
- "Don't send typing notifications": "Don't send typing notifications",
- "Always show message timestamps": "Always show message timestamps",
- "Show timestamps in 12 hour format (e.g. 2:30pm)": "Show timestamps in 12 hour format (e.g. 2:30pm)",
- "Hide join/leave messages (invites/kicks/bans unaffected)": "Hide join/leave messages (invites/kicks/bans unaffected)",
- "Hide avatar and display name changes": "Hide avatar and display name changes",
- "Use compact timeline layout": "Use compact timeline layout",
- "Hide removed messages": "Hide removed messages",
- "Enable automatic language detection for syntax highlighting": "Enable automatic language detection for syntax highlighting",
- "Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
- "Disable Emoji suggestions while typing": "Disable Emoji suggestions while typing",
- "Hide avatars in user and room mentions": "Hide avatars in user and room mentions",
- "Disable big emoji in chat": "Disable big emoji in chat",
- "Mirror local video feed": "Mirror local video feed",
- "Opt out of analytics": "Opt out of analytics",
- "Disable Peer-to-Peer for 1:1 calls": "Disable Peer-to-Peer for 1:1 calls",
- "Never send encrypted messages to unverified devices from this device": "Never send encrypted messages to unverified devices from this device",
"Light theme": "Light theme",
"Dark theme": "Dark theme",
+ "Status.im theme": "Status.im theme",
"Can't load user settings": "Can't load user settings",
"Server may be unavailable or overloaded": "Server may be unavailable or overloaded",
"Sign out": "Sign out",
@@ -792,7 +822,6 @@
"Interface Language": "Interface Language",
"User Interface": "User Interface",
"Autocomplete Delay (ms):": "Autocomplete Delay (ms):",
- "Disable inline URL previews by default": "Disable inline URL previews by default",
"": "",
"Import E2E room keys": "Import E2E room keys",
"Cryptography": "Cryptography",
@@ -857,14 +886,15 @@
"Create an account": "Create an account",
"This Home Server does not support login using email address.": "This Home Server does not support login using email address.",
"Incorrect username and/or password.": "Incorrect username and/or password.",
+ "Please note you are logging into the %(hs)s server, not matrix.org.": "Please note you are logging into the %(hs)s server, not matrix.org.",
"Guest access is disabled on this Home Server.": "Guest access is disabled on this Home Server.",
"The phone number entered looks invalid": "The phone number entered looks invalid",
+ "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.",
"Error: Problem communicating with the given homeserver.": "Error: Problem communicating with the given homeserver.",
"Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.",
"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.": "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.",
- "This homeserver doesn't offer any login flows which are supported by this client.": "This homeserver doesn't offer any login flows which are supported by this client.",
"Login as guest": "Login as guest",
- "Return to app": "Return to app",
+ "Sign in to get started": "Sign in to get started",
"Failed to fetch avatar URL": "Failed to fetch avatar URL",
"Set a display name:": "Set a display name:",
"Upload an avatar:": "Upload an avatar:",
diff --git a/src/languageHandler.js b/src/languageHandler.js
index da62bfee56..e732927a75 100644
--- a/src/languageHandler.js
+++ b/src/languageHandler.js
@@ -19,11 +19,14 @@ import request from 'browser-request';
import counterpart from 'counterpart';
import Promise from 'bluebird';
import React from 'react';
-
-import UserSettingsStore from './UserSettingsStore';
+import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
const i18nFolder = 'i18n/';
+// Control whether to also return original, untranslated strings
+// Useful for debugging and testing
+const ANNOTATE_STRINGS = false;
+
// We use english strings as keys, some of which contain full stops
counterpart.setSeparator('|');
// Fall back to English
@@ -35,12 +38,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
@@ -51,11 +51,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';
}
});
@@ -64,75 +64,167 @@ 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);
- for (let i = 0; i < patterns.length; i++) {
- // convert the last element in 'output' into 3 elements (pre-text, sub function, post-text).
- // Rinse and repeat for other patterns (using post-text).
- const inputText = output.pop();
- const match = inputText.match(patterns[i]);
- if (!match) {
- throw new Error(`_tJsx: translator error. expected translation to match regexp: ${patterns[i]}`);
+ let substituted = substitute(translated, variables, tags);
+
+ // For development/testing purposes it is useful to also output the original string
+ // Don't do that for release versions
+ if (ANNOTATE_STRINGS) {
+ if (typeof substituted === 'string') {
+ return `@@${text}##${substituted}@@`
}
- const capturedGroups = match.slice(1);
+ else {
+ return {substituted};
+ }
+ }
+ else {
+ return substituted;
+ }
+}
- // 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));
+/*
+ * 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];
+ }
}
- // 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 (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) {
+ // We initially store our output as an array of strings and objects (e.g. React components).
+ // This will then be converted to a string or a at the end
+ 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);
+
+ // Loop over what output we have so far and perform replacements
+ // We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
+ // and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
+ // Otherwise there would be no need for the splitting and we could do simple replcement.
+ let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
+ for (const outputIndex in output) {
+ const inputText = output[outputIndex];
+ if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them
+ continue;
+ }
+
+ const match = inputText.match(regexp);
+ if (!match) {
+ continue;
+ }
+ matchFoundSomewhere = true;
+
+ const capturedGroups = match.slice(2);
+
+ // The textual part before the match
+ const head = inputText.substr(0, match.index);
+
+ // The textual part after the match
+ const tail = inputText.substr(match.index + match[0].length);
+
+ let replaced;
+ // If substitution is a function, call it
+ if (mapping[regexpString] instanceof Function) {
+ replaced = mapping[regexpString].apply(null, capturedGroups);
+ } else {
+ replaced = mapping[regexpString];
+ }
+
+ if (typeof replaced === 'object') {
+ shouldWrapInSpan = true;
+ }
+
+ output.splice(outputIndex, 1); // Remove old element
+
+ // Insert in reverse order as splice does insert-before and this way we get the final order correct
+ if (tail !== '') {
+ output.splice(outputIndex, 0, tail);
+ }
+
+ // 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.splice(outputIndex, 0, replaced);
+ }
+
+ if (head !== '') { // Don't push empty nodes, they are of no use
+ output.splice(outputIndex, 0, head);
+ }
+ }
+ if (!matchFoundSomewhere) { // The current regexp did not match anything in the 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 ${text}`);
+ }
+ }
+ }
+
+ if (shouldWrapInSpan) {
+ return React.createElement('span', null, ...output);
+ } else {
+ return output.join('');
+ }
}
// Allow overriding the text displayed when no translation exists
@@ -168,7 +260,7 @@ export function setLanguage(preferredLangs) {
}).then((langData) => {
counterpart.registerTranslations(langToUse, langData);
counterpart.setLocale(langToUse);
- UserSettingsStore.setLocalSetting('language', langToUse);
+ SettingsStore.setValue("language", null, SettingLevel.DEVICE, langToUse);
console.log("set language to " + langToUse);
// Set 'en' as fallback language:
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
new file mode 100644
index 0000000000..07de17ccfd
--- /dev/null
+++ b/src/settings/Settings.js
@@ -0,0 +1,258 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 {_td} from '../languageHandler';
+import {
+ AudioNotificationsEnabledController,
+ NotificationBodyEnabledController,
+ NotificationsEnabledController,
+} from "./controllers/NotificationControllers";
+
+
+// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
+const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config'];
+const LEVELS_ROOM_SETTINGS_WITH_ROOM = ['device', 'room-device', 'room-account', 'account', 'config', 'room'];
+const LEVELS_ACCOUNT_SETTINGS = ['device', 'account', 'config'];
+const LEVELS_FEATURE = ['device', 'config'];
+const LEVELS_DEVICE_ONLY_SETTINGS = ['device'];
+const LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG = ['device', 'config'];
+
+export const SETTINGS = {
+ // EXAMPLE SETTING:
+ // "my-setting": {
+ // // Must be set to true for features. Default is 'false'.
+ // isFeature: false,
+ //
+ // // Display names are strongly recommended for clarity.
+ // displayName: _td("Cool Name"),
+ //
+ // // Display name can also be an object for different levels.
+ // //displayName: {
+ // // "device": _td("Name for when the setting is used at 'device'"),
+ // // "room": _td("Name for when the setting is used at 'room'"),
+ // // "default": _td("The name for all other levels"),
+ // //}
+ //
+ // // The supported levels are required. Preferably, use the preset arrays
+ // // at the top of this file to define this rather than a custom array.
+ // supportedLevels: [
+ // // The order does not matter.
+ //
+ // "device", // Affects the current device only
+ // "room-device", // Affects the current room on the current device
+ // "room-account", // Affects the current room for the current account
+ // "account", // Affects the current account
+ // "room", // Affects the current room (controlled by room admins)
+ // "config", // Affects the current application
+ //
+ // // "default" is always supported and does not get listed here.
+ // ],
+ //
+ // // Required. Can be any data type. The value specified here should match
+ // // the data being stored (ie: if a boolean is used, the setting should
+ // // represent a boolean).
+ // default: {
+ // your: "value",
+ // },
+ //
+ // // Optional settings controller. See SettingsController for more information.
+ // controller: new MySettingController(),
+ //
+ // // Optional flag to make supportedLevels be respected as the order to handle
+ // // settings. The first element is treated as "most preferred". The "default"
+ // // level is always appended to the end.
+ // supportedLevelsAreOrdered: false,
+ // },
+ "feature_pinning": {
+ isFeature: true,
+ displayName: _td("Message Pinning"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
+ "feature_presence_management": {
+ isFeature: true,
+ displayName: _td("Presence Management"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
+ "feature_tag_panel": {
+ isFeature: true,
+ displayName: _td("Tag Panel"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
+ "MessageComposerInput.dontSuggestEmoji": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Disable Emoji suggestions while typing'),
+ default: false,
+ },
+ "useCompactLayout": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Use compact timeline layout'),
+ default: false,
+ },
+ "hideRedactions": {
+ supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
+ displayName: _td('Hide removed messages'),
+ default: false,
+ },
+ "hideJoinLeaves": {
+ supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
+ displayName: _td('Hide join/leave messages (invites/kicks/bans unaffected)'),
+ default: false,
+ },
+ "hideAvatarChanges": {
+ supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
+ displayName: _td('Hide avatar changes'),
+ default: false,
+ },
+ "hideDisplaynameChanges": {
+ supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
+ displayName: _td('Hide display name changes'),
+ default: false,
+ },
+ "hideReadReceipts": {
+ supportedLevels: LEVELS_ROOM_SETTINGS,
+ displayName: _td('Hide read receipts'),
+ default: false,
+ },
+ "showTwelveHourTimestamps": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Show timestamps in 12 hour format (e.g. 2:30pm)'),
+ default: false,
+ },
+ "alwaysShowTimestamps": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Always show message timestamps'),
+ default: false,
+ },
+ "autoplayGifsAndVideos": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Autoplay GIFs and videos'),
+ default: false,
+ },
+ "enableSyntaxHighlightLanguageDetection": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Enable automatic language detection for syntax highlighting'),
+ default: false,
+ },
+ "Pill.shouldHidePillAvatar": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Hide avatars in user and room mentions'),
+ default: false,
+ },
+ "TextualBody.disableBigEmoji": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Disable big emoji in chat'),
+ default: false,
+ },
+ "MessageComposerInput.isRichTextEnabled": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ default: false,
+ },
+ "MessageComposer.showFormatting": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ default: false,
+ },
+ "dontSendTypingNotifications": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td("Don't send typing notifications"),
+ default: false,
+ },
+ "MessageComposerInput.autoReplaceEmoji": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Automatically replace plain text Emoji'),
+ default: false,
+ },
+ "VideoView.flipVideoHorizontally": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ displayName: _td('Mirror local video feed'),
+ default: false,
+ },
+ "theme": {
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ default: "light",
+ },
+ "webRtcForceTURN": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
+ displayName: _td('Disable Peer-to-Peer for 1:1 calls'),
+ default: false,
+ },
+ "webrtc_audioinput": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+ default: null,
+ },
+ "webrtc_videoinput": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+ default: null,
+ },
+ "language": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
+ default: "en",
+ },
+ "analyticsOptOut": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
+ displayName: _td('Opt out of analytics'),
+ default: false,
+ },
+ "autocompleteDelay": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
+ default: 200,
+ },
+ "blacklistUnverifiedDevices": {
+ // We specifically want to have room-device > device so that users may set a device default
+ // with a per-room override.
+ supportedLevels: ['room-device', 'device'],
+ supportedLevelsAreOrdered: true,
+ displayName: {
+ "default": _td('Never send encrypted messages to unverified devices from this device'),
+ "room-device": _td('Never send encrypted messages to unverified devices in this room from this device'),
+ },
+ default: false,
+ },
+ "urlPreviewsEnabled": {
+ supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
+ displayName: {
+ "default": _td('Enable inline URL previews by default'),
+ "room-account": _td("Enable URL previews for this room (only affects you)"),
+ "room": _td("Enable URL previews by default for participants in this room"),
+ },
+ default: true,
+ },
+ "roomColor": {
+ supportedLevels: LEVELS_ROOM_SETTINGS_WITH_ROOM,
+ displayName: _td("Room Colour"),
+ default: {
+ primary_color: null, // Hex string, eg: #000000
+ secondary_color: null, // Hex string, eg: #000000
+ },
+ },
+ "notificationsEnabled": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+ default: false,
+ controller: new NotificationsEnabledController(),
+ },
+ "notificationBodyEnabled": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+ default: true,
+ controller: new NotificationBodyEnabledController(),
+ },
+ "audioNotificationsEnabled": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+ default: true,
+ controller: new AudioNotificationsEnabledController(),
+ },
+};
diff --git a/src/settings/SettingsStore.js b/src/settings/SettingsStore.js
new file mode 100644
index 0000000000..d93a48005d
--- /dev/null
+++ b/src/settings/SettingsStore.js
@@ -0,0 +1,355 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 DeviceSettingsHandler from "./handlers/DeviceSettingsHandler";
+import RoomDeviceSettingsHandler from "./handlers/RoomDeviceSettingsHandler";
+import DefaultSettingsHandler from "./handlers/DefaultSettingsHandler";
+import RoomAccountSettingsHandler from "./handlers/RoomAccountSettingsHandler";
+import AccountSettingsHandler from "./handlers/AccountSettingsHandler";
+import RoomSettingsHandler from "./handlers/RoomSettingsHandler";
+import ConfigSettingsHandler from "./handlers/ConfigSettingsHandler";
+import {_t} from '../languageHandler';
+import SdkConfig from "../SdkConfig";
+import {SETTINGS} from "./Settings";
+import LocalEchoWrapper from "./handlers/LocalEchoWrapper";
+
+/**
+ * Represents the various setting levels supported by the SettingsStore.
+ */
+export const SettingLevel = {
+ // Note: This enum is not used in this class or in the Settings file
+ // This should always be used elsewhere in the project.
+ DEVICE: "device",
+ ROOM_DEVICE: "room-device",
+ ROOM_ACCOUNT: "room-account",
+ ACCOUNT: "account",
+ ROOM: "room",
+ CONFIG: "config",
+ DEFAULT: "default",
+};
+
+// Convert the settings to easier to manage objects for the handlers
+const defaultSettings = {};
+const featureNames = [];
+for (const key of Object.keys(SETTINGS)) {
+ defaultSettings[key] = SETTINGS[key].default;
+ if (SETTINGS[key].isFeature) featureNames.push(key);
+}
+
+const LEVEL_HANDLERS = {
+ "device": new DeviceSettingsHandler(featureNames),
+ "room-device": new RoomDeviceSettingsHandler(),
+ "room-account": new RoomAccountSettingsHandler(),
+ "account": new AccountSettingsHandler(),
+ "room": new RoomSettingsHandler(),
+ "config": new ConfigSettingsHandler(),
+ "default": new DefaultSettingsHandler(defaultSettings),
+};
+
+// Wrap all the handlers with local echo
+for (const key of Object.keys(LEVEL_HANDLERS)) {
+ LEVEL_HANDLERS[key] = new LocalEchoWrapper(LEVEL_HANDLERS[key]);
+}
+
+const LEVEL_ORDER = [
+ 'device', 'room-device', 'room-account', 'account', 'room', 'config', 'default',
+];
+
+/**
+ * Controls and manages application settings by providing varying levels at which the
+ * setting value may be specified. The levels are then used to determine what the setting
+ * value should be given a set of circumstances. The levels, in priority order, are:
+ * - "device" - Values are determined by the current device
+ * - "room-device" - Values are determined by the current device for a particular room
+ * - "room-account" - Values are determined by the current account for a particular room
+ * - "account" - Values are determined by the current account
+ * - "room" - Values are determined by a particular room (by the room admins)
+ * - "config" - Values are determined by the config.json
+ * - "default" - Values are determined by the hardcoded defaults
+ *
+ * Each level has a different method to storing the setting value. For implementation
+ * specific details, please see the handlers. The "config" and "default" levels are
+ * both always supported on all platforms. All other settings should be guarded by
+ * isLevelSupported() prior to attempting to set the value.
+ *
+ * Settings can also represent features. Features are significant portions of the
+ * application that warrant a dedicated setting to toggle them on or off. Features are
+ * special-cased to ensure that their values respect the configuration (for example, a
+ * feature may be reported as disabled even though a user has specifically requested it
+ * be enabled).
+ */
+export default class SettingsStore {
+ /**
+ * Gets the translated display name for a given setting
+ * @param {string} settingName The setting to look up.
+ * @param {"device"|"room-device"|"room-account"|"account"|"room"|"config"|"default"} atLevel
+ * The level to get the display name for; Defaults to 'default'.
+ * @return {String} The display name for the setting, or null if not found.
+ */
+ static getDisplayName(settingName, atLevel = "default") {
+ if (!SETTINGS[settingName] || !SETTINGS[settingName].displayName) return null;
+
+ let displayName = SETTINGS[settingName].displayName;
+ if (displayName instanceof Object) {
+ if (displayName[atLevel]) displayName = displayName[atLevel];
+ else displayName = displayName["default"];
+ }
+
+ return _t(displayName);
+ }
+
+ /**
+ * Returns a list of all available labs feature names
+ * @returns {string[]} The list of available feature names
+ */
+ static getLabsFeatures() {
+ const possibleFeatures = Object.keys(SETTINGS).filter((s) => SettingsStore.isFeature(s));
+
+ const enableLabs = SdkConfig.get()["enableLabs"];
+ if (enableLabs) return possibleFeatures;
+
+ return possibleFeatures.filter((s) => SettingsStore._getFeatureState(s) === "labs");
+ }
+
+ /**
+ * Determines if a setting is also a feature.
+ * @param {string} settingName The setting to look up.
+ * @return {boolean} True if the setting is a feature.
+ */
+ static isFeature(settingName) {
+ if (!SETTINGS[settingName]) return false;
+ return SETTINGS[settingName].isFeature;
+ }
+
+ /**
+ * Determines if a given feature is enabled. The feature given must be a known
+ * feature.
+ * @param {string} settingName The name of the setting that is a feature.
+ * @param {String} roomId The optional room ID to validate in, may be null.
+ * @return {boolean} True if the feature is enabled, false otherwise
+ */
+ static isFeatureEnabled(settingName, roomId = null) {
+ if (!SettingsStore.isFeature(settingName)) {
+ throw new Error("Setting " + settingName + " is not a feature");
+ }
+
+ return SettingsStore.getValue(settingName, roomId);
+ }
+
+ /**
+ * Sets a feature as enabled or disabled on the current device.
+ * @param {string} settingName The name of the setting.
+ * @param {boolean} value True to enable the feature, false otherwise.
+ * @returns {Promise} Resolves when the setting has been set.
+ */
+ static setFeatureEnabled(settingName, value) {
+ // Verify that the setting is actually a setting
+ if (!SETTINGS[settingName]) {
+ throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
+ }
+ if (!SettingsStore.isFeature(settingName)) {
+ throw new Error("Setting " + settingName + " is not a feature");
+ }
+
+ return SettingsStore.setValue(settingName, null, "device", value);
+ }
+
+ /**
+ * Gets the value of a setting. The room ID is optional if the setting is not to
+ * be applied to any particular room, otherwise it should be supplied.
+ * @param {string} settingName The name of the setting to read the value of.
+ * @param {String} roomId The room ID to read the setting value in, may be null.
+ * @param {boolean} excludeDefault True to disable using the default value.
+ * @return {*} The value, or null if not found
+ */
+ static getValue(settingName, roomId = null, excludeDefault = false) {
+ // 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"|"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
+ * provided. Defaults to false.
+ * @param {boolean} excludeDefault True to disable using the default value.
+ * @return {*} The value, or null if not found.
+ */
+ static getValueAt(level, settingName, roomId = null, explicit = false, excludeDefault = false) {
+ // 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);
+ if (!levelOrder.includes("default")) levelOrder.push("default"); // always include default
+
+ const minIndex = levelOrder.indexOf(level);
+ if (minIndex === -1) throw new Error("Level " + level + " is not prioritized");
+
+ if (SettingsStore.isFeature(settingName)) {
+ const configValue = SettingsStore._getFeatureState(settingName);
+ if (configValue === "enable") return true;
+ if (configValue === "disable") return false;
+ // else let it fall through the default process
+ }
+
+ const handlers = SettingsStore._getHandlers(settingName);
+
+ if (explicit) {
+ const handler = handlers[level];
+ if (!handler) return SettingsStore._tryControllerOverride(settingName, level, roomId, null);
+ const value = handler.getValue(settingName, roomId);
+ return SettingsStore._tryControllerOverride(settingName, level, roomId, value);
+ }
+
+ for (let i = minIndex; i < levelOrder.length; i++) {
+ const handler = handlers[levelOrder[i]];
+ if (!handler) continue;
+ if (excludeDefault && levelOrder[i] === "default") continue;
+
+ const value = handler.getValue(settingName, roomId);
+ if (value === null || value === undefined) continue;
+ return SettingsStore._tryControllerOverride(settingName, level, roomId, value);
+ }
+
+ return SettingsStore._tryControllerOverride(settingName, level, roomId, null);
+ }
+
+ static _tryControllerOverride(settingName, level, roomId, calculatedValue) {
+ const controller = SETTINGS[settingName].controller;
+ if (!controller) return calculatedValue;
+
+ const actualValue = controller.getValueOverride(level, roomId, calculatedValue);
+ if (actualValue !== undefined && actualValue !== null) return actualValue;
+ return calculatedValue;
+ }
+
+ /**
+ * Sets the value for a setting. The room ID is optional if the setting is not being
+ * set for a particular room, otherwise it should be supplied. The value may be null
+ * to indicate that the level should no longer have an override.
+ * @param {string} settingName The name of the setting to change.
+ * @param {String} roomId The room ID to change the value in, may be null.
+ * @param {"device"|"room-device"|"room-account"|"account"|"room"} level The level
+ * to change the value at.
+ * @param {*} value The new value of the setting, may be null.
+ * @return {Promise} Resolves when the setting has been changed.
+ */
+ static setValue(settingName, roomId, level, value) {
+ // Verify that the setting is actually a setting
+ if (!SETTINGS[settingName]) {
+ throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
+ }
+
+ const handler = SettingsStore._getHandler(settingName, level);
+ if (!handler) {
+ throw new Error("Setting " + settingName + " does not have a handler for " + level);
+ }
+
+ if (!handler.canSetValue(settingName, roomId)) {
+ throw new Error("User cannot set " + settingName + " at " + level + " in " + roomId);
+ }
+
+ return handler.setValue(settingName, roomId, value).then(() => {
+ const controller = SETTINGS[settingName].controller;
+ if (!controller) return;
+ controller.onChange(level, roomId, value);
+ });
+ }
+
+ /**
+ * Determines if the current user is permitted to set the given setting at the given
+ * level for a particular room. The room ID is optional if the setting is not being
+ * set for a particular room, otherwise it should be supplied.
+ * @param {string} settingName The name of the setting to check.
+ * @param {String} roomId The room ID to check in, may be null.
+ * @param {"device"|"room-device"|"room-account"|"account"|"room"} level The level to
+ * check at.
+ * @return {boolean} True if the user may set the setting, false otherwise.
+ */
+ static canSetValue(settingName, roomId, level) {
+ // Verify that the setting is actually a setting
+ if (!SETTINGS[settingName]) {
+ throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
+ }
+
+ const handler = SettingsStore._getHandler(settingName, level);
+ if (!handler) return false;
+ return handler.canSetValue(settingName, roomId);
+ }
+
+ /**
+ * Determines if the given level is supported on this device.
+ * @param {"device"|"room-device"|"room-account"|"account"|"room"} level The level
+ * to check the feasibility of.
+ * @return {boolean} True if the level is supported, false otherwise.
+ */
+ static isLevelSupported(level) {
+ if (!LEVEL_HANDLERS[level]) return false;
+ return LEVEL_HANDLERS[level].isSupported();
+ }
+
+ static _getHandler(settingName, level) {
+ const handlers = SettingsStore._getHandlers(settingName);
+ if (!handlers[level]) return null;
+ return handlers[level];
+ }
+
+ static _getHandlers(settingName) {
+ if (!SETTINGS[settingName]) return {};
+
+ const handlers = {};
+ for (const level of SETTINGS[settingName].supportedLevels) {
+ if (!LEVEL_HANDLERS[level]) throw new Error("Unexpected level " + level);
+ handlers[level] = LEVEL_HANDLERS[level];
+ }
+
+ // Always support 'default'
+ if (!handlers['default']) handlers['default'] = LEVEL_HANDLERS['default'];
+
+ return handlers;
+ }
+
+ static _getFeatureState(settingName) {
+ const featuresConfig = SdkConfig.get()['features'];
+ const enableLabs = SdkConfig.get()['enableLabs']; // we'll honour the old flag
+
+ let featureState = enableLabs ? "labs" : "disable";
+ if (featuresConfig && featuresConfig[settingName] !== undefined) {
+ featureState = featuresConfig[settingName];
+ }
+
+ const allowedStates = ['enable', 'disable', 'labs'];
+ if (!allowedStates.includes(featureState)) {
+ console.warn("Feature state '" + featureState + "' is invalid for " + settingName);
+ featureState = "disable"; // to prevent accidental features.
+ }
+
+ return featureState;
+ }
+}
diff --git a/src/settings/controllers/NotificationControllers.js b/src/settings/controllers/NotificationControllers.js
new file mode 100644
index 0000000000..9dcf78e26b
--- /dev/null
+++ b/src/settings/controllers/NotificationControllers.js
@@ -0,0 +1,79 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 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;
+
+ if (calculatedValue === null) {
+ return isMasterRuleEnabled();
+ }
+
+ return calculatedValue;
+ }
+
+ onChange(level, roomId, newValue) {
+ const Notifier = require('../../Notifier'); // avoids cyclical references
+
+ if (Notifier.supportsDesktopNotifications()) {
+ Notifier.setEnabled(newValue);
+ }
+ }
+}
+
+export class NotificationBodyEnabledController extends SettingController {
+ getValueOverride(level, roomId, calculatedValue) {
+ const Notifier = require('../../Notifier'); // avoids cyclical references
+ if (!Notifier.isPossible()) return false;
+
+ 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;
+
+ // Note: Audio notifications are *not* enabled by default.
+ return calculatedValue;
+ }
+}
diff --git a/src/settings/controllers/SettingController.js b/src/settings/controllers/SettingController.js
new file mode 100644
index 0000000000..a91b616da9
--- /dev/null
+++ b/src/settings/controllers/SettingController.js
@@ -0,0 +1,49 @@
+/*
+Copyright 2017 Travis Ralston
+
+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.
+*/
+
+/**
+ * Represents a controller for individual settings to alter the reading behaviour
+ * based upon environmental conditions, or to react to changes and therefore update
+ * the working environment.
+ *
+ * This is not intended to replace the functionality of a SettingsHandler, it is only
+ * intended to handle environmental factors for specific settings.
+ */
+export default class SettingController {
+
+ /**
+ * Gets the overridden value for the setting, if any. This must return null if the
+ * value is not to be overridden, otherwise it must return the new value.
+ * @param {string} level The level at which the value was requested at.
+ * @param {String} roomId The room ID, may be null.
+ * @param {*} calculatedValue The value that the handlers think the setting should be,
+ * may be null.
+ * @return {*} The value that should be used, or null if no override is applicable.
+ */
+ getValueOverride(level, roomId, calculatedValue) {
+ return null; // no override
+ }
+
+ /**
+ * Called when the setting value has been changed.
+ * @param {string} level The level at which the setting has been modified.
+ * @param {String} roomId The room ID, may be null.
+ * @param {*} newValue The new value for the setting, may be null.
+ */
+ onChange(level, roomId, newValue) {
+ // do nothing by default
+ }
+}
diff --git a/src/settings/handlers/AccountSettingsHandler.js b/src/settings/handlers/AccountSettingsHandler.js
new file mode 100644
index 0000000000..e50358a728
--- /dev/null
+++ b/src/settings/handlers/AccountSettingsHandler.js
@@ -0,0 +1,76 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 SettingsHandler from "./SettingsHandler";
+import MatrixClientPeg from '../../MatrixClientPeg';
+
+/**
+ * Gets and sets settings at the "account" level for the current user.
+ * This handler does not make use of the roomId parameter.
+ */
+export default class AccountSettingHandler extends SettingsHandler {
+ getValue(settingName, roomId) {
+ // 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'];
+ }
+
+ let preferredValue = this._getSettings()[settingName];
+
+ if (preferredValue === null || preferredValue === undefined) {
+ // Honour the old setting on read only
+ if (settingName === "hideAvatarChanges" || settingName === "hideDisplaynameChanges") {
+ preferredValue = this._getSettings()["hideAvatarDisplaynameChanges"];
+ }
+ }
+
+ return preferredValue;
+ }
+
+ setValue(settingName, roomId, newValue) {
+ // Special case URL previews
+ if (settingName === "urlPreviewsEnabled") {
+ const content = this._getSettings("org.matrix.preview_urls");
+ content['disable'] = !newValue;
+ return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", content);
+ }
+
+ const content = this._getSettings();
+ content[settingName] = newValue;
+ return MatrixClientPeg.get().setAccountData("im.vector.web.settings", content);
+ }
+
+ canSetValue(settingName, roomId) {
+ return true; // It's their account, so they should be able to
+ }
+
+ isSupported() {
+ const cli = MatrixClientPeg.get();
+ return cli !== undefined && cli !== null;
+ }
+
+ _getSettings(eventType = "im.vector.web.settings") {
+ const cli = MatrixClientPeg.get();
+ if (!cli) return {};
+ const event = cli.getAccountData(eventType);
+ if (!event || !event.getContent()) return {};
+ return event.getContent();
+ }
+}
diff --git a/src/settings/handlers/ConfigSettingsHandler.js b/src/settings/handlers/ConfigSettingsHandler.js
new file mode 100644
index 0000000000..7a370249a7
--- /dev/null
+++ b/src/settings/handlers/ConfigSettingsHandler.js
@@ -0,0 +1,49 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 SettingsHandler from "./SettingsHandler";
+import SdkConfig from "../../SdkConfig";
+
+/**
+ * Gets and sets settings at the "config" level. This handler does not make use of the
+ * roomId parameter.
+ */
+export default class ConfigSettingsHandler extends SettingsHandler {
+ getValue(settingName, roomId) {
+ const config = SdkConfig.get() || {};
+
+ // Special case themes
+ if (settingName === "theme") {
+ return config["default_theme"];
+ }
+
+ const settingsConfig = config["settingDefaults"];
+ if (!settingsConfig || !settingsConfig[settingName]) return null;
+ return settingsConfig[settingName];
+ }
+
+ setValue(settingName, roomId, newValue) {
+ throw new Error("Cannot change settings at the config level");
+ }
+
+ canSetValue(settingName, roomId) {
+ return false;
+ }
+
+ isSupported() {
+ return true; // SdkConfig is always there
+ }
+}
diff --git a/src/settings/handlers/DefaultSettingsHandler.js b/src/settings/handlers/DefaultSettingsHandler.js
new file mode 100644
index 0000000000..cf2e660411
--- /dev/null
+++ b/src/settings/handlers/DefaultSettingsHandler.js
@@ -0,0 +1,48 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 SettingsHandler from "./SettingsHandler";
+
+/**
+ * Gets settings at the "default" level. This handler does not support setting values.
+ * This handler does not make use of the roomId parameter.
+ */
+export default class DefaultSettingsHandler extends SettingsHandler {
+ /**
+ * Creates a new default settings handler with the given defaults
+ * @param {object} defaults The default setting values, keyed by setting name.
+ */
+ constructor(defaults) {
+ super();
+ this._defaults = defaults;
+ }
+
+ getValue(settingName, roomId) {
+ return this._defaults[settingName];
+ }
+
+ setValue(settingName, roomId, newValue) {
+ throw new Error("Cannot set values on the default level handler");
+ }
+
+ canSetValue(settingName, roomId) {
+ return false;
+ }
+
+ isSupported() {
+ return true;
+ }
+}
diff --git a/src/settings/handlers/DeviceSettingsHandler.js b/src/settings/handlers/DeviceSettingsHandler.js
new file mode 100644
index 0000000000..22f6140a80
--- /dev/null
+++ b/src/settings/handlers/DeviceSettingsHandler.js
@@ -0,0 +1,114 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 Promise from 'bluebird';
+import SettingsHandler from "./SettingsHandler";
+import MatrixClientPeg from "../../MatrixClientPeg";
+
+/**
+ * Gets and sets settings at the "device" level for the current device.
+ * This handler does not make use of the roomId parameter. This handler
+ * will special-case features to support legacy settings.
+ */
+export default class DeviceSettingsHandler extends SettingsHandler {
+ /**
+ * Creates a new device settings handler
+ * @param {string[]} featureNames The names of known features.
+ */
+ constructor(featureNames) {
+ super();
+ this._featureNames = featureNames;
+ }
+
+ getValue(settingName, roomId) {
+ if (this._featureNames.includes(settingName)) {
+ return this._readFeature(settingName);
+ }
+
+ // Special case notifications
+ if (settingName === "notificationsEnabled") {
+ 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") {
+ 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") {
+ 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];
+ }
+
+ setValue(settingName, roomId, newValue) {
+ if (this._featureNames.includes(settingName)) {
+ this._writeFeature(settingName, newValue);
+ return Promise.resolve();
+ }
+
+ // Special case notifications
+ if (settingName === "notificationsEnabled") {
+ localStorage.setItem("notifications_enabled", newValue);
+ return Promise.resolve();
+ } else if (settingName === "notificationBodyEnabled") {
+ localStorage.setItem("notifications_body_enabled", newValue);
+ return Promise.resolve();
+ } else if (settingName === "audioNotificationsEnabled") {
+ localStorage.setItem("audio_notifications_enabled", newValue);
+ return Promise.resolve();
+ }
+
+ const settings = this._getSettings();
+ settings[settingName] = newValue;
+ localStorage.setItem("mx_local_settings", JSON.stringify(settings));
+
+ return Promise.resolve();
+ }
+
+ canSetValue(settingName, roomId) {
+ return true; // It's their device, so they should be able to
+ }
+
+ isSupported() {
+ return localStorage !== undefined && localStorage !== null;
+ }
+
+ _getSettings() {
+ const value = localStorage.getItem("mx_local_settings");
+ if (!value) return {};
+ return JSON.parse(value);
+ }
+
+ // Note: features intentionally don't use the same key as settings to avoid conflicts
+ // and to be backwards compatible.
+
+ _readFeature(featureName) {
+ if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest()) {
+ // Guests should not have any labs features enabled.
+ return {enabled: false};
+ }
+
+ const value = localStorage.getItem("mx_labs_feature_" + featureName);
+ return value === "true";
+ }
+
+ _writeFeature(featureName, enabled) {
+ localStorage.setItem("mx_labs_feature_" + featureName, enabled);
+ }
+}
diff --git a/src/settings/handlers/LocalEchoWrapper.js b/src/settings/handlers/LocalEchoWrapper.js
new file mode 100644
index 0000000000..d616edd9fb
--- /dev/null
+++ b/src/settings/handlers/LocalEchoWrapper.js
@@ -0,0 +1,69 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 Promise from "bluebird";
+import SettingsHandler from "./SettingsHandler";
+
+/**
+ * A wrapper for a SettingsHandler that performs local echo on
+ * changes to settings. This wrapper will use the underlying
+ * handler as much as possible to ensure values are not stale.
+ */
+export default class LocalEchoWrapper extends SettingsHandler {
+ /**
+ * Creates a new local echo wrapper
+ * @param {SettingsHandler} handler The handler to wrap
+ */
+ constructor(handler) {
+ super();
+ this._handler = handler;
+ this._cache = {
+ // settingName: { roomId: value }
+ };
+ }
+
+ getValue(settingName, roomId) {
+ const cacheRoomId = roomId ? roomId : "UNDEFINED"; // avoid weird keys
+ const bySetting = this._cache[settingName];
+ if (bySetting && bySetting.hasOwnProperty(cacheRoomId)) {
+ return bySetting[roomId];
+ }
+
+ return this._handler.getValue(settingName, roomId);
+ }
+
+ setValue(settingName, roomId, newValue) {
+ if (!this._cache[settingName]) this._cache[settingName] = {};
+ const bySetting = this._cache[settingName];
+
+ const cacheRoomId = roomId ? roomId : "UNDEFINED"; // avoid weird keys
+ bySetting[cacheRoomId] = newValue;
+
+ const handlerPromise = this._handler.setValue(settingName, roomId, newValue);
+ return Promise.resolve(handlerPromise).finally(() => {
+ delete bySetting[cacheRoomId];
+ });
+ }
+
+
+ canSetValue(settingName, roomId) {
+ return this._handler.canSetValue(settingName, roomId);
+ }
+
+ isSupported() {
+ return this._handler.isSupported();
+ }
+}
diff --git a/src/settings/handlers/RoomAccountSettingsHandler.js b/src/settings/handlers/RoomAccountSettingsHandler.js
new file mode 100644
index 0000000000..e946581807
--- /dev/null
+++ b/src/settings/handlers/RoomAccountSettingsHandler.js
@@ -0,0 +1,83 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 SettingsHandler from "./SettingsHandler";
+import MatrixClientPeg from '../../MatrixClientPeg';
+
+/**
+ * Gets and sets settings at the "room-account" level for the current user.
+ */
+export default class RoomAccountSettingsHandler extends SettingsHandler {
+ getValue(settingName, roomId) {
+ // 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'];
+ }
+
+ // Special case room color
+ if (settingName === "roomColor") {
+ // The event content should already be in an appropriate format, we just need
+ // to get the right value.
+ return this._getSettings(roomId, "org.matrix.room.color_scheme");
+ }
+
+ return this._getSettings(roomId)[settingName];
+ }
+
+ setValue(settingName, roomId, newValue) {
+ // Special case URL previews
+ if (settingName === "urlPreviewsEnabled") {
+ const content = this._getSettings(roomId, "org.matrix.room.preview_urls");
+ content['disable'] = !newValue;
+ return MatrixClientPeg.get().setRoomAccountData(roomId, "org.matrix.room.preview_urls", content);
+ }
+
+ // Special case room color
+ if (settingName === "roomColor") {
+ // The new value should match our requirements, we just need to store it in the right place.
+ return MatrixClientPeg.get().setRoomAccountData(roomId, "org.matrix.room.color_scheme", newValue);
+ }
+
+ const content = this._getSettings(roomId);
+ content[settingName] = newValue;
+ return MatrixClientPeg.get().setRoomAccountData(roomId, "im.vector.web.settings", content);
+ }
+
+ canSetValue(settingName, roomId) {
+ const room = MatrixClientPeg.get().getRoom(roomId);
+
+ // If they have the room, they can set their own account data
+ return room !== undefined && room !== null;
+ }
+
+ isSupported() {
+ const cli = MatrixClientPeg.get();
+ return cli !== undefined && cli !== null;
+ }
+
+ _getSettings(roomId, eventType = "im.vector.settings") {
+ const room = MatrixClientPeg.get().getRoom(roomId);
+ if (!room) return {};
+
+ const event = room.getAccountData(eventType);
+ if (!event || !event.getContent()) return {};
+ return event.getContent();
+ }
+}
diff --git a/src/settings/handlers/RoomDeviceSettingsHandler.js b/src/settings/handlers/RoomDeviceSettingsHandler.js
new file mode 100644
index 0000000000..186be3041f
--- /dev/null
+++ b/src/settings/handlers/RoomDeviceSettingsHandler.js
@@ -0,0 +1,77 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 Promise from 'bluebird';
+import SettingsHandler from "./SettingsHandler";
+
+/**
+ * Gets and sets settings at the "room-device" level for the current device in a particular
+ * room.
+ */
+export default class RoomDeviceSettingsHandler extends SettingsHandler {
+ getValue(settingName, roomId) {
+ // Special case blacklist setting to use legacy values
+ if (settingName === "blacklistUnverifiedDevices") {
+ const value = this._read("mx_local_settings");
+ if (value && value['blacklistUnverifiedDevicesPerRoom']) {
+ return value['blacklistUnverifiedDevicesPerRoom'][roomId];
+ }
+ }
+
+ const value = this._read(this._getKey(settingName, roomId));
+ if (value) return value.value;
+ return null;
+ }
+
+ setValue(settingName, roomId, newValue) {
+ // Special case blacklist setting for legacy structure
+ if (settingName === "blacklistUnverifiedDevices") {
+ let value = this._read("mx_local_settings");
+ if (!value) value = {};
+ if (!value["blacklistUnverifiedDevicesPerRoom"]) value["blacklistUnverifiedDevicesPerRoom"] = {};
+ value["blacklistUnverifiedDevicesPerRoom"][roomId] = newValue;
+ localStorage.setItem("mx_local_settings", JSON.stringify(value));
+ return Promise.resolve();
+ }
+
+ if (newValue === null) {
+ localStorage.removeItem(this._getKey(settingName, roomId));
+ } else {
+ newValue = JSON.stringify({value: newValue});
+ localStorage.setItem(this._getKey(settingName, roomId), newValue);
+ }
+
+ return Promise.resolve();
+ }
+
+ canSetValue(settingName, roomId) {
+ return true; // It's their device, so they should be able to
+ }
+
+ isSupported() {
+ return localStorage !== undefined && localStorage !== null;
+ }
+
+ _read(key) {
+ const rawValue = localStorage.getItem(key);
+ if (!rawValue) return null;
+ return JSON.parse(rawValue);
+ }
+
+ _getKey(settingName, roomId) {
+ return "mx_setting_" + settingName + "_" + roomId;
+ }
+}
diff --git a/src/settings/handlers/RoomSettingsHandler.js b/src/settings/handlers/RoomSettingsHandler.js
new file mode 100644
index 0000000000..cb3e836c7f
--- /dev/null
+++ b/src/settings/handlers/RoomSettingsHandler.js
@@ -0,0 +1,73 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 SettingsHandler from "./SettingsHandler";
+import MatrixClientPeg from '../../MatrixClientPeg';
+
+/**
+ * Gets and sets settings at the "room" level.
+ */
+export default class RoomSettingsHandler extends SettingsHandler {
+ getValue(settingName, roomId) {
+ // 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'];
+ }
+
+ return this._getSettings(roomId)[settingName];
+ }
+
+ setValue(settingName, roomId, newValue) {
+ // Special case URL previews
+ if (settingName === "urlPreviewsEnabled") {
+ const content = this._getSettings(roomId, "org.matrix.room.preview_urls");
+ content['disable'] = !newValue;
+ return MatrixClientPeg.get().sendStateEvent(roomId, "org.matrix.room.preview_urls", content);
+ }
+
+ const content = this._getSettings(roomId);
+ content[settingName] = newValue;
+ return MatrixClientPeg.get().sendStateEvent(roomId, "im.vector.web.settings", content, "");
+ }
+
+ canSetValue(settingName, roomId) {
+ const cli = MatrixClientPeg.get();
+ const room = cli.getRoom(roomId);
+
+ let eventType = "im.vector.web.settings";
+ if (settingName === "urlPreviewsEnabled") eventType = "org.matrix.room.preview_urls";
+
+ if (!room) return false;
+ return room.currentState.maySendStateEvent(eventType, cli.getUserId());
+ }
+
+ isSupported() {
+ const cli = MatrixClientPeg.get();
+ return cli !== undefined && cli !== null;
+ }
+
+ _getSettings(roomId, eventType = "im.vector.web.settings") {
+ const room = MatrixClientPeg.get().getRoom(roomId);
+ if (!room) return {};
+ const event = room.currentState.getStateEvents(eventType, "");
+ if (!event || !event.getContent()) return {};
+ return event.getContent();
+ }
+}
diff --git a/src/settings/handlers/SettingsHandler.js b/src/settings/handlers/SettingsHandler.js
new file mode 100644
index 0000000000..69f633c650
--- /dev/null
+++ b/src/settings/handlers/SettingsHandler.js
@@ -0,0 +1,71 @@
+/*
+Copyright 2017 Travis Ralston
+
+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 Promise from "bluebird";
+
+/**
+ * Represents the base class for all level handlers. This class performs no logic
+ * and should be overridden.
+ */
+export default class SettingsHandler {
+ /**
+ * Gets the value for a particular setting at this level for a particular room.
+ * If no room is applicable, the roomId may be null. The roomId may not be
+ * applicable to this level and may be ignored by the handler.
+ * @param {string} settingName The name of the setting.
+ * @param {String} roomId The room ID to read from, may be null.
+ * @returns {*} The setting value, or null if not found.
+ */
+ getValue(settingName, roomId) {
+ console.error("Invalid operation: getValue was not overridden");
+ return null;
+ }
+
+ /**
+ * Sets the value for a particular setting at this level for a particular room.
+ * If no room is applicable, the roomId may be null. The roomId may not be
+ * applicable to this level and may be ignored by the handler. Setting a value
+ * to null will cause the level to remove the value. The current user should be
+ * able to set the value prior to calling this.
+ * @param {string} settingName The name of the setting to change.
+ * @param {String} roomId The room ID to set the value in, may be null.
+ * @param {*} newValue The new value for the setting, may be null.
+ * @returns {Promise} Resolves when the setting has been saved.
+ */
+ setValue(settingName, roomId, newValue) {
+ console.error("Invalid operation: setValue was not overridden");
+ return Promise.reject();
+ }
+
+ /**
+ * Determines if the current user is able to set the value of the given setting
+ * in the given room at this level.
+ * @param {string} settingName The name of the setting to check.
+ * @param {String} roomId The room ID to check in, may be null
+ * @returns {boolean} True if the setting can be set by the user, false otherwise.
+ */
+ canSetValue(settingName, roomId) {
+ return false;
+ }
+
+ /**
+ * Determines if this level is supported on this device.
+ * @returns {boolean} True if this level is supported on the current device.
+ */
+ isSupported() {
+ return false;
+ }
+}
diff --git a/src/shouldHideEvent.js b/src/shouldHideEvent.js
index 1501e28875..1ecd1ac051 100644
--- a/src/shouldHideEvent.js
+++ b/src/shouldHideEvent.js
@@ -14,6 +14,8 @@
limitations under the License.
*/
+import SettingsStore from "./settings/SettingsStore";
+
function memberEventDiff(ev) {
const diff = {
isMemberEvent: ev.getType() === 'm.room.member',
@@ -34,16 +36,19 @@ function memberEventDiff(ev) {
return diff;
}
-export default function shouldHideEvent(ev, syncedSettings) {
+export default function shouldHideEvent(ev) {
+ // Wrap getValue() for readability
+ const isEnabled = (name) => SettingsStore.getValue(name, ev.getRoomId());
+
// Hide redacted events
- if (syncedSettings['hideRedactions'] && ev.isRedacted()) return true;
+ if (isEnabled('hideRedactions') && ev.isRedacted()) return true;
const eventDiff = memberEventDiff(ev);
if (eventDiff.isMemberEvent) {
- if (syncedSettings['hideJoinLeaves'] && (eventDiff.isJoin || eventDiff.isPart)) return true;
- const isMemberAvatarDisplaynameChange = eventDiff.isAvatarChange || eventDiff.isDisplaynameChange;
- if (syncedSettings['hideAvatarDisplaynameChanges'] && isMemberAvatarDisplaynameChange) return true;
+ if (isEnabled('hideJoinLeaves') && (eventDiff.isJoin || eventDiff.isPart)) return true;
+ if (isEnabled('hideAvatarChanges') && eventDiff.isAvatarChange) return true;
+ if (isEnabled('hideDisplaynameChanges') && eventDiff.isDisplaynameChange) return true;
}
return false;
diff --git a/src/stores/FilterStore.js b/src/stores/FilterStore.js
new file mode 100644
index 0000000000..8078a13ffd
--- /dev/null
+++ b/src/stores/FilterStore.js
@@ -0,0 +1,115 @@
+/*
+Copyright 2017 Vector Creations 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 {Store} from 'flux/utils';
+import dis from '../dispatcher';
+import Analytics from '../Analytics';
+
+const INITIAL_STATE = {
+ allTags: [],
+ selectedTags: [],
+ // Last selected tag when shift was not being pressed
+ anchorTag: null,
+};
+
+/**
+ * A class for storing application state for filtering via TagPanel.
+ */
+class FilterStore extends Store {
+ constructor() {
+ super(dis);
+
+ // Initialise state
+ this._state = INITIAL_STATE;
+ }
+
+ _setState(newState) {
+ this._state = Object.assign(this._state, newState);
+ this.__emitChange();
+ }
+
+ __onDispatch(payload) {
+ switch (payload.action) {
+ case 'all_tags' :
+ this._setState({
+ allTags: payload.tags,
+ });
+ break;
+ case 'select_tag': {
+ let newTags = [];
+ // Shift-click semantics
+ if (payload.shiftKey) {
+ // Select range of tags
+ let start = this._state.allTags.indexOf(this._state.anchorTag);
+ let end = this._state.allTags.indexOf(payload.tag);
+
+ if (start === -1) {
+ start = end;
+ }
+ if (start > end) {
+ const temp = start;
+ start = end;
+ end = temp;
+ }
+ newTags = payload.ctrlOrCmdKey ? this._state.selectedTags : [];
+ newTags = [...new Set(
+ this._state.allTags.slice(start, end + 1).concat(newTags),
+ )];
+ } else {
+ if (payload.ctrlOrCmdKey) {
+ // Toggle individual tag
+ if (this._state.selectedTags.includes(payload.tag)) {
+ newTags = this._state.selectedTags.filter((t) => t !== payload.tag);
+ } else {
+ newTags = [...this._state.selectedTags, payload.tag];
+ }
+ } else {
+ // Select individual tag
+ newTags = [payload.tag];
+ }
+ // Only set the anchor tag if the tag was previously unselected, otherwise
+ // the next range starts with an unselected tag.
+ if (!this._state.selectedTags.includes(payload.tag)) {
+ this._setState({
+ anchorTag: payload.tag,
+ });
+ }
+ }
+
+ this._setState({
+ selectedTags: newTags,
+ });
+
+ Analytics.trackEvent('FilterStore', 'select_tag');
+ }
+ break;
+ case 'deselect_tags':
+ this._setState({
+ selectedTags: [],
+ });
+ Analytics.trackEvent('FilterStore', 'deselect_tags');
+ break;
+ }
+ }
+
+ getSelectedTags() {
+ return this._state.selectedTags;
+ }
+}
+
+if (global.singletonFilterStore === undefined) {
+ global.singletonFilterStore = new FilterStore();
+}
+export default global.singletonFilterStore;
diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js
index 0f339eaf63..7a3aa31e4e 100644
--- a/src/stores/FlairStore.js
+++ b/src/stores/FlairStore.js
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import EventEmitter from 'events';
import Promise from 'bluebird';
const BULK_REQUEST_DEBOUNCE_MS = 200;
@@ -29,9 +28,8 @@ const GROUP_PROFILES_CACHE_BUST_MS = 1800000; // 30 mins
/**
* Stores data used by
*/
-class FlairStore extends EventEmitter {
+class FlairStore {
constructor(matrixClient) {
- super();
this._matrixClient = matrixClient;
this._userGroups = {
// $userId: ['+group1:domain', '+group2:domain', ...]
@@ -41,6 +39,9 @@ class FlairStore extends EventEmitter {
// avatar_url: 'mxc://...'
// }
};
+ this._groupProfilesPromise = {
+ // $groupId: Promise
+ };
this._usersPending = {
// $userId: {
// prom: Promise
@@ -95,7 +96,7 @@ class FlairStore extends EventEmitter {
// Return silently to avoid spamming for non-supporting servers
return;
}
- console.error('Could not get groups for user', this.props.userId, err);
+ console.error('Could not get groups for user', userId, err);
throw err;
}).finally(() => {
delete this._usersInFlight[userId];
@@ -151,13 +152,29 @@ class FlairStore extends EventEmitter {
return this._groupProfiles[groupId];
}
- const profile = await matrixClient.getGroupProfile(groupId);
+ // No request yet, start one
+ if (!this._groupProfilesPromise[groupId]) {
+ this._groupProfilesPromise[groupId] = matrixClient.getGroupProfile(groupId);
+ }
+
+ let profile;
+ try {
+ profile = await this._groupProfilesPromise[groupId];
+ } catch (e) {
+ console.log('Failed to get group profile for ' + groupId, e);
+ // Don't retry, but allow a retry when the profile is next requested
+ delete this._groupProfilesPromise[groupId];
+ return;
+ }
+
this._groupProfiles[groupId] = {
groupId,
avatarUrl: profile.avatar_url,
name: profile.name,
shortDescription: profile.short_description,
};
+ delete this._groupProfilesPromise[groupId];
+
setTimeout(() => {
delete this._groupProfiles[groupId];
}, GROUP_PROFILES_CACHE_BUST_MS);
diff --git a/src/stores/GroupStore.js b/src/stores/GroupStore.js
index cb8b77ceb7..9dce15fb53 100644
--- a/src/stores/GroupStore.js
+++ b/src/stores/GroupStore.js
@@ -17,6 +17,7 @@ limitations under the License.
import EventEmitter from 'events';
import { groupMemberFromApiObject, groupRoomFromApiObject } from '../groups';
import FlairStore from './FlairStore';
+import MatrixClientPeg from '../MatrixClientPeg';
/**
* Stores the group summary for a room and provides an API to change it and
@@ -31,13 +32,12 @@ export default class GroupStore extends EventEmitter {
GroupRooms: 'GroupRooms',
};
- constructor(matrixClient, groupId) {
+ constructor(groupId) {
super();
if (!groupId) {
throw new Error('GroupStore needs a valid groupId to be created');
}
this.groupId = groupId;
- this._matrixClient = matrixClient;
this._summary = {};
this._rooms = [];
this._members = [];
@@ -50,7 +50,7 @@ export default class GroupStore extends EventEmitter {
}
_fetchMembers() {
- this._matrixClient.getGroupUsers(this.groupId).then((result) => {
+ MatrixClientPeg.get().getGroupUsers(this.groupId).then((result) => {
this._members = result.chunk.map((apiMember) => {
return groupMemberFromApiObject(apiMember);
});
@@ -61,7 +61,7 @@ export default class GroupStore extends EventEmitter {
this.emit('error', err);
});
- this._matrixClient.getGroupInvitedUsers(this.groupId).then((result) => {
+ MatrixClientPeg.get().getGroupInvitedUsers(this.groupId).then((result) => {
this._invitedMembers = result.chunk.map((apiMember) => {
return groupMemberFromApiObject(apiMember);
});
@@ -78,7 +78,7 @@ export default class GroupStore extends EventEmitter {
}
_fetchSummary() {
- this._matrixClient.getGroupSummary(this.groupId).then((resp) => {
+ MatrixClientPeg.get().getGroupSummary(this.groupId).then((resp) => {
this._summary = resp;
this._ready[GroupStore.STATE_KEY.Summary] = true;
this._notifyListeners();
@@ -88,7 +88,7 @@ export default class GroupStore extends EventEmitter {
}
_fetchRooms() {
- this._matrixClient.getGroupRooms(this.groupId).then((resp) => {
+ MatrixClientPeg.get().getGroupRooms(this.groupId).then((resp) => {
this._rooms = resp.chunk.map((apiRoom) => {
return groupRoomFromApiObject(apiRoom);
});
@@ -103,6 +103,22 @@ export default class GroupStore extends EventEmitter {
this.emit('update');
}
+ /**
+ * Register a listener to recieve updates from the store. This also
+ * immediately triggers an update to send the current state of the
+ * store (which could be the initial state).
+ *
+ * XXX: This also causes a fetch of all group data, which effectively
+ * causes 4 separate HTTP requests. This is bad, we should at least
+ * deduplicate these in order to fix:
+ * https://github.com/vector-im/riot-web/issues/5901
+ *
+ * @param {function} fn the function to call when the store updates.
+ * @return {Object} tok a registration "token" with a single
+ * property `unregister`, a function that can
+ * be called to unregister the listener such
+ * that it won't be called any more.
+ */
registerListener(fn) {
this.on('update', fn);
// Call to set initial state (before fetching starts)
@@ -110,6 +126,14 @@ export default class GroupStore extends EventEmitter {
this._fetchSummary();
this._fetchRooms();
this._fetchMembers();
+
+ // Similar to the Store of flux/utils, we return a "token" that
+ // can be used to unregister the listener.
+ return {
+ unregister: () => {
+ this.unregisterListener(fn);
+ },
+ };
}
unregisterListener(fn) {
@@ -145,19 +169,19 @@ export default class GroupStore extends EventEmitter {
}
addRoomToGroup(roomId, isPublic) {
- return this._matrixClient
+ return MatrixClientPeg.get()
.addRoomToGroup(this.groupId, roomId, isPublic)
.then(this._fetchRooms.bind(this));
}
updateGroupRoomVisibility(roomId, isPublic) {
- return this._matrixClient
+ return MatrixClientPeg.get()
.updateGroupRoomVisibility(this.groupId, roomId, isPublic)
.then(this._fetchRooms.bind(this));
}
removeRoomFromGroup(roomId) {
- return this._matrixClient
+ return MatrixClientPeg.get()
.removeRoomFromGroup(this.groupId, roomId)
// Room might be in the summary, refresh just in case
.then(this._fetchSummary.bind(this))
@@ -165,12 +189,12 @@ export default class GroupStore extends EventEmitter {
}
inviteUserToGroup(userId) {
- return this._matrixClient.inviteUserToGroup(this.groupId, userId)
+ return MatrixClientPeg.get().inviteUserToGroup(this.groupId, userId)
.then(this._fetchMembers.bind(this));
}
acceptGroupInvite() {
- return this._matrixClient.acceptGroupInvite(this.groupId)
+ return MatrixClientPeg.get().acceptGroupInvite(this.groupId)
// The user might be able to see more rooms now
.then(this._fetchRooms.bind(this))
// The user should now appear as a member
@@ -178,33 +202,33 @@ export default class GroupStore extends EventEmitter {
}
addRoomToGroupSummary(roomId, categoryId) {
- return this._matrixClient
+ return MatrixClientPeg.get()
.addRoomToGroupSummary(this.groupId, roomId, categoryId)
.then(this._fetchSummary.bind(this));
}
addUserToGroupSummary(userId, roleId) {
- return this._matrixClient
+ return MatrixClientPeg.get()
.addUserToGroupSummary(this.groupId, userId, roleId)
.then(this._fetchSummary.bind(this));
}
removeRoomFromGroupSummary(roomId) {
- return this._matrixClient
+ return MatrixClientPeg.get()
.removeRoomFromGroupSummary(this.groupId, roomId)
.then(this._fetchSummary.bind(this));
}
removeUserFromGroupSummary(userId) {
- return this._matrixClient
+ return MatrixClientPeg.get()
.removeUserFromGroupSummary(this.groupId, userId)
.then(this._fetchSummary.bind(this));
}
setGroupPublicity(isPublished) {
- return this._matrixClient
+ return MatrixClientPeg.get()
.setGroupPublicity(this.groupId, isPublished)
- .then(() => { FlairStore.invalidatePublicisedGroups(this._matrixClient.credentials.userId); })
+ .then(() => { FlairStore.invalidatePublicisedGroups(MatrixClientPeg.get().credentials.userId); })
.then(this._fetchSummary.bind(this));
}
}
diff --git a/src/stores/GroupStoreCache.js b/src/stores/GroupStoreCache.js
index df5ffcda5e..8b4286831b 100644
--- a/src/stores/GroupStoreCache.js
+++ b/src/stores/GroupStoreCache.js
@@ -21,14 +21,13 @@ class GroupStoreCache {
this.groupStore = null;
}
- getGroupStore(matrixClient, groupId) {
+ getGroupStore(groupId) {
if (!this.groupStore || this.groupStore.groupId !== groupId) {
// This effectively throws away the reference to any previous GroupStore,
// allowing it to be GCd once the components referencing it have stopped
// referencing it.
- this.groupStore = new GroupStore(matrixClient, groupId);
+ this.groupStore = new GroupStore(groupId);
}
- this.groupStore._fetchSummary();
return this.groupStore;
}
}
diff --git a/src/stores/TagOrderStore.js b/src/stores/TagOrderStore.js
new file mode 100644
index 0000000000..633ffc7e9c
--- /dev/null
+++ b/src/stores/TagOrderStore.js
@@ -0,0 +1,137 @@
+/*
+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 {Store} from 'flux/utils';
+import dis from '../dispatcher';
+
+const INITIAL_STATE = {
+ orderedTags: null,
+ orderedTagsAccountData: null,
+ hasSynced: false,
+ joinedGroupIds: null,
+};
+
+/**
+ * A class for storing application state for ordering tags in the TagPanel.
+ */
+class TagOrderStore extends Store {
+ constructor() {
+ super(dis);
+
+ // Initialise state
+ this._state = Object.assign({}, INITIAL_STATE);
+ }
+
+ _setState(newState) {
+ this._state = Object.assign(this._state, newState);
+ this.__emitChange();
+ }
+
+ __onDispatch(payload) {
+ switch (payload.action) {
+ // Initialise state after initial sync
+ case 'MatrixActions.sync': {
+ if (!(payload.prevState === 'PREPARED' && payload.state === 'SYNCING')) {
+ break;
+ }
+ const tagOrderingEvent = payload.matrixClient.getAccountData('im.vector.web.tag_ordering');
+ const tagOrderingEventContent = tagOrderingEvent ? tagOrderingEvent.getContent() : {};
+ this._setState({
+ orderedTagsAccountData: tagOrderingEventContent.tags || null,
+ hasSynced: true,
+ });
+ this._updateOrderedTags();
+ break;
+ }
+ // Get ordering from account data
+ case 'MatrixActions.accountData': {
+ if (payload.event_type !== 'im.vector.web.tag_ordering') break;
+ this._setState({
+ orderedTagsAccountData: payload.event_content ? payload.event_content.tags : null,
+ });
+ this._updateOrderedTags();
+ break;
+ }
+ // Initialise the state such that if account data is unset, default to joined groups
+ case 'GroupActions.fetchJoinedGroups.success': {
+ this._setState({
+ joinedGroupIds: payload.result.groups.sort(), // Sort lexically
+ hasFetchedJoinedGroups: true,
+ });
+ this._updateOrderedTags();
+ break;
+ }
+ // Puts payload.tag at payload.targetTag, placing the targetTag before or after the tag
+ case 'order_tag': {
+ if (!this._state.orderedTags ||
+ !payload.tag ||
+ !payload.targetTag ||
+ payload.tag === payload.targetTag
+ ) return;
+
+ const tags = this._state.orderedTags;
+
+ let orderedTags = tags.filter((t) => t !== payload.tag);
+ const newIndex = orderedTags.indexOf(payload.targetTag) + (payload.after ? 1 : 0);
+ orderedTags = [
+ ...orderedTags.slice(0, newIndex),
+ payload.tag,
+ ...orderedTags.slice(newIndex),
+ ];
+ this._setState({orderedTags});
+ break;
+ }
+ case 'on_logged_out': {
+ // Reset state without pushing an update to the view, which generally assumes that
+ // the matrix client isn't `null` and so causing a re-render will cause NPEs.
+ this._state = Object.assign({}, INITIAL_STATE);
+ break;
+ }
+ }
+ }
+
+ _updateOrderedTags() {
+ this._setState({
+ orderedTags:
+ this._state.hasSynced &&
+ this._state.hasFetchedJoinedGroups ?
+ this._mergeGroupsAndTags() : null,
+ });
+ }
+
+ _mergeGroupsAndTags() {
+ const groupIds = this._state.joinedGroupIds || [];
+ const tags = this._state.orderedTagsAccountData || [];
+
+ const tagsToKeep = tags.filter(
+ (t) => t[0] !== '+' || groupIds.includes(t),
+ );
+
+ const groupIdsToAdd = groupIds.filter(
+ (groupId) => !tags.includes(groupId),
+ );
+
+ return tagsToKeep.concat(groupIdsToAdd);
+ }
+
+ getOrderedTags() {
+ return this._state.orderedTags;
+ }
+}
+
+if (global.singletonTagOrderStore === undefined) {
+ global.singletonTagOrderStore = new TagOrderStore();
+}
+export default global.singletonTagOrderStore;
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/MultiInviter.js b/src/utils/MultiInviter.js
index 02c413ac83..a0f33f5c39 100644
--- a/src/utils/MultiInviter.js
+++ b/src/utils/MultiInviter.js
@@ -119,7 +119,7 @@ export default class MultiInviter {
let doInvite;
if (this.groupId !== null) {
doInvite = GroupStoreCache
- .getGroupStore(MatrixClientPeg.get(), this.groupId)
+ .getGroupStore(this.groupId)
.inviteUserToGroup(addr);
} else {
doInvite = inviteToRoom(this.roomId, addr);
diff --git a/src/utils/createMatrixClient.js b/src/utils/createMatrixClient.js
index 2d294e262b..b83e254fad 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
@@ -39,7 +39,9 @@ try {
* @returns {MatrixClient} the newly-created MatrixClient
*/
export default function createMatrixClient(opts) {
- const storeOpts = {};
+ const storeOpts = {
+ useAuthorizationHeader: true,
+ };
if (localStorage) {
storeOpts.sessionStore = new Matrix.WebStorageSessionStore(localStorage);
diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js
index 4de3b7626d..e7176e2c16 100644
--- a/test/components/structures/MessagePanel-test.js
+++ b/test/components/structures/MessagePanel-test.js
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import SettingsStore from "../../../src/settings/SettingsStore";
+
const React = require('react');
const ReactDOM = require("react-dom");
const TestUtils = require('react-addons-test-utils');
@@ -23,7 +25,6 @@ import sinon from 'sinon';
const sdk = require('matrix-react-sdk');
const MessagePanel = sdk.getComponent('structures.MessagePanel');
-import UserSettingsStore from '../../../src/UserSettingsStore';
import MatrixClientPeg from '../../../src/MatrixClientPeg';
const test_utils = require('test-utils');
@@ -59,7 +60,9 @@ describe('MessagePanel', function() {
sandbox = test_utils.stubClient();
client = MatrixClientPeg.get();
client.credentials = {userId: '@me:here'};
- UserSettingsStore.getSyncedSettings = sinon.stub().returns({});
+
+ // HACK: We assume all settings want to be disabled
+ SettingsStore.getValue = sinon.stub().returns(false);
});
afterEach(function() {
diff --git a/test/components/views/rooms/MessageComposerInput-test.js b/test/components/views/rooms/MessageComposerInput-test.js
index faf3f0f804..1f0ede6ae2 100644
--- a/test/components/views/rooms/MessageComposerInput-test.js
+++ b/test/components/views/rooms/MessageComposerInput-test.js
@@ -6,7 +6,6 @@ import sinon from 'sinon';
import Promise from 'bluebird';
import * as testUtils from '../../../test-utils';
import sdk from 'matrix-react-sdk';
-import UserSettingsStore from '../../../../src/UserSettingsStore';
const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput');
import MatrixClientPeg from '../../../../src/MatrixClientPeg';
import RoomMember from 'matrix-js-sdk';
diff --git a/test/i18n-test/languageHandler-test.js b/test/i18n-test/languageHandler-test.js
new file mode 100644
index 0000000000..ce9f8e1684
--- /dev/null
+++ b/test/i18n-test/languageHandler-test.js
@@ -0,0 +1,73 @@
+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);
+ });
+
+ it('replacements in the wrong order', function() {
+ const text = '%(var1)s %(var2)s';
+ expect(languageHandler._t(text, { var2: 'val2', var1: 'val1' })).toBe('val1 val2');
+ });
+});