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/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/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/MatrixClientPeg.js b/src/MatrixClientPeg.js index a6012f5213..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. @@ -22,6 +23,7 @@ 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, @@ -68,6 +70,8 @@ class MatrixClientPeg { unset() { this.matrixClient = null; + + MatrixActionCreators.stop(); } /** @@ -108,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`); 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/ScalarAuthClient.js b/src/ScalarAuthClient.js index c9d056f88e..3e775a94ab 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -76,6 +76,35 @@ 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); diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 7698829647..7bde607451 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'); @@ -593,6 +609,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/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/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/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 01abf966f9..38b7634edb 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -18,6 +18,8 @@ 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 { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import Notifier from '../../Notifier'; @@ -38,7 +40,7 @@ import SettingsStore from "../../settings/SettingsStore"; * * Components mounted below us can access the matrix client via the react context. */ -export default React.createClass({ +const LoggedInView = React.createClass({ displayName: 'LoggedInView', propTypes: { @@ -344,3 +346,5 @@ export default React.createClass({ ); }, }); + +export default DragDropContext(HTML5Backend)(LoggedInView); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index cd75ad8798..3ac2f5bd50 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -40,7 +40,6 @@ require('../../stores/LifecycleStore'); import PageTypes from '../../PageTypes'; import createRoom from "../../createRoom"; -import * as UDEHandler from '../../UnknownDeviceErrorHandler'; import KeyRequestHandler from '../../KeyRequestHandler'; import { _t, getCurrentLanguage } from '../../languageHandler'; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; @@ -84,7 +83,7 @@ const ONBOARDING_FLOW_STARTERS = [ 'view_create_group', ]; -module.exports = React.createClass({ +export default React.createClass({ // we export this so that the integration tests can use it :-S statics: { VIEWS: VIEWS, @@ -295,7 +294,6 @@ module.exports = React.createClass({ componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); - UDEHandler.startListening(); this.focusComposer = false; @@ -361,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); }, @@ -1068,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.canResetTimelineInRoom(roomId); }); cli.on('sync', function(state, prevState) { @@ -1142,6 +1139,37 @@ module.exports = React.createClass({ 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; + } + }); }, /** @@ -1398,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'}); }); }, @@ -1466,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}`); @@ -1498,7 +1523,7 @@ module.exports = React.createClass({ */ const LoggedInView = sdk.getComponent('structures.LoggedInView'); return ( - 0) { return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; @@ -241,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'); @@ -263,28 +349,8 @@ module.exports = React.createClass({ ); } - if (this.props.unsentMessageError) { - return ( -
- /!\ -
- { this.props.unsentMessageError } -
-
- { - _t("Resend all or cancel all now. " + - "You can also select individual messages to resend or cancel.", - {}, - { - 'resendText': (sub) => - { sub }, - 'cancelText': (sub) => - { sub }, - }, - ) } -
-
- ); + if (this.state.unsentMessages.length > 0) { + return this._getUnsentMessageContent(); } // unread count trumps who is typing since the unread count is only @@ -342,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 1fda05fb76..138c110c4f 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -26,7 +26,6 @@ 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 MatrixClientPeg = require("../../MatrixClientPeg"); @@ -34,7 +33,6 @@ 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'); @@ -110,7 +108,6 @@ module.exports = React.createClass({ draggingFile: false, searching: false, searchResults: null, - unsentMessageError: '', callState: null, guestsCanJoin: false, canPeek: false, @@ -202,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); } @@ -462,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': @@ -711,35 +702,6 @@ module.exports = React.createClass({ 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) { @@ -784,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({ @@ -935,11 +889,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"); @@ -1571,12 +1521,9 @@ module.exports = React.createClass({ statusBar = : -
; - return -
- - { tip } -
-
; - }, -}); - -export default React.createClass({ +const TagPanel = React.createClass({ displayName: 'TagPanel', contextTypes: { @@ -98,7 +36,17 @@ export default React.createClass({ getInitialState() { return { - joinedGroupProfiles: [], + // A list of group profiles for tags that are group IDs. The intention in future + // is to allow arbitrary tags to be selected in the TagPanel, not just groups. + // For now, it suffices to maintain a list of ordered group profiles. + orderedGroupTagProfiles: [ + // { + // groupId: '+awesome:foo.bar',{ + // name: 'My Awesome Community', + // avatarUrl: 'mxc://...', + // shortDescription: 'Some description...', + // }, + ], selectedTags: [], }; }, @@ -115,8 +63,23 @@ export default React.createClass({ selectedTags: FilterStore.getSelectedTags(), }); }); + this._tagOrderStoreToken = TagOrderStore.addListener(() => { + if (this.unmounted) { + return; + } - this._fetchJoinedRooms(); + const orderedTags = TagOrderStore.getOrderedTags() || []; + const orderedGroupTags = orderedTags.filter((t) => t[0] === '+'); + // XXX: One profile lookup failing will bring the whole lot down + Promise.all(orderedGroupTags.map( + (groupId) => FlairStore.getGroupProfileCached(this.context.matrixClient, groupId), + )).then((orderedGroupTagProfiles) => { + if (this.unmounted) return; + this.setState({orderedGroupTagProfiles}); + }); + }); + // This could be done by anything with a matrix client + dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); }, componentWillUnmount() { @@ -129,7 +92,7 @@ export default React.createClass({ _onGroupMyMembership() { if (this.unmounted) return; - this._fetchJoinedRooms(); + dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); }, onClick() { @@ -141,27 +104,21 @@ export default React.createClass({ dis.dispatch({action: 'view_create_group'}); }, - async _fetchJoinedRooms() { - const joinedGroupResponse = await this.context.matrixClient.getJoinedGroups(); - const joinedGroupIds = joinedGroupResponse.groups; - const joinedGroupProfiles = await Promise.all(joinedGroupIds.map( - (groupId) => FlairStore.getGroupProfileCached(this.context.matrixClient, groupId), - )); - dis.dispatch({ - action: 'all_tags', - tags: joinedGroupIds, - }); - this.setState({joinedGroupProfiles}); + onTagTileEndDrag() { + dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient)); }, render() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); - const tags = this.state.joinedGroupProfiles.map((groupProfile, index) => { - return { + return ; }); return
@@ -174,3 +131,4 @@ export default React.createClass({
; }, }); +export default TagPanel; diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js index 34cf544bb7..9c19ee6eca 100644 --- a/src/components/views/dialogs/UnknownDeviceDialog.js +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -1,5 +1,6 @@ /* 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. @@ -15,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import GeminiScrollbar from 'react-gemini-scrollbar'; @@ -22,6 +24,14 @@ import Resend from '../../../Resend'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; +function markAllDevicesKnown(devices) { + Object.keys(devices).forEach((userId) => { + Object.keys(devices[userId]).map((deviceId) => { + MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true); + }); + }); +} + function DeviceListEntry(props) { const {userId, device} = props; @@ -38,10 +48,10 @@ function DeviceListEntry(props) { } DeviceListEntry.propTypes = { - userId: React.PropTypes.string.isRequired, + userId: PropTypes.string.isRequired, // deviceinfo - device: React.PropTypes.object.isRequired, + device: PropTypes.object.isRequired, }; @@ -61,10 +71,10 @@ function UserUnknownDeviceList(props) { } UserUnknownDeviceList.propTypes = { - userId: React.PropTypes.string.isRequired, + userId: PropTypes.string.isRequired, // map from deviceid -> deviceinfo - userDevices: React.PropTypes.object.isRequired, + userDevices: PropTypes.object.isRequired, }; @@ -83,7 +93,7 @@ function UnknownDeviceList(props) { UnknownDeviceList.propTypes = { // map from userid -> deviceid -> deviceinfo - devices: React.PropTypes.object.isRequired, + devices: PropTypes.object.isRequired, }; @@ -91,28 +101,63 @@ export default React.createClass({ displayName: 'UnknownDeviceDialog', propTypes: { - room: React.PropTypes.object.isRequired, + room: PropTypes.object.isRequired, - // map from userid -> deviceid -> deviceinfo - devices: React.PropTypes.object.isRequired, - onFinished: React.PropTypes.func.isRequired, + // map from userid -> deviceid -> deviceinfo or null if devices are not yet loaded + devices: PropTypes.object, + + onFinished: PropTypes.func.isRequired, + + // Label for the button that marks all devices known and tries the send again + sendAnywayLabel: PropTypes.string.isRequired, + + // Label for the button that to send the event if you've verified all devices + sendLabel: PropTypes.string.isRequired, + + // function to retry the request once all devices are verified / known + onSend: PropTypes.func.isRequired, }, - componentDidMount: function() { - // Given we've now shown the user the unknown device, it is no longer - // unknown to them. Therefore mark it as 'known'. - Object.keys(this.props.devices).forEach((userId) => { - Object.keys(this.props.devices[userId]).map((deviceId) => { - MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true); - }); - }); + componentWillMount: function() { + MatrixClientPeg.get().on("deviceVerificationChanged", this._onDeviceVerificationChanged); + }, - // XXX: temporary logging to try to diagnose - // https://github.com/vector-im/riot-web/issues/3148 - console.log('Opening UnknownDeviceDialog'); + componentWillUnmount: function() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("deviceVerificationChanged", this._onDeviceVerificationChanged); + } + }, + + _onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) { + if (this.props.devices[userId] && this.props.devices[userId][deviceId]) { + // XXX: Mutating props :/ + this.props.devices[userId][deviceId] = deviceInfo; + this.forceUpdate(); + } + }, + + _onDismissClicked: function() { + this.props.onFinished(); + }, + + _onSendAnywayClicked: function() { + markAllDevicesKnown(this.props.devices); + + this.props.onFinished(); + this.props.onSend(); + }, + + _onSendClicked: function() { + this.props.onFinished(); + this.props.onSend(); }, render: function() { + if (this.props.devices === null) { + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + let warning; if (SettingsStore.getValue("blacklistUnverifiedDevices", this.props.room.roomId)) { warning = ( @@ -133,15 +178,30 @@ export default React.createClass({ ); } + let haveUnknownDevices = false; + Object.keys(this.props.devices).forEach((userId) => { + Object.keys(this.props.devices[userId]).map((deviceId) => { + const device = this.props.devices[userId][deviceId]; + if (device.isUnverified() && !device.isKnown()) { + haveUnknownDevices = true; + } + }); + }); + let sendButton; + if (haveUnknownDevices) { + sendButton = ; + } else { + sendButton = ; + } + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( { - // XXX: temporary logging to try to diagnose - // https://github.com/vector-im/riot-web/issues/3148 - console.log("UnknownDeviceDialog closed by escape"); - this.props.onFinished(); - }} + onFinished={this.props.onFinished} title={_t('Room contains unknown devices')} > @@ -154,21 +214,11 @@ export default React.createClass({
+ {sendButton} -
diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index a005406133..0d67b4c814 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'; @@ -51,11 +52,13 @@ export default React.createClass({ userId: React.PropTypes.string.isRequired, // UserId of the entity that added / modified the widget creatorUserId: React.PropTypes.string, + waitForIframeLoad: React.PropTypes.bool, }, getDefaultProps() { return { url: "", + waitForIframeLoad: true, }; }, @@ -70,17 +73,46 @@ export default React.createClass({ const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); return { initialising: true, // True while we are mangling the widget URL - loading: true, // True while the iframe content is loading - widgetUrl: newProps.url, + loading: this.props.waitForIframeLoad, // True while the iframe content is loading + 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: newProps.widgetPageTitle, }; }, + /** + * 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 +154,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 +171,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 +184,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); @@ -164,6 +198,11 @@ export default React.createClass({ widgetUrl: u.format(), initialising: false, }); + + // Fetch page title from remote content if not already set + if (!this.state.widgetPageTitle && params.url) { + this._fetchWidgetTitle(params.url); + } }, (err) => { console.error("Failed to get scalar_token", err); this.setState({ @@ -174,6 +213,8 @@ export default React.createClass({ }, componentWillUnmount() { + WidgetMessaging.stopListening(); + WidgetMessaging.removeEndpoint(this.props.id, this.props.url); window.removeEventListener('message', this._onMessage); }, @@ -181,10 +222,14 @@ export default React.createClass({ if (nextProps.url !== this.props.url) { this._getNewState(nextProps); this.setScalarToken(); - } else if (nextProps.show && !this.props.show) { + } else if (nextProps.show && !this.props.show && this.props.waitForIframeLoad) { this.setState({ loading: true, }); + } else if (nextProps.widgetPageTitle !== this.props.widgetPageTitle) { + this.setState({ + widgetPageTitle: nextProps.widgetPageTitle, + }); } }, @@ -256,10 +301,27 @@ export default React.createClass({ } }, + /** + * Called when widget iframe has finished loading + */ _onLoaded() { this.setState({loading: false}); }, + /** + * Set remote content title on AppTile + * @param {string} url Url to check for title + */ + _fetchWidgetTitle(url) { + this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => { + if (widgetPageTitle) { + this.setState({widgetPageTitle: widgetPageTitle}); + } + }, (err) =>{ + console.error("Failed to get page title", err); + }); + }, + // 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 +367,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 +391,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 +413,7 @@ export default React.createClass({ { this.state.loading && loadingElement }