diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 8d40b65124..7fe625f8b9 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -275,6 +275,13 @@ class ContentMessages { this.nextId = 0; } + sendStickerContentToRoom(url, roomId, info, text, matrixClient) { + return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { + console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); + throw e; + }); + } + sendContentToRoom(file, roomId, matrixClient) { const content = { body: file.name || 'Attachment', diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js new file mode 100644 index 0000000000..ad1f1acbbd --- /dev/null +++ b/src/FromWidgetPostMessageApi.js @@ -0,0 +1,201 @@ +/* +Copyright 2018 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 URL from 'url'; +import dis from './dispatcher'; +import IntegrationManager from './IntegrationManager'; +import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; + +const WIDGET_API_VERSION = '0.0.1'; // Current API version +const SUPPORTED_WIDGET_API_VERSIONS = [ + '0.0.1', +]; +const INBOUND_API_NAME = 'fromWidget'; + +// Listen for and handle incomming requests using the 'fromWidget' postMessage +// API and initiate responses +export default class FromWidgetPostMessageApi { + constructor() { + this.widgetMessagingEndpoints = []; + + this.start = this.start.bind(this); + this.stop = this.stop.bind(this); + this.onPostMessage = this.onPostMessage.bind(this); + } + + start() { + window.addEventListener('message', this.onPostMessage); + } + + stop() { + window.removeEventListener('message', this.onPostMessage); + } + + /** + * Register a widget endpoint for trusted postMessage communication + * @param {string} widgetId Unique widget identifier + * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) + */ + addEndpoint(widgetId, endpointUrl) { + const u = URL.parse(endpointUrl); + if (!u || !u.protocol || !u.host) { + console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl); + return; + } + + const origin = u.protocol + '//' + u.host; + const endpoint = new WidgetMessagingEndpoint(widgetId, origin); + if (this.widgetMessagingEndpoints.some(function(ep) { + return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); + })) { + // Message endpoint already registered + console.warn('Add FromWidgetPostMessageApi - Endpoint already registered'); + return; + } else { + console.warn(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint); + this.widgetMessagingEndpoints.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 + */ + removeEndpoint(widgetId, endpointUrl) { + const u = URL.parse(endpointUrl); + if (!u || !u.protocol || !u.host) { + console.warn('Remove widget messaging endpoint - Invalid origin'); + return; + } + + const origin = u.protocol + '//' + u.host; + if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) { + const length = this.widgetMessagingEndpoints.length; + this.widgetMessagingEndpoints = this.widgetMessagingEndpoints. + filter(function(endpoint) { + return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin); + }); + return (length > this.widgetMessagingEndpoints.length); + } + return false; + } + + /** + * Handle widget postMessage events + * Messages are only handled where a valid, registered messaging endpoints + * @param {Event} event Event to handle + * @return {undefined} + */ + onPostMessage(event) { + if (!event.origin) { // Handle chrome + event.origin = event.originalEvent.origin; + } + + // Event origin is empty string if undefined + if ( + event.origin.length === 0 || + !this.trustedEndpoint(event.origin) || + event.data.api !== INBOUND_API_NAME || + !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') { + console.warn('Widget reported content loaded for', widgetId); + dis.dispatch({ + action: 'widget_content_loaded', + widgetId: widgetId, + }); + this.sendResponse(event, {success: true}); + } else if (action === 'supported_api_versions') { + this.sendResponse(event, { + api: INBOUND_API_NAME, + supported_versions: SUPPORTED_WIDGET_API_VERSIONS, + }); + } else if (action === 'api_version') { + this.sendResponse(event, { + api: INBOUND_API_NAME, + version: WIDGET_API_VERSION, + }); + } else if (action === 'm.sticker') { + // console.warn('Got sticker message from widget', widgetId); + dis.dispatch({action: 'm.sticker', data: event.data.widgetData, widgetId: event.data.widgetId}); + } else if (action === 'integration_manager_open') { + // Close the stickerpicker + dis.dispatch({action: 'stickerpicker_close'}); + // Open the integration manager + const data = event.data.widgetData; + const integType = (data && data.integType) ? data.integType : null; + const integId = (data && data.integId) ? data.integId : null; + IntegrationManager.open(integType, integId); + } else { + console.warn('Widget postMessage event unhandled'); + this.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 + */ + trustedEndpoint(origin) { + if (!origin) { + return false; + } + + return this.widgetMessagingEndpoints.some((endpoint) => { + // TODO / FIXME -- Should this also check the widgetId? + 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 + */ + 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) + */ + 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); + } +} diff --git a/src/IntegrationManager.js b/src/IntegrationManager.js new file mode 100644 index 0000000000..eb45a1f425 --- /dev/null +++ b/src/IntegrationManager.js @@ -0,0 +1,73 @@ +/* +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 Modal from './Modal'; +import sdk from './index'; +import SdkConfig from './SdkConfig'; +import ScalarMessaging from './ScalarMessaging'; +import ScalarAuthClient from './ScalarAuthClient'; +import RoomViewStore from './stores/RoomViewStore'; + +if (!global.mxIntegrationManager) { + global.mxIntegrationManager = {}; +} + +export default class IntegrationManager { + static _init() { + if (!global.mxIntegrationManager.client || !global.mxIntegrationManager.connected) { + if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { + ScalarMessaging.startListening(); + global.mxIntegrationManager.client = new ScalarAuthClient(); + + return global.mxIntegrationManager.client.connect().then(() => { + global.mxIntegrationManager.connected = true; + }).catch((e) => { + console.error("Failed to connect to integrations server", e); + global.mxIntegrationManager.error = e; + }); + } else { + console.error('Invalid integration manager config', SdkConfig.get()); + } + } + } + + /** + * Launch the integrations manager on the stickers integration page + * @param {string} integName integration / widget type + * @param {string} integId integration / widget ID + * @param {function} onFinished Callback to invoke on integration manager close + */ + static async open(integName, integId, onFinished) { + await IntegrationManager._init(); + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + if (global.mxIntegrationManager.error || + !(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) { + console.error("Scalar error", global.mxIntegrationManager); + return; + } + const integType = 'type_' + integName; + const src = (global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials()) ? + global.mxIntegrationManager.client.getScalarInterfaceUrlForRoom( + {roomId: RoomViewStore.getRoomId()}, + integType, + integId, + ) : + null; + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + src: src, + onFinished: onFinished, + }, "mx_IntegrationsManager"); + } +} diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 568dd6d185..c7e439bf2e 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -148,10 +148,48 @@ class ScalarAuthClient { return defer.promise; } - getScalarInterfaceUrlForRoom(roomId, screen, id) { + /** + * Mark all assets associated with the specified widget as "disabled" in the + * integration manager database. + * This can be useful to temporarily prevent purchased assets from being displayed. + * @param {string} widgetType [description] + * @param {string} widgetId [description] + * @return {Promise} Resolves on completion + */ + disableWidgetAssets(widgetType, widgetId) { + let url = SdkConfig.get().integrations_rest_url + '/widgets/set_assets_state'; + url = this.getStarterLink(url); + return new Promise((resolve, reject) => { + request({ + method: 'GET', + uri: url, + json: true, + qs: { + 'widget_type': widgetType, + 'widget_id': widgetId, + 'state': 'disable', + }, + }, (err, response, body) => { + if (err) { + reject(err); + } else if (response.statusCode / 100 !== 2) { + reject({statusCode: response.statusCode}); + } else if (!body) { + reject(new Error("Failed to set widget assets state")); + } else { + resolve(); + } + }); + }); + } + + getScalarInterfaceUrlForRoom(room, screen, id) { + const roomId = room.roomId; + const roomName = room.name; let url = SdkConfig.get().integrations_ui_url; url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); + url += "&room_name=" + encodeURIComponent(roomName); url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme")); if (id) { url += '&integ_id=' + encodeURIComponent(id); diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index fc8ee9edf6..123d02159e 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -235,6 +235,7 @@ const SdkConfig = require('./SdkConfig'); const MatrixClientPeg = require("./MatrixClientPeg"); const MatrixEvent = require("matrix-js-sdk").MatrixEvent; const dis = require("./dispatcher"); +const Widgets = require('./utils/widgets'); import { _t } from './languageHandler'; function sendResponse(event, res) { @@ -291,6 +292,7 @@ function setWidget(event, roomId) { const widgetUrl = event.data.url; const widgetName = event.data.name; // optional const widgetData = event.data.data; // optional + const userWidget = event.data.userWidget; const client = MatrixClientPeg.get(); if (!client) { @@ -330,17 +332,54 @@ function setWidget(event, roomId) { name: widgetName, data: widgetData, }; - if (widgetUrl === null) { // widget is being deleted - content = {}; - } - client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => { - sendResponse(event, { - success: true, + if (userWidget) { + const client = MatrixClientPeg.get(); + const userWidgets = Widgets.getUserWidgets(); + + // Delete existing widget with ID + try { + delete userWidgets[widgetId]; + } catch (e) { + console.error(`$widgetId is non-configurable`); + } + + // Add new widget / update + if (widgetUrl !== null) { + userWidgets[widgetId] = { + content: content, + sender: client.getUserId(), + stateKey: widgetId, + type: 'm.widget', + id: widgetId, + }; + } + + client.setAccountData('m.widgets', userWidgets).then(() => { + sendResponse(event, { + success: true, + }); + + dis.dispatch({ action: "user_widget_updated" }); }); - }, (err) => { - sendError(event, _t('Failed to send request.'), err); - }); + } else { // Room widget + if (!roomId) { + sendError(event, _t('Missing roomId.'), null); + } + + if (widgetUrl === null) { // widget is being deleted + content = {}; + } + // TODO - Room widgets need to be moved to 'm.widget' state events + // https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing + client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => { + sendResponse(event, { + success: true, + }); + }, (err) => { + sendError(event, _t('Failed to send request.'), err); + }); + } } function getWidgets(event, roomId) { @@ -349,19 +388,30 @@ function getWidgets(event, roomId) { 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 stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); - // Only return widgets which have required fields - const widgetStateEvents = []; - stateEvents.forEach((ev) => { - if (ev.getContent().type && ev.getContent().url) { - widgetStateEvents.push(ev.event); // return the raw event + let widgetStateEvents = []; + + if (roomId) { + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); + return; } - }); + // TODO - Room widgets need to be moved to 'm.widget' state events + // https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing + const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); + // Only return widgets which have required fields + if (room) { + stateEvents.forEach((ev) => { + if (ev.getContent().type && ev.getContent().url) { + widgetStateEvents.push(ev.event); // return the raw event + } + }); + } + } + + // Add user widgets (not linked to a specific room) + const userWidgets = Widgets.getUserWidgetsArray(); + widgetStateEvents = widgetStateEvents.concat(userWidgets); sendResponse(event, widgetStateEvents); } @@ -578,9 +628,22 @@ const onMessage = function(event) { const roomId = event.data.room_id; const userId = event.data.user_id; + if (!roomId) { - sendError(event, _t('Missing room_id in request')); - return; + // These APIs don't require roomId + // Get and set user widgets (not associated with a specific room) + // If roomId is specified, it must be validated, so room-based widgets agreed + // handled further down. + if (event.data.action === "get_widgets") { + getWidgets(event, null); + return; + } else if (event.data.action === "set_widget") { + setWidget(event, null); + return; + } else { + sendError(event, _t('Missing room_id in request')); + return; + } } let promise = Promise.resolve(currentRoomId); if (!currentRoomId) { @@ -601,6 +664,15 @@ const onMessage = function(event) { return; } + // Get and set room-based widgets + if (event.data.action === "get_widgets") { + getWidgets(event, roomId); + return; + } else if (event.data.action === "set_widget") { + setWidget(event, roomId); + return; + } + // These APIs don't require userId if (event.data.action === "join_rules_state") { getJoinRules(event, roomId); @@ -611,12 +683,6 @@ const onMessage = function(event) { } else if (event.data.action === "get_membership_count") { getMembershipCount(event, roomId); return; - } else if (event.data.action === "set_widget") { - setWidget(event, roomId); - return; - } else if (event.data.action === "get_widgets") { - getWidgets(event, roomId); - return; } else if (event.data.action === "get_room_enc_state") { getRoomEncState(event, roomId); return; diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js new file mode 100644 index 0000000000..ccaa0207c1 --- /dev/null +++ b/src/ToWidgetPostMessageApi.js @@ -0,0 +1,86 @@ +/* +Copyright 2018 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 Promise from "bluebird"; + +// const OUTBOUND_API_NAME = 'toWidget'; + +// Initiate requests using the "toWidget" postMessage API and handle responses +// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a +// response field +export default class ToWidgetPostMessageApi { + constructor(timeoutMs) { + this._timeoutMs = timeoutMs || 5000; // default to 5s timer + this._counter = 0; + this._requestMap = { + // $ID: {resolve, reject} + }; + this.start = this.start.bind(this); + this.stop = this.stop.bind(this); + this.onPostMessage = this.onPostMessage.bind(this); + } + + start() { + window.addEventListener('message', this.onPostMessage); + } + + stop() { + window.removeEventListener('message', this.onPostMessage); + } + + onPostMessage(ev) { + // THIS IS ALL UNSAFE EXECUTION. + // We do not verify who the sender of `ev` is! + const payload = ev.data; + // NOTE: Workaround for running in a mobile WebView where a + // postMessage immediately triggers this callback even though it is + // not the response. + if (payload.response === undefined) { + return; + } + const promise = this._requestMap[payload._id]; + if (!promise) { + return; + } + delete this._requestMap[payload._id]; + promise.resolve(payload); + } + + // Initiate outbound requests (toWidget) + exec(action, targetWindow, targetOrigin) { + targetWindow = targetWindow || window.parent; // default to parent window + targetOrigin = targetOrigin || "*"; + this._counter += 1; + action._id = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter; + + return new Promise((resolve, reject) => { + this._requestMap[action._id] = {resolve, reject}; + targetWindow.postMessage(action, targetOrigin); + + if (this._timeoutMs > 0) { + setTimeout(() => { + if (!this._requestMap[action._id]) { + return; + } + console.error("postMessage request timed out. Sent object: " + JSON.stringify(action), + this._requestMap); + this._requestMap[action._id].reject(new Error("Timed out")); + delete this._requestMap[action._id]; + }, this._timeoutMs); + } + }); + } +} diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 0f23413b5f..effd96dacf 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -15,312 +15,91 @@ 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", -} - +* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for +* spec. details / documentation. */ -import URL from 'url'; +import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; +import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; -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.mxFromWidgetMessaging) { + global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); + global.mxFromWidgetMessaging.start(); } -if (!global.mxWidgetMessagingMessageEndpoints) { - global.mxWidgetMessagingMessageEndpoints = []; +if (!global.mxToWidgetMessaging) { + global.mxToWidgetMessaging = new ToWidgetPostMessageApi(); + global.mxToWidgetMessaging.start(); } +const OUTBOUND_API_NAME = 'toWidget'; -/** - * 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"); - } +export default class WidgetMessaging { + constructor(widgetId, widgetUrl, target) { this.widgetId = widgetId; - this.endpointUrl = endpointUrl; + this.widgetUrl = widgetUrl; + this.target = target; + this.fromWidget = global.mxFromWidgetMessaging; + this.toWidget = global.mxToWidgetMessaging; + this.start(); + } + + messageToWidget(action) { + return this.toWidget.exec(action, this.target).then((data) => { + // Check for errors and reject if found + if (data.response === undefined) { // null is valid + throw new Error("Missing 'response' field"); + } + if (data.response && data.response.error) { + const err = data.response.error; + const msg = String(err.message ? err.message : "An error was returned"); + if (err._error) { + console.error(err._error); + } + // Potential XSS attack if 'msg' is not appropriately sanitized, + // as it is untrusted input by our parent window (which we assume is Riot). + // We can't aggressively sanitize [A-z0-9] since it might be a translation. + throw new Error(msg); + } + // Return the response field for the request + return data.response; + }); + } + + /** + * Request a screenshot from a widget + * @return {Promise} To be resolved with screenshot data when it has been generated + */ + getScreenshot() { + console.warn('Requesting screenshot for', this.widgetId); + return this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: "screenshot", + }) + .catch((error) => new Error("Failed to get screenshot: " + error.message)) + .then((response) => response.screenshot); + } + + /** + * Request capabilities required by the widget + * @return {Promise} To be resolved with an array of requested widget capabilities + */ + getCapabilities() { + console.warn('Requesting capabilities for', this.widgetId); + return this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: "capabilities", + }).then((response) => { + console.warn('Got capabilities for', this.widgetId, response.capabilities); + return response.capabilities; + }); + } + + + start() { + this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl); + } + + stop() { + this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl); } } - -export default { - startListening: startListening, - stopListening: stopListening, - addEndpoint: addEndpoint, - removeEndpoint: removeEndpoint, -}; diff --git a/src/WidgetMessagingEndpoint.js b/src/WidgetMessagingEndpoint.js new file mode 100644 index 0000000000..9114e12137 --- /dev/null +++ b/src/WidgetMessagingEndpoint.js @@ -0,0 +1,37 @@ +/* +Copyright 2018 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. +*/ + + +/** + * Represents mapping of widget instance to URLs for trusted postMessage communication. + */ +export default 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; + } +} diff --git a/src/WidgetUtils.js b/src/WidgetUtils.js index 34c998978d..5f45a8c58c 100644 --- a/src/WidgetUtils.js +++ b/src/WidgetUtils.js @@ -17,8 +17,8 @@ limitations under the License. import MatrixClientPeg from './MatrixClientPeg'; export default class WidgetUtils { - /* Returns true if user is able to send state events to modify widgets in this room + * (Does not apply to non-room-based / user widgets) * @param roomId -- The ID of the room to check * @return Boolean -- true if the user can modify widgets in this room * @throws Error -- specifies the error reason diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index 94f5713a79..59a68181c3 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -52,14 +52,19 @@ module.exports = { createMenu: function(Element, props) { const self = this; - const closeMenu = function() { + const closeMenu = function(...args) { ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); if (props && props.onFinished) { - props.onFinished.apply(null, arguments); + props.onFinished.apply(null, args); } }; + // Close the menu on window resize + const windowResize = function() { + closeMenu(); + }; + const position = {}; let chevronFace = null; @@ -130,13 +135,17 @@ module.exports = { menuStyle["backgroundColor"] = props.menuColour; } + if (!isNaN(Number(props.menuPaddingTop))) { + menuStyle["paddingTop"] = props.menuPaddingTop; + } + // FIXME: If a menu uses getDefaultProps it clobbers the onFinished // property set here so you can't close the menu from a button click! const menu = (
{ chevron } - +
diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index e86b76333d..3249cae22c 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -68,6 +68,9 @@ const FilePanel = React.createClass({ "room": { "timeline": { "contains_url": true, + "not_types": [ + "m.sticker", + ], }, }, }, diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 6a8c2e9c2e..773c9710dd 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -450,7 +450,12 @@ module.exports = React.createClass({ if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId - && mxEv.getType() == prevEvent.getType()) { + // The preferred way of checking for 'continuation messages' is by + // checking whether subsiquent messages from the same user have a + // message body. This is because all messages intended to be displayed + // should have a 'body' whereas some (non-m.room) messages (such as + // m.sticker) may not have a message 'type'. + && Boolean(mxEv.getContent().body) == Boolean(prevEvent.getContent().body)) { continuation = true; } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index b71978647f..6fc16b9760 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -467,6 +467,15 @@ module.exports = React.createClass({ case 'message_sent': this._checkIfAlone(this.state.room); break; + case 'post_sticker_message': + this.injectSticker( + payload.data.content.url, + payload.data.content.info, + payload.data.description || payload.data.name); + break; + case 'picture_snapshot': + this.uploadFile(payload.file); + break; case 'notifier_enabled': case 'upload_failed': case 'upload_started': @@ -907,7 +916,7 @@ module.exports = React.createClass({ ContentMessages.sendContentToRoom( file, this.state.room.roomId, MatrixClientPeg.get(), - ).done(undefined, (error) => { + ).catch((error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this return; @@ -916,11 +925,27 @@ module.exports = React.createClass({ console.error("Failed to upload file " + file + " " + error); Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, { title: _t('Failed to upload file'), - description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or the file too big")), + description: ((error && error.message) + ? error.message : _t("Server may be unavailable, overloaded, or the file too big")), }); }); }, + injectSticker: function(url, info, text) { + if (MatrixClientPeg.get().isGuest()) { + dis.dispatch({action: 'view_set_mxid'}); + return; + } + + ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get()) + .done(undefined, (error) => { + if (error.name === "UnknownDeviceError") { + // Let the staus bar handle this + return; + } + }); + }, + onSearch: function(term, scope) { this.setState({ searchTerm: term, @@ -1603,7 +1628,8 @@ module.exports = React.createClass({ displayConfCallNotification={this.state.displayConfCallNotification} maxHeight={this.state.auxPanelMaxHeight} onResize={this.onChildResize} - showApps={this.state.showApps && !this.state.editingRoomSettings} > + showApps={this.state.showApps} + hideAppsDrawer={this.state.editingRoomSettings} > { aux } ); diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 3972b0d0be..98b68bd890 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -1,4 +1,4 @@ -/* +/** Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); @@ -36,32 +36,23 @@ import WidgetUtils from '../../../WidgetUtils'; import dis from '../../../dispatcher'; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; +const ENABLE_REACT_PERF = false; -export default React.createClass({ - displayName: 'AppTile', +export default class AppTile extends React.Component { + constructor(props) { + super(props); + this.state = this._getNewState(props); - propTypes: { - id: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - room: PropTypes.object.isRequired, - type: PropTypes.string.isRequired, - // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. - // This should be set to true when there is only one widget in the app drawer, otherwise it should be false. - fullWidth: PropTypes.bool, - // UserId of the current user - userId: PropTypes.string.isRequired, - // UserId of the entity that added / modified the widget - creatorUserId: PropTypes.string, - waitForIframeLoad: PropTypes.bool, - }, - - getDefaultProps() { - return { - url: "", - waitForIframeLoad: true, - }; - }, + this._onWidgetAction = this._onWidgetAction.bind(this); + this._onMessage = this._onMessage.bind(this); + this._onLoaded = this._onLoaded.bind(this); + this._onEditClick = this._onEditClick.bind(this); + this._onDeleteClick = this._onDeleteClick.bind(this); + this._onSnapshotClick = this._onSnapshotClick.bind(this); + this.onClickMenuBar = this.onClickMenuBar.bind(this); + this._onMinimiseClick = this._onMinimiseClick.bind(this); + this._onInitialLoad = this._onInitialLoad.bind(this); + } /** * Set initial component state when the App wUrl (widget URL) is being updated. @@ -73,8 +64,8 @@ export default React.createClass({ const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_'); const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); return { - initialising: true, // True while we are mangling the widget URL - loading: this.props.waitForIframeLoad, // True while the iframe content is loading + initialising: true, // True while we are mangling the widget 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 @@ -83,8 +74,20 @@ export default React.createClass({ error: null, deleting: false, widgetPageTitle: newProps.widgetPageTitle, + allowedCapabilities: (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) ? + this.props.whitelistCapabilities : [], + requestedCapabilities: [], }; - }, + } + + /** + * Does the widget support a given capability + * @param {[type]} capability Capability to check for + * @return {Boolean} True if capability supported + */ + _hasCapability(capability) { + return this.state.allowedCapabilities.some((c) => {return c === capability;}); + } /** * Add widget instance specific parameters to pass in wUrl @@ -112,11 +115,7 @@ export default React.createClass({ u.query = params; return u.format(); - }, - - getInitialState() { - return this._getNewState(this.props); - }, + } /** * Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api @@ -140,7 +139,7 @@ export default React.createClass({ } } return false; - }, + } isMixedContent() { const parentContentProtocol = window.location.protocol; @@ -152,14 +151,36 @@ export default React.createClass({ return true; } return false; - }, + } componentWillMount() { - WidgetMessaging.startListening(); - WidgetMessaging.addEndpoint(this.props.id, this.props.url); - window.addEventListener('message', this._onMessage, false); this.setScalarToken(); - }, + } + + componentDidMount() { + // Legacy Jitsi widget messaging -- TODO replace this with standard widget + // postMessaging API + window.addEventListener('message', this._onMessage, false); + + // Widget action listeners + this.dispatcherRef = dis.register(this._onWidgetAction); + } + + componentWillUnmount() { + // Widget action listeners + dis.unregister(this.dispatcherRef); + + // Widget postMessage listeners + try { + if (this.widgetMessaging) { + this.widgetMessaging.stop(); + } + } catch (e) { + console.error('Failed to stop listening for widgetMessaging events', e.message); + } + // Jitsi listener + window.removeEventListener('message', this._onMessage); + } /** * Adds a scalar token to the widget URL, if required @@ -211,13 +232,7 @@ export default React.createClass({ initialising: false, }); }); - }, - - componentWillUnmount() { - WidgetMessaging.stopListening(); - WidgetMessaging.removeEndpoint(this.props.id, this.props.url); - window.removeEventListener('message', this._onMessage); - }, + } componentWillReceiveProps(nextProps) { if (nextProps.url !== this.props.url) { @@ -232,8 +247,10 @@ export default React.createClass({ widgetPageTitle: nextProps.widgetPageTitle, }); } - }, + } + // Legacy Jitsi widget messaging + // TODO -- This should be replaced with the new widget postMessaging API _onMessage(event) { if (this.props.type !== 'jitsi') { return; @@ -251,63 +268,140 @@ export default React.createClass({ .document.querySelector('iframe[id^="jitsiConferenceFrame"]'); PlatformPeg.get().setupScreenSharingForIframe(iframe); } - }, + } _canUserModify() { return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); - }, + } _onEditClick(e) { console.log("Edit widget ID ", this.props.id); - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - const src = this._scalarClient.getScalarInterfaceUrlForRoom( - this.props.room.roomId, 'type_' + this.props.type, this.props.id); - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { - src: src, - }, "mx_IntegrationsManager"); - }, + if (this.props.onEditClick) { + this.props.onEditClick(); + } else { + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + const src = this._scalarClient.getScalarInterfaceUrlForRoom( + this.props.room, 'type_' + this.props.type, this.props.id); + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + src: src, + }, "mx_IntegrationsManager"); + } + } + + _onSnapshotClick(e) { + console.warn("Requesting widget snapshot"); + this.widgetMessaging.getScreenshot() + .catch((err) => { + console.error("Failed to get screenshot", err); + }) + .then((screenshot) => { + dis.dispatch({ + action: 'picture_snapshot', + file: screenshot, + }, true); + }); + } /* If user has permission to modify widgets, delete the widget, * otherwise revoke access for the widget to load in the user's browser */ _onDeleteClick() { - if (this._canUserModify()) { - // Show delete confirmation dialog - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { - title: _t("Delete Widget"), - description: _t( - "Deleting a widget removes it for all users in this room." + - " Are you sure you want to delete this widget?"), - button: _t("Delete widget"), - onFinished: (confirmed) => { - if (!confirmed) { - return; - } - this.setState({deleting: true}); - MatrixClientPeg.get().sendStateEvent( - this.props.room.roomId, - 'im.vector.modular.widgets', - {}, // empty content - this.props.id, - ).catch((e) => { - console.error('Failed to delete widget', e); - this.setState({deleting: false}); - }); - }, - }); + if (this.props.onDeleteClick) { + this.props.onDeleteClick(); } else { - console.log("Revoke widget permissions - %s", this.props.id); - this._revokeWidgetPermission(); + if (this._canUserModify()) { + // Show delete confirmation dialog + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) { + return; + } + this.setState({deleting: true}); + MatrixClientPeg.get().sendStateEvent( + this.props.room.roomId, + 'im.vector.modular.widgets', + {}, // empty content + this.props.id, + ).catch((e) => { + console.error('Failed to delete widget', e); + }).finally(() => { + this.setState({deleting: false}); + }); + }, + }); + } else { + console.log("Revoke widget permissions - %s", this.props.id); + this._revokeWidgetPermission(); + } } - }, + } /** * Called when widget iframe has finished loading */ _onLoaded() { + if (!this.widgetMessaging) { + this._onInitialLoad(); + } + } + + /** + * Called on initial load of the widget iframe + */ + _onInitialLoad() { + this.widgetMessaging = new WidgetMessaging(this.props.id, this.props.url, this.refs.appFrame.contentWindow); + this.widgetMessaging.getCapabilities().then((requestedCapabilities) => { + console.log(`Widget ${this.props.id} requested capabilities:`, requestedCapabilities); + requestedCapabilities = requestedCapabilities || []; + + // Allow whitelisted capabilities + let requestedWhitelistCapabilies = []; + + if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) { + requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) { + return this.indexOf(e)>=0; + }, this.props.whitelistCapabilities); + + if (requestedWhitelistCapabilies.length > 0 ) { + console.warn(`Widget ${this.props.id} allowing requested, whitelisted properties:`, + requestedWhitelistCapabilies); + } + } + + // TODO -- Add UI to warn about and optionally allow requested capabilities + this.setState({ + requestedCapabilities, + allowedCapabilities: this.state.allowedCapabilities.concat(requestedWhitelistCapabilies), + }); + + if (this.props.onCapabilityRequest) { + this.props.onCapabilityRequest(requestedCapabilities); + } + }).catch((err) => { + console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err); + }); this.setState({loading: false}); - }, + } + + _onWidgetAction(payload) { + if (payload.widgetId === this.props.id) { + switch (payload.action) { + case 'm.sticker': + if (this._hasCapability('m.sticker')) { + dis.dispatch({action: 'post_sticker_message', data: payload.data}); + } else { + console.warn('Ignoring sticker message. Invalid capability'); + } + break; + } + } + } /** * Set remote content title on AppTile @@ -321,7 +415,7 @@ export default React.createClass({ }, (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 @@ -330,20 +424,20 @@ export default React.createClass({ return _td('Delete widget'); } return _td('Revoke widget access'); - }, + } /* TODO -- Store permission in account data so that it is persisted across multiple devices */ _grantWidgetPermission() { console.warn('Granting permission to load widget - ', this.state.widgetUrl); localStorage.setItem(this.state.widgetPermissionId, true); this.setState({hasPermissionToLoad: true}); - }, + } _revokeWidgetPermission() { console.warn('Revoking permission to load widget - ', this.state.widgetUrl); localStorage.removeItem(this.state.widgetPermissionId); this.setState({hasPermissionToLoad: false}); - }, + } formatAppTileName() { let appTileName = "No name"; @@ -351,7 +445,7 @@ export default React.createClass({ appTileName = this.props.name.trim(); } return appTileName; - }, + } onClickMenuBar(ev) { ev.preventDefault(); @@ -366,16 +460,42 @@ export default React.createClass({ action: 'appsDrawer', show: !this.props.show, }); - }, + } _getSafeUrl() { - const parsedWidgetUrl = url.parse(this.state.widgetUrl); + const parsedWidgetUrl = url.parse(this.state.widgetUrl, true); + if (ENABLE_REACT_PERF) { + parsedWidgetUrl.search = null; + parsedWidgetUrl.query.react_perf = true; + } let safeWidgetUrl = ''; if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { safeWidgetUrl = url.format(parsedWidgetUrl); } return safeWidgetUrl; - }, + } + + _getTileTitle() { + const name = this.formatAppTileName(); + const titleSpacer =  - ; + let title = ''; + if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { + title = this.state.widgetPageTitle; + } + + return ( + + { name } + { title ? titleSpacer : '' }{ title } + + ); + } + + _onMinimiseClick(e) { + if (this.props.onMinimiseClick) { + this.props.onMinimiseClick(); + } + } render() { let appTileBody; @@ -399,7 +519,7 @@ export default React.createClass({ if (this.props.show) { const loadingElement = ( -
+
); @@ -414,7 +534,7 @@ export default React.createClass({ ); } else { appTileBody = ( -
+
{ this.state.loading && loadingElement } { /* The "is" attribute in the following iframe tag is needed in order to enable rendering of the @@ -456,29 +576,42 @@ export default React.createClass({ deleteClasses += ' mx_AppTileMenuBarWidgetDelete'; } + // Picture snapshot - only show button when apps are maximised. + const showPictureSnapshotButton = this._hasCapability('screenshot') && this.props.show; + const showPictureSnapshotIcon = 'img/camera_green.svg'; const windowStateIcon = (this.props.show ? 'img/minimize.svg' : 'img/maximize.svg'); return (
+ { this.props.showMenubar &&
- - + { this.props.showMinimise && - { this.formatAppTileName() } - { this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName() && ( -  - { this.state.widgetPageTitle } - ) } + onClick={this._onMinimiseClick} + /> } + { this.props.showTitle && this._getTileTitle() } + { /* Snapshot widget */ } + { showPictureSnapshotButton && } + { /* Edit widget */ } { showEditButton && } { /* Delete widget */ } - + /> } -
+
} { appTileBody }
); - }, -}); + } +} + +AppTile.displayName ='AppTile'; + +AppTile.propTypes = { + id: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + room: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. + // This should be set to true when there is only one widget in the app drawer, otherwise it should be false. + fullWidth: PropTypes.bool, + // UserId of the current user + userId: PropTypes.string.isRequired, + // UserId of the entity that added / modified the widget + creatorUserId: PropTypes.string, + waitForIframeLoad: PropTypes.bool, + showMenubar: PropTypes.bool, + // Should the AppTile render itself + show: PropTypes.bool, + // Optional onEditClickHandler (overrides default behaviour) + onEditClick: PropTypes.func, + // Optional onDeleteClickHandler (overrides default behaviour) + onDeleteClick: PropTypes.func, + // Optional onMinimiseClickHandler + onMinimiseClick: PropTypes.func, + // Optionally hide the tile title + showTitle: PropTypes.bool, + // Optionally hide the tile minimise icon + showMinimise: PropTypes.bool, + // Optionally handle minimise button pointer events (default false) + handleMinimisePointerEvents: PropTypes.bool, + // Optionally hide the delete icon + showDelete: PropTypes.bool, + // Widget apabilities to allow by default (without user confirmation) + // NOTE -- Use with caution. This is intended to aid better integration / UX + // basic widget capabilities, e.g. injecting sticker message events. + whitelistCapabilities: PropTypes.array, + // Optional function to be called on widget capability request + // Called with an array of the requested capabilities + onCapabilityRequest: PropTypes.func, +}; + +AppTile.defaultProps = { + url: "", + waitForIframeLoad: true, + showMenubar: true, + showTitle: true, + showMinimise: true, + showDelete: true, + handleMinimisePointerEvents: false, + whitelistCapabilities: [], +}; diff --git a/src/components/views/elements/ManageIntegsButton.js b/src/components/views/elements/ManageIntegsButton.js index 17dbbeee62..024c5feda5 100644 --- a/src/components/views/elements/ManageIntegsButton.js +++ b/src/components/views/elements/ManageIntegsButton.js @@ -64,7 +64,7 @@ export default class ManageIntegsButton extends React.Component { const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); Modal.createDialog(IntegrationsManager, { src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? - this.scalarClient.getScalarInterfaceUrlForRoom(this.props.roomId) : + this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) : null, }, "mx_IntegrationsManager"); } @@ -103,5 +103,5 @@ export default class ManageIntegsButton extends React.Component { } ManageIntegsButton.propTypes = { - roomId: PropTypes.string.isRequired, + room: PropTypes.object.isRequired, }; diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index f5515fad90..6a95b3c16e 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -30,35 +30,45 @@ import Promise from 'bluebird'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; -module.exports = React.createClass({ - displayName: 'MImageBody', +export default class extends React.Component { + displayName: 'MImageBody' - propTypes: { + static propTypes = { /* the MatrixEvent to show */ mxEvent: PropTypes.object.isRequired, /* called when the image has loaded */ onWidgetLoad: PropTypes.func.isRequired, - }, + } - contextTypes: { + static contextTypes = { matrixClient: PropTypes.instanceOf(MatrixClient), - }, + } - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.onAction = this.onAction.bind(this); + this.onImageEnter = this.onImageEnter.bind(this); + this.onImageLeave = this.onImageLeave.bind(this); + this.onClientSync = this.onClientSync.bind(this); + this.onClick = this.onClick.bind(this); + this.fixupHeight = this.fixupHeight.bind(this); + this._isGif = this._isGif.bind(this); + + this.state = { decryptedUrl: null, decryptedThumbnailUrl: null, decryptedBlob: null, error: null, imgError: false, }; - }, + } componentWillMount() { this.unmounted = false; this.context.matrixClient.on('sync', this.onClientSync); - }, + } onClientSync(syncState, prevState) { if (this.unmounted) return; @@ -71,9 +81,9 @@ module.exports = React.createClass({ imgError: false, }); } - }, + } - onClick: function onClick(ev) { + onClick(ev) { if (ev.button == 0 && !ev.metaKey) { ev.preventDefault(); const content = this.props.mxEvent.getContent(); @@ -93,49 +103,49 @@ module.exports = React.createClass({ Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); } - }, + } - _isGif: function() { + _isGif() { const content = this.props.mxEvent.getContent(); return ( content && content.info && content.info.mimetype === "image/gif" ); - }, + } - onImageEnter: function(e) { + onImageEnter(e) { if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { return; } const imgElement = e.target; imgElement.src = this._getContentUrl(); - }, + } - onImageLeave: function(e) { + onImageLeave(e) { if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { return; } const imgElement = e.target; imgElement.src = this._getThumbUrl(); - }, + } - onImageError: function() { + onImageError() { this.setState({ imgError: true, }); - }, + } - _getContentUrl: function() { + _getContentUrl() { const content = this.props.mxEvent.getContent(); if (content.file !== undefined) { return this.state.decryptedUrl; } else { return this.context.matrixClient.mxcUrlToHttp(content.url); } - }, + } - _getThumbUrl: function() { + _getThumbUrl() { const content = this.props.mxEvent.getContent(); if (content.file !== undefined) { // Don't use the thumbnail for clients wishing to autoplay gifs. @@ -146,9 +156,9 @@ module.exports = React.createClass({ } else { return this.context.matrixClient.mxcUrlToHttp(content.url, 800, 600); } - }, + } - componentDidMount: function() { + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); this.fixupHeight(); const content = this.props.mxEvent.getContent(); @@ -182,23 +192,35 @@ module.exports = React.createClass({ }); }).done(); } - }, + this._afterComponentDidMount(); + } - componentWillUnmount: function() { + // To be overridden by subclasses (e.g. MStickerBody) for further + // initialisation after componentDidMount + _afterComponentDidMount() { + } + + componentWillUnmount() { this.unmounted = true; dis.unregister(this.dispatcherRef); this.context.matrixClient.removeListener('sync', this.onClientSync); - }, + this._afterComponentWillUnmount(); + } - onAction: function(payload) { + // To be overridden by subclasses (e.g. MStickerBody) for further + // cleanup after componentWillUnmount + _afterComponentWillUnmount() { + } + + onAction(payload) { if (payload.action === "timeline_resize") { this.fixupHeight(); } - }, + } - fixupHeight: function() { + fixupHeight() { if (!this.refs.image) { - console.warn("Refusing to fix up height on MImageBody with no image element"); + console.warn(`Refusing to fix up height on ${this.displayName} with no image element`); return; } @@ -214,10 +236,25 @@ module.exports = React.createClass({ } this.refs.image.style.height = thumbHeight + "px"; // console.log("Image height now", thumbHeight); - }, + } - render: function() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); + _messageContent(contentUrl, thumbUrl, content) { + return ( + + + {content.body} + + + + ); + } + + render() { const content = this.props.mxEvent.getContent(); if (this.state.error !== null) { @@ -265,19 +302,7 @@ module.exports = React.createClass({ } if (thumbUrl) { - return ( - - - {content.body} - - - - ); + return this._messageContent(contentUrl, thumbUrl, content); } else if (content.body) { return ( @@ -291,5 +316,5 @@ module.exports = React.createClass({ ); } - }, -}); + } +} diff --git a/src/components/views/messages/MStickerBody.js b/src/components/views/messages/MStickerBody.js new file mode 100644 index 0000000000..08ddb6de20 --- /dev/null +++ b/src/components/views/messages/MStickerBody.js @@ -0,0 +1,149 @@ +/* +Copyright 2018 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. +*/ + +'use strict'; + +import MImageBody from './MImageBody'; +import sdk from '../../../index'; +import TintableSVG from '../elements/TintableSvg'; + +export default class MStickerBody extends MImageBody { + displayName: 'MStickerBody' + + constructor(props) { + super(props); + + this._onMouseEnter = this._onMouseEnter.bind(this); + this._onMouseLeave = this._onMouseLeave.bind(this); + this._onImageLoad = this._onImageLoad.bind(this); + } + + _onMouseEnter() { + this.setState({showTooltip: true}); + } + + _onMouseLeave() { + this.setState({showTooltip: false}); + } + + _onImageLoad() { + this.setState({ + placeholderClasses: 'mx_MStickerBody_placeholder_invisible', + }); + const hidePlaceholderTimer = setTimeout(() => { + this.setState({ + placeholderVisible: false, + imageClasses: 'mx_MStickerBody_image_visible', + }); + }, 500); + this.setState({hidePlaceholderTimer}); + if (this.props.onWidgetLoad) { + this.props.onWidgetLoad(); + } + } + + _afterComponentDidMount() { + if (this.refs.image.complete) { + // Image already loaded + this.setState({ + placeholderVisible: false, + placeholderClasses: '.mx_MStickerBody_placeholder_invisible', + imageClasses: 'mx_MStickerBody_image_visible', + }); + } else { + // Image not already loaded + this.setState({ + placeholderVisible: true, + placeholderClasses: '', + imageClasses: '', + }); + } + } + + _afterComponentWillUnmount() { + if (this.state.hidePlaceholderTimer) { + clearTimeout(this.state.hidePlaceholderTimer); + this.setState({hidePlaceholderTimer: null}); + } + } + + _messageContent(contentUrl, thumbUrl, content) { + let tooltip; + const tooltipBody = ( + this.props.mxEvent && + this.props.mxEvent.getContent() && + this.props.mxEvent.getContent().body) ? + this.props.mxEvent.getContent().body : null; + if (this.state.showTooltip && tooltipBody) { + const RoomTooltip = sdk.getComponent('rooms.RoomTooltip'); + tooltip = ; + } + + const gutterSize = 0; + let placeholderSize = 75; + let placeholderFixupHeight = '100px'; + let placeholderTop = 0; + let placeholderLeft = 0; + + if (content.info) { + placeholderTop = Math.floor((content.info.h/2) - (placeholderSize/2)) + 'px'; + placeholderLeft = Math.floor((content.info.w/2) - (placeholderSize/2) + gutterSize) + 'px'; + placeholderFixupHeight = content.info.h + 'px'; + } + + placeholderSize = placeholderSize + 'px'; + + // Body 'ref' required by MImageBody + return ( + +
+ { this.state.placeholderVisible && +
+ +
} + {content.body} + { tooltip } +
+
+ ); + } + + // Empty to prevent default behaviour of MImageBody + onClick() { + } +} diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index d35959599c..7358e297c7 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -65,15 +65,19 @@ module.exports = React.createClass({ let BodyType = UnknownBody; if (msgtype && bodyTypes[msgtype]) { BodyType = bodyTypes[msgtype]; + } else if (this.props.mxEvent.getType() === 'm.sticker') { + BodyType = sdk.getComponent('messages.MStickerBody'); } else if (content.url) { // Fallback to MFileBody if there's a content URL BodyType = bodyTypes['m.file']; } - return ; + return ; }, }); diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 9707cd95de..9f57ca51e9 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -37,7 +37,15 @@ module.exports = React.createClass({ displayName: 'AppsDrawer', propTypes: { + userId: PropTypes.string.isRequired, room: PropTypes.object.isRequired, + showApps: PropTypes.bool, // Should apps be rendered + hide: PropTypes.bool, // If rendered, should apps drawer be visible + }, + + defaultProps: { + showApps: true, + hide: false, }, getInitialState: function() { @@ -48,7 +56,7 @@ module.exports = React.createClass({ componentWillMount: function() { ScalarMessaging.startListening(); - MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); + MatrixClientPeg.get().on('RoomState.events', this.onRoomStateEvents); }, componentDidMount: function() { @@ -58,7 +66,7 @@ module.exports = React.createClass({ this.scalarClient.connect().then(() => { this.forceUpdate(); }).catch((e) => { - console.log("Failed to connect to integrations server"); + console.log('Failed to connect to integrations server'); // TODO -- Handle Scalar errors // this.setState({ // scalar_error: err, @@ -72,7 +80,7 @@ module.exports = React.createClass({ componentWillUnmount: function() { ScalarMessaging.stopListening(); if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); + MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents); } dis.unregister(this.dispatcherRef); }, @@ -83,7 +91,7 @@ module.exports = React.createClass({ }, onAction: function(action) { - const hideWidgetKey = this.props.room.roomId + "_hide_widget_drawer"; + const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer'; switch (action.action) { case 'appsDrawer': // When opening the app drawer when there aren't any apps, @@ -111,7 +119,7 @@ module.exports = React.createClass({ * passed through encodeURIComponent. * @param {string} pathTemplate The path with template variables e.g. '/foo/$bar'. * @param {Object} variables The key/value pairs to replace the template - * variables with. E.g. { "$bar": "baz" }. + * variables with. E.g. { '$bar': 'baz' }. * @return {string} The result of replacing all template variables e.g. '/foo/baz'. */ encodeUri: function(pathTemplate, variables) { @@ -192,13 +200,13 @@ module.exports = React.createClass({ }, _launchManageIntegrations: function() { - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager'); const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? - this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId, 'add_integ') : + this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') : null; Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { src: src, - }, "mx_IntegrationsManager"); + }, 'mx_IntegrationsManager'); }, onClickAddWidget: function(e) { @@ -206,12 +214,12 @@ module.exports = React.createClass({ // Display a warning dialog if the max number of widgets have already been added to the room const apps = this._getApps(); if (apps && apps.length >= MAX_WIDGETS) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); const errorMsg = `The maximum number of ${MAX_WIDGETS} widgets have already been added to this room.`; console.error(errorMsg); Modal.createDialog(ErrorDialog, { - title: _t("Cannot add any more widgets"), - description: _t("The maximum permitted number of widgets have already been added to this room."), + title: _t('Cannot add any more widgets'), + description: _t('The maximum permitted number of widgets have already been added to this room.'), }); return; } @@ -243,11 +251,11 @@ module.exports = React.createClass({ ) { addWidget =
[+] { _t('Add a widget') } @@ -255,8 +263,8 @@ module.exports = React.createClass({ } return ( -
-
+
+
{ apps }
{ this._canUserModify() && addWidget } diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index 2713b1d30f..1f0fb2831b 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -32,7 +32,8 @@ module.exports = React.createClass({ // js-sdk room object room: PropTypes.object.isRequired, userId: PropTypes.string.isRequired, - showApps: PropTypes.bool, + showApps: PropTypes.bool, // Render apps + hideAppsDrawer: PropTypes.bool, // Do not display apps drawer and content (may still be rendered) // Conference Handler implementation conferenceHandler: PropTypes.object, @@ -52,6 +53,11 @@ module.exports = React.createClass({ onResize: PropTypes.func, }, + defaultProps: { + showApps: true, + hideAppsDrawer: false, + }, + shouldComponentUpdate: function(nextProps, nextState) { return (!ObjectUtils.shallowEqual(this.props, nextProps) || !ObjectUtils.shallowEqual(this.state, nextState)); @@ -134,6 +140,7 @@ module.exports = React.createClass({ userId={this.props.userId} maxHeight={this.props.maxHeight} showApps={this.props.showApps} + hide={this.props.hideAppsDrawer} />; return ( diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 62af1ad2db..5e453e66d0 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -36,6 +36,7 @@ const ObjectUtils = require('../../../ObjectUtils'); const eventTileTypes = { 'm.room.message': 'messages.MessageEvent', + 'm.sticker': 'messages.MessageEvent', 'm.call.invite': 'messages.TextualEvent', 'm.call.answer': 'messages.TextualEvent', 'm.call.hangup': 'messages.TextualEvent', @@ -470,7 +471,8 @@ module.exports = withMatrixClient(React.createClass({ const eventType = this.props.mxEvent.getType(); // Info messages are basically information about commands processed on a room - const isInfoMessage = (eventType !== 'm.room.message'); + // For now assume that anything that doesn't have a content body is an isInfoMessage + const isInfoMessage = !content.body; // Boolean comparison of non-boolean content body const EventTileType = sdk.getComponent(getHandlerTile(this.props.mxEvent)); // This shouldn't happen: the caller should check we support this type diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 6ad033fa0c..ff46f7423e 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -24,7 +24,7 @@ import sdk from '../../../index'; import dis from '../../../dispatcher'; import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; - +import Stickerpicker from './Stickerpicker'; export default class MessageComposer extends React.Component { constructor(props, context) { @@ -32,8 +32,6 @@ export default class MessageComposer extends React.Component { this.onCallClick = this.onCallClick.bind(this); this.onHangupClick = this.onHangupClick.bind(this); this.onUploadClick = this.onUploadClick.bind(this); - this.onShowAppsClick = this.onShowAppsClick.bind(this); - this.onHideAppsClick = this.onHideAppsClick.bind(this); this.onUploadFileSelected = this.onUploadFileSelected.bind(this); this.uploadFiles = this.uploadFiles.bind(this); this.onVoiceCallClick = this.onVoiceCallClick.bind(this); @@ -202,20 +200,6 @@ export default class MessageComposer extends React.Component { // this._startCallApp(true); } - onShowAppsClick(ev) { - dis.dispatch({ - action: 'appsDrawer', - show: true, - }); - } - - onHideAppsClick(ev) { - dis.dispatch({ - action: 'appsDrawer', - show: false, - }); - } - onInputContentChanged(content: string, selection: {start: number, end: number}) { this.setState({ autocompleteQuery: content, @@ -281,7 +265,12 @@ export default class MessageComposer extends React.Component { alt={e2eTitle} title={e2eTitle} />, ); - let callButton, videoCallButton, hangupButton, showAppsButton, hideAppsButton; + + let callButton; + let videoCallButton; + let hangupButton; + + // Call buttons if (this.props.callState && this.props.callState !== 'ended') { hangupButton =
@@ -298,19 +287,6 @@ export default class MessageComposer extends React.Component {
; } - // Apps - if (this.props.showApps) { - hideAppsButton = -
- -
; - } else { - showAppsButton = -
- -
; - } - const canSendMessages = this.props.room.currentState.maySendMessage( MatrixClientPeg.get().credentials.userId); @@ -353,6 +329,11 @@ export default class MessageComposer extends React.Component { } } + let stickerpickerButton; + if (SettingsStore.isFeatureEnabled('feature_sticker_messages')) { + stickerpickerButton = ; + } + controls.push( this.messageComposerInput = c} @@ -364,12 +345,11 @@ export default class MessageComposer extends React.Component { onContentChanged={this.onInputContentChanged} onInputStateChanged={this.onInputStateChanged} />, formattingButton, + stickerpickerButton, uploadButton, hangupButton, callButton, videoCallButton, - showAppsButton, - hideAppsButton, ); } else { controls.push( diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index c0644baef7..19162c60cd 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -392,7 +392,7 @@ module.exports = React.createClass({ let manageIntegsButton; if (this.props.room && this.props.room.roomId && this.props.inRoom) { manageIntegsButton = ; } diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js new file mode 100644 index 0000000000..e45f0e8f1b --- /dev/null +++ b/src/components/views/rooms/Stickerpicker.js @@ -0,0 +1,290 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import React from 'react'; +import { _t } from '../../../languageHandler'; +import Widgets from '../../../utils/widgets'; +import AppTile from '../elements/AppTile'; +import ContextualMenu from '../../structures/ContextualMenu'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import SdkConfig from '../../../SdkConfig'; +import ScalarAuthClient from '../../../ScalarAuthClient'; +import dis from '../../../dispatcher'; +import AccessibleButton from '../elements/AccessibleButton'; + +const widgetType = 'm.stickerpicker'; + +export default class Stickerpicker extends React.Component { + constructor(props) { + super(props); + this._onShowStickersClick = this._onShowStickersClick.bind(this); + this._onHideStickersClick = this._onHideStickersClick.bind(this); + this._launchManageIntegrations = this._launchManageIntegrations.bind(this); + this._removeStickerpickerWidgets = this._removeStickerpickerWidgets.bind(this); + this._onWidgetAction = this._onWidgetAction.bind(this); + this._onFinished = this._onFinished.bind(this); + + this.popoverWidth = 300; + this.popoverHeight = 300; + + this.state = { + showStickers: false, + imError: null, + }; + } + + _removeStickerpickerWidgets() { + console.warn('Removing Stickerpicker widgets'); + if (this.widgetId) { + this.scalarClient.disableWidgetAssets(widgetType, this.widgetId).then(() => { + console.warn('Assets disabled'); + }).catch((err) => { + console.error('Failed to disable assets'); + }); + } else { + console.warn('No widget ID specified, not disabling assets'); + } + + // Wrap this in a timeout in order to avoid the DOM node from being pulled from under its feet + setTimeout(() => this.stickersMenu.close()); + Widgets.removeStickerpickerWidgets().then(() => { + this.forceUpdate(); + }).catch((e) => { + console.error('Failed to remove sticker picker widget', e); + }); + } + + componentDidMount() { + this.scalarClient = null; + if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) { + this.scalarClient = new ScalarAuthClient(); + this.scalarClient.connect().then(() => { + this.forceUpdate(); + }).catch((e) => { + this._imError("Failed to connect to integrations server", e); + }); + } + + if (!this.state.imError) { + this.dispatcherRef = dis.register(this._onWidgetAction); + } + } + + componentWillUnmount() { + if (this.dispatcherRef) { + dis.unregister(this.dispatcherRef); + } + } + + _imError(errorMsg, e) { + console.error(errorMsg, e); + this.setState({ + showStickers: false, + imError: errorMsg, + }); + } + + _onWidgetAction(payload) { + if (payload.action === "user_widget_updated") { + this.forceUpdate(); + } else if (payload.action === "stickerpicker_close") { + // Wrap this in a timeout in order to avoid the DOM node from being + // pulled from under its feet + setTimeout(() => this.stickersMenu.close()); + } + } + + _defaultStickerpickerContent() { + return ( + +

{ _t("You don't currently have any stickerpacks enabled") }

+

Add some now

+ {_t('Add +
+ ); + } + + _errorStickerpickerContent() { + return ( +
+

{ this.state.imError }

+
+ ); + } + + _getStickerpickerContent() { + // Handle Integration Manager errors + if (this.state._imError) { + return this._errorStickerpickerContent(); + } + + // Stickers + // TODO - Add support for Stickerpickers from multiple app stores. + // Render content from multiple stickerpack sources, each within their + // own iframe, within the stickerpicker UI element. + const stickerpickerWidget = Widgets.getStickerpickerWidgets()[0]; + let stickersContent; + + // Load stickerpack content + if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) { + // Set default name + stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack"); + this.widgetId = stickerpickerWidget.id; + + stickersContent = ( +
+
+ +
+
+ ); + } else { + // Default content to show if stickerpicker widget not added + console.warn("No available sticker picker widgets"); + stickersContent = this._defaultStickerpickerContent(); + this.widgetId = null; + this.forceUpdate(); + } + this.setState({ + showStickers: false, + }); + return stickersContent; + } + + /** + * Show the sticker picker overlay + * If no stickerpacks have been added, show a link to the integration manager add sticker packs page. + * @param {Event} e Event that triggered the function + */ + _onShowStickersClick(e) { + const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu'); + const buttonRect = e.target.getBoundingClientRect(); + + // The window X and Y offsets are to adjust position when zoomed in to page + const x = buttonRect.right + window.pageXOffset - 42; + const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19; + // const self = this; + this.stickersMenu = ContextualMenu.createMenu(GenericElementContextMenu, { + chevronOffset: 10, + chevronFace: 'bottom', + left: x, + top: y, + menuWidth: this.popoverWidth, + menuHeight: this.popoverHeight, + element: this._getStickerpickerContent(), + onFinished: this._onFinished, + menuPaddingTop: 0, + }); + + + this.setState({showStickers: true}); + } + + /** + * Trigger hiding of the sticker picker overlay + * @param {Event} ev Event that triggered the function call + */ + _onHideStickersClick(ev) { + setTimeout(() => this.stickersMenu.close()); + } + + /** + * The stickers picker was hidden + */ + _onFinished() { + this.setState({showStickers: false}); + } + + /** + * Launch the integrations manager on the stickers integration page + */ + _launchManageIntegrations() { + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? + this.scalarClient.getScalarInterfaceUrlForRoom( + this.props.room, + 'type_' + widgetType, + this.widgetId, + ) : + null; + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + src: src, + }, "mx_IntegrationsManager"); + + // Wrap this in a timeout in order to avoid the DOM node from being pulled from under its feet + setTimeout(() => this.stickersMenu.close()); + } + + render() { + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + let stickersButton; + if (this.state.showStickers) { + // Show hide-stickers button + stickersButton = +
+ +
; + } else { + // Show show-stickers button + stickersButton = +
+ +
; + } + return stickersButton; + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 25bb9660ea..b515df1c07 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -255,6 +255,13 @@ "Cannot add any more widgets": "Cannot add any more widgets", "The maximum permitted number of widgets have already been added to this room.": "The maximum permitted number of widgets have already been added to this room.", "Add a widget": "Add a widget", + "Stickerpack": "Stickerpack", + "Sticker Messages": "Sticker Messages", + "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", + "Click": "Click", + "here": "here", + "to add some!": "to add some!", + "Add a stickerpack": "Add a stickerpack", "Drop File Here": "Drop File Here", "Drop file here to upload": "Drop file here to upload", " (unsupported)": " (unsupported)", @@ -324,6 +331,8 @@ "Video call": "Video call", "Hide Apps": "Hide Apps", "Show Apps": "Show Apps", + "Hide Stickers": "Hide Stickers", + "Show Stickers": "Show Stickers", "Upload file": "Upload file", "Show Text Formatting Toolbar": "Show Text Formatting Toolbar", "Send an encrypted reply…": "Send an encrypted reply…", @@ -578,6 +587,7 @@ "NOTE: Apps are not end-to-end encrypted": "NOTE: Apps are not end-to-end encrypted", "Do you want to load widget from URL:": "Do you want to load widget from URL:", "Allow": "Allow", + "Manage sticker packs": "Manage sticker packs", "Delete Widget": "Delete Widget", "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?", "Delete widget": "Delete widget", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index e33aed1a6c..7acaa8009f 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -94,6 +94,18 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_tag_panel": { + isFeature: true, + displayName: _td("Tag Panel"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, + "feature_sticker_messages": { + isFeature: true, + displayName: _td("Sticker Messages"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "MessageComposerInput.dontSuggestEmoji": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Disable Emoji suggestions while typing'), diff --git a/src/utils/widgets.js b/src/utils/widgets.js new file mode 100644 index 0000000000..0d7f5dbf3f --- /dev/null +++ b/src/utils/widgets.js @@ -0,0 +1,91 @@ +import MatrixClientPeg from '../MatrixClientPeg'; + +/** + * Get all widgets (user and room) for the current user + * @param {object} room The room to get widgets for + * @return {[object]} Array containing current / active room and user widget state events + */ +function getWidgets(room) { + const widgets = getRoomWidgets(room); + widgets.concat(getUserWidgetsArray()); + return widgets; +} + +/** + * Get room specific widgets + * @param {object} room The room to get widgets force + * @return {[object]} Array containing current / active room widgets + */ +function getRoomWidgets(room) { + const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets'); + if (!appsStateEvents) { + return []; + } + + return appsStateEvents.filter((ev) => { + return ev.getContent().type && ev.getContent().url; + }); +} + +/** + * Get user specific widgets (not linked to a specific room) + * @return {object} Event content object containing current / active user widgets + */ +function getUserWidgets() { + const client = MatrixClientPeg.get(); + if (!client) { + throw new Error('User not logged in'); + } + const userWidgets = client.getAccountData('m.widgets'); + let userWidgetContent = {}; + if (userWidgets && userWidgets.getContent()) { + userWidgetContent = userWidgets.getContent(); + } + return userWidgetContent; +} + +/** + * Get user specific widgets (not linked to a specific room) as an array + * @return {[object]} Array containing current / active user widgets + */ +function getUserWidgetsArray() { + return Object.values(getUserWidgets()); +} + +/** + * Get active stickerpicker widgets (stickerpickers are user widgets by nature) + * @return {[object]} Array containing current / active stickerpicker widgets + */ +function getStickerpickerWidgets() { + const widgets = getUserWidgetsArray(); + const stickerpickerWidgets = widgets.filter((widget) => widget.type='m.stickerpicker'); + return stickerpickerWidgets; +} + +/** + * Remove all stickerpicker widgets (stickerpickers are user widgets by nature) + * @return {Promise} Resolves on account data updated + */ +function removeStickerpickerWidgets() { + const client = MatrixClientPeg.get(); + if (!client) { + throw new Error('User not logged in'); + } + const userWidgets = client.getAccountData('m.widgets').getContent() || {}; + Object.entries(userWidgets).forEach(([key, widget]) => { + if (widget.type === 'm.stickerpicker') { + delete userWidgets[key]; + } + }); + return client.setAccountData('m.widgets', userWidgets); +} + + +export default { + getWidgets, + getRoomWidgets, + getUserWidgets, + getUserWidgetsArray, + getStickerpickerWidgets, + removeStickerpickerWidgets, +};