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
- { _tJsx("You must register to use this functionality", /(.*?)<\/a>/, (sub) => { sub }) } + { _t("You must register to use this functionality", + {}, + { 'a': (sub) => { sub } }) + }
; } else if (this.noRoom) { diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 1b5ebb6b36..5ffb97c6ed 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -22,7 +22,7 @@ import MatrixClientPeg from '../../MatrixClientPeg'; import sdk from '../../index'; import dis from '../../dispatcher'; import { sanitizedHtmlNode } from '../../HtmlUtils'; -import { _t, _td, _tJsx } from '../../languageHandler'; +import { _t, _td } from '../../languageHandler'; import AccessibleButton from '../views/elements/AccessibleButton'; import Modal from '../../Modal'; import classnames from 'classnames'; @@ -469,7 +469,7 @@ export default React.createClass({ if (group && group.inviter && group.inviter.userId) { this._fetchInviterProfile(group.inviter.userId); } - this._groupStore = GroupStoreCache.getGroupStore(this._matrixClient, groupId); + this._groupStore = GroupStoreCache.getGroupStore(groupId); this._groupStore.registerListener(() => { const summary = this._groupStore.getSummary(); if (summary.profile) { @@ -495,7 +495,19 @@ export default React.createClass({ this._onEditClick(); } }); + let willDoOnboarding = false; this._groupStore.on('error', (err) => { + if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN' && !willDoOnboarding) { + dis.dispatch({ + action: 'do_after_sync_prepared', + deferred_action: { + action: 'view_group', + group_id: groupId, + }, + }); + dis.dispatch({action: 'view_set_mxid'}); + willDoOnboarding = true; + } this.setState({ summary: null, error: err, @@ -672,18 +684,6 @@ export default React.createClass({ showGroupAddRoomDialog(this.props.groupId); }, - _onPublicityToggle: function() { - this.setState({ - publicityBusy: true, - }); - const publicity = !this.state.isGroupPublicised; - this._groupStore.setGroupPublicity(publicity).then(() => { - this.setState({ - publicityBusy: false, - }); - }); - }, - _getGroupSection: function() { const groupSettingsSectionClasses = classnames({ "mx_GroupView_group": this.state.editing, @@ -903,25 +903,6 @@ export default React.createClass({ return null; }, - _getMemberSettingsSection: function() { - return
-

{ _t("Community Member Settings") }

-
- - -
-
; - }, - _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 -
- -
-
-

{ name }

-
{ desc }
-
{ this.props.groupId }
-
-
; - }, -}); export default withMatrixClient(React.createClass({ displayName: 'MyGroups', @@ -98,14 +41,18 @@ export default withMatrixClient(React.createClass({ }, _onCreateGroupClick: function() { - const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog"); - Modal.createTrackedDialog('Create Community', '', CreateGroupDialog); + dis.dispatch({action: 'view_create_group'}); }, _fetch: function() { this.props.matrixClient.getJoinedGroups().done((result) => { this.setState({groups: result.groups, error: null}); }, (err) => { + if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') { + // Indicate that the guest isn't in any groups (which should be true) + this.setState({groups: [], error: null}); + return; + } this.setState({groups: null, error: err}); }); }, @@ -114,6 +61,7 @@ export default withMatrixClient(React.createClass({ const Loader = sdk.getComponent("elements.Spinner"); const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); const TintableSvg = sdk.getComponent("elements.TintableSvg"); + const GroupTile = sdk.getComponent("groups.GroupTile"); let content; let contentHeader; @@ -165,13 +113,13 @@ export default withMatrixClient(React.createClass({
{ _t('Join an existing community') }
- { _tJsx( + { _t( 'To join an existing community you\'ll have to '+ 'know its community identifier; this will look '+ 'something like +example:matrix.org.', - /(.*)<\/i>/, - (sub) => { sub }, - ) } + {}, + { 'i': (sub) => { sub } }) + }
diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index cad55351d1..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
+ {_t("Warning")} +
+ { 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 ( -
- /!\ -
- { this.props.unsentMessageError } -
-
- { _tJsx("Resend all or cancel all now. You can also select individual messages to resend or cancel.", - [/(.*?)<\/a>/, /(.*?)<\/a>/], - [ - (sub) => { sub }, - (sub) => { sub }, - ], - ) } -
-
- ); + 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 (
- { _tJsx("There's no one else here! Would you like to invite others or stop warning about the empty room?", - [/(.*?)<\/a>/, /(.*?)<\/a>/], - [ - (sub) => { sub }, - (sub) => { sub }, - ], + { _t("There's no one else here! Would you like to invite others " + + "or stop warning about the empty room?", + {}, + { + 'inviteText': (sub) => + { sub }, + 'nowarnText': (sub) => + { sub }, + }, ) }
); @@ -336,7 +408,6 @@ module.exports = React.createClass({ return null; }, - render: function() { const content = this._getContent(); const indicator = this._getIndicator(this.state.usersTyping.length > 0); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 409b95947f..e240ab38d5 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -26,26 +26,24 @@ const React = require("react"); const ReactDOM = require("react-dom"); import Promise from 'bluebird'; const classNames = require("classnames"); -const Matrix = require("matrix-js-sdk"); import { _t } from '../../languageHandler'; -const UserSettingsStore = require('../../UserSettingsStore'); const MatrixClientPeg = require("../../MatrixClientPeg"); const ContentMessages = require("../../ContentMessages"); const Modal = require("../../Modal"); const sdk = require('../../index'); const CallHandler = require('../../CallHandler'); -const Resend = require("../../Resend"); const dis = require("../../dispatcher"); const Tinter = require("../../Tinter"); const rate_limited_func = require('../../ratelimitedfunc'); const ObjectUtils = require('../../ObjectUtils'); const Rooms = require('../../Rooms'); -import KeyCode from '../../KeyCode'; +import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import RoomViewStore from '../../stores/RoomViewStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; +import SettingsStore from "../../settings/SettingsStore"; const DEBUG = false; let debuglog = function() {}; @@ -110,7 +108,6 @@ module.exports = React.createClass({ draggingFile: false, searching: false, searchResults: null, - unsentMessageError: '', callState: null, guestsCanJoin: false, canPeek: false, @@ -149,8 +146,6 @@ module.exports = React.createClass({ MatrixClientPeg.get().on("RoomMember.membership", this.onRoomMemberMembership); MatrixClientPeg.get().on("accountData", this.onAccountData); - this._syncedSettings = UserSettingsStore.getSyncedSettings(); - // Start listening for RoomViewStore updates this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(true); @@ -204,7 +199,6 @@ module.exports = React.createClass({ if (initial) { newState.room = MatrixClientPeg.get().getRoom(newState.roomId); if (newState.room) { - newState.unsentMessageError = this._getUnsentMessageError(newState.room); newState.showApps = this._shouldShowApps(newState.room); this._onRoomLoaded(newState.room); } @@ -305,7 +299,7 @@ module.exports = React.createClass({ // Check if user has previously chosen to hide the app drawer for this // room. If so, do not show apps - let hideWidgetDrawer = localStorage.getItem( + const hideWidgetDrawer = localStorage.getItem( room.roomId + "_hide_widget_drawer"); if (hideWidgetDrawer === "true") { @@ -435,13 +429,7 @@ module.exports = React.createClass({ onKeyDown: function(ev) { 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.KEY_D: @@ -470,11 +458,6 @@ module.exports = React.createClass({ case 'message_send_failed': case 'message_sent': this._checkIfAlone(this.state.room); - // no break; to intentionally fall through - case 'message_send_cancelled': - this.setState({ - unsentMessageError: this._getUnsentMessageError(this.state.room), - }); break; case 'notifier_enabled': case 'upload_failed': @@ -542,7 +525,7 @@ module.exports = React.createClass({ // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { // no change - } else if (!shouldHideEvent(ev, this._syncedSettings)) { + } else if (!shouldHideEvent(ev)) { this.setState((state, props) => { return {numUnreadMessages: state.numUnreadMessages + 1}; }); @@ -616,38 +599,8 @@ module.exports = React.createClass({ }, _updatePreviewUrlVisibility: function(room) { - // console.log("_updatePreviewUrlVisibility"); - - // check our per-room overrides - const roomPreviewUrls = room.getAccountData("org.matrix.room.preview_urls"); - if (roomPreviewUrls && roomPreviewUrls.getContent().disable !== undefined) { - this.setState({ - showUrlPreview: !roomPreviewUrls.getContent().disable, - }); - return; - } - - // check our global disable override - const userRoomPreviewUrls = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls"); - if (userRoomPreviewUrls && userRoomPreviewUrls.getContent().disable) { - this.setState({ - showUrlPreview: false, - }); - return; - } - - // check the room state event - const roomStatePreviewUrls = room.currentState.getStateEvents('org.matrix.room.preview_urls', ''); - if (roomStatePreviewUrls && roomStatePreviewUrls.getContent().disable) { - this.setState({ - showUrlPreview: false, - }); - return; - } - - // otherwise, we assume they're on. this.setState({ - showUrlPreview: true, + showUrlPreview: SettingsStore.getValue("urlPreviewsEnabled", room.roomId), }); }, @@ -666,12 +619,7 @@ module.exports = React.createClass({ const room = this.state.room; if (!room) return; - const color_scheme_event = room.getAccountData("org.matrix.room.color_scheme"); - let color_scheme = {}; - if (color_scheme_event) { - color_scheme = color_scheme_event.getContent(); - // XXX: we should validate the event - } + const color_scheme = SettingsStore.getValue("roomColor", room.room_id); console.log("Tinter.tint from updateTint"); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); }, @@ -750,39 +698,10 @@ module.exports = React.createClass({ return; } - const joinedMembers = room.currentState.getMembers().filter(m => m.membership === "join" || m.membership === "invite"); + const joinedMembers = room.currentState.getMembers().filter((m) => m.membership === "join" || m.membership === "invite"); this.setState({isAlone: joinedMembers.length === 1}); }, - _getUnsentMessageError: function(room) { - const unsentMessages = this._getUnsentMessages(room); - if (!unsentMessages.length) return ""; - - if ( - unsentMessages.length === 1 && - unsentMessages[0].error && - unsentMessages[0].error.data && - unsentMessages[0].error.data.error && - unsentMessages[0].error.name !== "UnknownDeviceError" - ) { - return unsentMessages[0].error.data.error; - } - - for (const event of unsentMessages) { - if (!event.error || event.error.name !== "UnknownDeviceError") { - return _t("Some of your messages have not been sent."); - } - } - return _t("Message not sent due to unknown devices being present"); - }, - - _getUnsentMessages: function(room) { - if (!room) { return []; } - return room.getPendingEvents().filter(function(ev) { - return ev.status === Matrix.EventStatus.NOT_SENT; - }); - }, - _updateConfCallNotification: function() { const room = this.state.room; if (!room || !this.props.ConferenceHandler) { @@ -827,14 +746,6 @@ module.exports = React.createClass({ } }, - onResendAllClick: function() { - Resend.resendUnsentEvents(this.state.room); - }, - - onCancelAllClick: function() { - Resend.cancelUnsentEvents(this.state.room); - }, - onInviteButtonClick: function() { // call AddressPickerDialog dis.dispatch({ @@ -943,9 +854,13 @@ module.exports = React.createClass({ ev.dataTransfer.dropEffect = 'none'; - const items = ev.dataTransfer.items; - if (items.length == 1) { - if (items[0].kind == 'file') { + const items = [...ev.dataTransfer.items]; + if (items.length >= 1) { + const isDraggingFiles = items.every(function(item) { + return item.kind == 'file'; + }); + + if (isDraggingFiles) { this.setState({ draggingFile: true }); ev.dataTransfer.dropEffect = 'copy'; } @@ -956,10 +871,8 @@ module.exports = React.createClass({ ev.stopPropagation(); ev.preventDefault(); this.setState({ draggingFile: false }); - const files = ev.dataTransfer.files; - if (files.length == 1) { - this.uploadFile(files[0]); - } + const files = [...ev.dataTransfer.files]; + files.forEach(this.uploadFile); }, onDragLeaveOrEnd: function(ev) { @@ -978,11 +891,7 @@ module.exports = React.createClass({ file, this.state.room.roomId, MatrixClientPeg.get(), ).done(undefined, (error) => { if (error.name === "UnknownDeviceError") { - dis.dispatch({ - action: 'unknown_device_error', - err: error, - room: this.state.room, - }); + // Let the staus bar handle this return; } const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -1147,7 +1056,7 @@ module.exports = React.createClass({ } if (this.state.searchScope === 'All') { - if(roomId != lastRoomId) { + if (roomId != lastRoomId) { const room = cli.getRoom(roomId); // XXX: if we've left the room, we might not know about @@ -1458,13 +1367,13 @@ module.exports = React.createClass({ */ handleScrollKey: function(ev) { let panel; - if(this.refs.searchResultsPanel) { + if (this.refs.searchResultsPanel) { panel = this.refs.searchResultsPanel; - } else if(this.refs.messagePanel) { + } else if (this.refs.messagePanel) { panel = this.refs.messagePanel; } - if(panel) { + if (panel) { panel.handleScrollKey(ev); } }, @@ -1483,7 +1392,7 @@ module.exports = React.createClass({ // otherwise react calls it with null on each update. _gatherTimelinePanelRef: function(r) { this.refs.messagePanel = r; - if(r) { + if (r) { console.log("updateTint from RoomView._gatherTimelinePanelRef"); this.updateTint(); } @@ -1614,12 +1523,9 @@ module.exports = React.createClass({ statusBar =