diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6d180616..87459882c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +Changes in [0.11.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.3) (2017-12-04) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.2...v0.11.3) + + * Bump js-sdk version to pull in fix for [setting room publicity in a group](https://github.com/matrix-org/matrix-js-sdk/commit/aa3201ebb0fff5af2fb733080aa65ed1f7213de6). + Changes in [0.11.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.2) (2017-11-28) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.1...v0.11.2) diff --git a/package.json b/package.json index ee089daf29..6735f58300 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.11.2", + "version": "0.11.3", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -72,7 +72,7 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "matrix-js-sdk": "0.9.1", + "matrix-js-sdk": "0.9.2", "optimist": "^0.6.1", "prop-types": "^15.5.8", "querystring": "^0.2.0", 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 946f22537d..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 diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js new file mode 100644 index 0000000000..74d5b91428 --- /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"); + 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/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 9d587c2eb4..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) { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 2f0ee5e47d..01abf966f9 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -19,7 +19,7 @@ limitations under the License. import * as Matrix from 'matrix-js-sdk'; import React from 'react'; -import KeyCode from '../../KeyCode'; +import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import Notifier from '../../Notifier'; import PageTypes from '../../PageTypes'; import CallMediaHandler from '../../CallMediaHandler'; @@ -153,13 +153,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: diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 1381b4fce0..1fda05fb76 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -41,7 +41,7 @@ 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'; @@ -433,13 +433,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: diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index dfc6b0f7a1..37cb2977aa 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -18,7 +18,7 @@ const React = require("react"); const ReactDOM = require("react-dom"); const GeminiScrollbar = require('react-gemini-scrollbar'); import Promise from 'bluebird'; -const KeyCode = require('../../KeyCode'); +import { KeyCode } from '../../Keyboard'; const DEBUG_SCROLL = false; // var DEBUG_SCROLL = true; diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 242b71cd16..0107ad1db1 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -22,6 +22,7 @@ import FilterStore from '../../stores/FilterStore'; import FlairStore from '../../stores/FlairStore'; import sdk from '../../index'; import dis from '../../dispatcher'; +import { isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; const TagTile = React.createClass({ displayName: 'TagTile', @@ -43,13 +44,11 @@ const TagTile = React.createClass({ onClick: function(e) { e.preventDefault(); e.stopPropagation(); - dis.dispatch({ - action: 'view_group', - group_id: this.props.groupProfile.groupId, - }); dis.dispatch({ action: 'select_tag', tag: this.props.groupProfile.groupId, + ctrlOrCmdKey: isOnlyCtrlOrCmdKeyEvent(e), + shiftKey: e.shiftKey, }); }, @@ -148,6 +147,10 @@ export default React.createClass({ const joinedGroupProfiles = await Promise.all(joinedGroupIds.map( (groupId) => FlairStore.getGroupProfileCached(this.context.matrixClient, groupId), )); + dis.dispatch({ + action: 'all_tags', + tags: joinedGroupIds, + }); this.setState({joinedGroupProfiles}); }, diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index aeb6cce6c3..98f57a60b5 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -31,7 +31,7 @@ const dis = require("../../dispatcher"); const ObjectUtils = require('../../ObjectUtils'); const Modal = require("../../Modal"); const UserActivity = require("../../UserActivity"); -const KeyCode = require('../../KeyCode'); +import { KeyCode } from '../../Keyboard'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 25909a18fa..face7ee0cb 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import FocusTrap from 'focus-trap-react'; -import * as KeyCode from '../../../KeyCode'; +import { KeyCode } from '../../../Keyboard'; import AccessibleButton from '../elements/AccessibleButton'; import sdk from '../../../index'; diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index 5ef04f5be1..b3ab499876 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -20,7 +20,7 @@ import React from 'react'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import classnames from 'classnames'; -import KeyCode from '../../../KeyCode'; +import { KeyCode } from '../../../Keyboard'; import { _t } from '../../../languageHandler'; // The amount of time to wait for further changes to the input username before diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index a005406133..1dbb7af586 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -22,6 +22,7 @@ import React from 'react'; import MatrixClientPeg from '../../../MatrixClientPeg'; import PlatformPeg from '../../../PlatformPeg'; import ScalarAuthClient from '../../../ScalarAuthClient'; +import WidgetMessaging from '../../../WidgetMessaging'; import TintableSvgButton from './TintableSvgButton'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; @@ -71,16 +72,45 @@ export default React.createClass({ return { initialising: true, // True while we are mangling the widget URL loading: true, // True while the iframe content is loading - widgetUrl: newProps.url, + widgetUrl: this._addWurlParams(newProps.url), widgetPermissionId: widgetPermissionId, // Assume that widget has permission to load if we are the user who // added it to the room, or if explicitly granted by the user hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId, error: null, deleting: false, + widgetPageTitle: null, }; }, + /** + * Add widget instance specific parameters to pass in wUrl + * Properties passed to widget instance: + * - widgetId + * - origin / parent URL + * @param {string} urlString Url string to modify + * @return {string} + * Url string with parameters appended. + * If url can not be parsed, it is returned unmodified. + */ + _addWurlParams(urlString) { + const u = url.parse(urlString); + if (!u) { + console.error("_addWurlParams", "Invalid URL", urlString); + return url; + } + + const params = qs.parse(u.query); + // Append widget ID to query parameters + params.widgetId = this.props.id; + // Append current / parent URL + params.parentUrl = window.location.href; + u.search = undefined; + u.query = params; + + return u.format(); + }, + getInitialState() { return this._getNewState(this.props); }, @@ -122,6 +152,8 @@ export default React.createClass({ }, componentWillMount() { + WidgetMessaging.startListening(); + WidgetMessaging.addEndpoint(this.props.id, this.props.url); window.addEventListener('message', this._onMessage, false); this.setScalarToken(); }, @@ -137,7 +169,7 @@ export default React.createClass({ console.warn('Non-scalar widget, not setting scalar token!', url); this.setState({ error: null, - widgetUrl: this.props.url, + widgetUrl: this._addWurlParams(this.props.url), initialising: false, }); return; @@ -150,7 +182,7 @@ export default React.createClass({ this._scalarClient.getScalarToken().done((token) => { // Append scalar_token as a query param if not already present this._scalarClient.scalarToken = token; - const u = url.parse(this.props.url); + const u = url.parse(this._addWurlParams(this.props.url)); const params = qs.parse(u.query); if (!params.scalar_token) { params.scalar_token = encodeURIComponent(token); @@ -174,6 +206,8 @@ export default React.createClass({ }, componentWillUnmount() { + WidgetMessaging.stopListening(); + WidgetMessaging.removeEndpoint(this.props.id, this.props.url); window.removeEventListener('message', this._onMessage); }, @@ -256,10 +290,23 @@ export default React.createClass({ } }, + /** + * Called when widget iframe has finished loading + */ _onLoaded() { this.setState({loading: false}); }, + /** + * Set remote content title on AppTile + * @param {string} title Title string to set on the AppTile + */ + _updateWidgetTitle(title) { + if (title) { + this.setState({widgetPageTitle: null}); + } + }, + // Widget labels to render, depending upon user permissions // These strings are translated at the point that they are inserted in to the DOM, in the render method _deleteWidgetLabel() { @@ -305,6 +352,15 @@ export default React.createClass({ }); }, + _getSafeUrl() { + const parsedWidgetUrl = url.parse(this.state.widgetUrl); + let safeWidgetUrl = ''; + if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { + safeWidgetUrl = url.format(parsedWidgetUrl); + } + return safeWidgetUrl; + }, + render() { let appTileBody; @@ -320,11 +376,6 @@ export default React.createClass({ // a link to it. const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ "allow-same-origin allow-scripts allow-presentation"; - const parsedWidgetUrl = url.parse(this.state.widgetUrl); - let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { - safeWidgetUrl = url.format(parsedWidgetUrl); - } if (this.props.show) { const loadingElement = ( @@ -347,7 +398,7 @@ export default React.createClass({ { this.state.loading && loadingElement }