Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
Weblate 2018-04-03 17:37:10 +00:00
commit 3d3fd35815
27 changed files with 1650 additions and 556 deletions

View file

@ -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',

View file

@ -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);
}
}

73
src/IntegrationManager.js Normal file
View file

@ -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");
}
}

View file

@ -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);

View file

@ -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;

View file

@ -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);
}
});
}
}

View file

@ -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: <Original Error Object>
}
}
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,
};

View file

@ -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;
}
}

View file

@ -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

View file

@ -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 = (
<div className={className} style={position}>
<div className={menuClasses} style={menuStyle}>
{ chevron }
<Element {...props} onFinished={closeMenu} />
<Element {...props} onFinished={closeMenu} onResize={windowResize} />
</div>
<div className="mx_ContextualMenu_background" onClick={closeMenu}></div>
<style>{ chevronCSS }</style>

View file

@ -68,6 +68,9 @@ const FilePanel = React.createClass({
"room": {
"timeline": {
"contains_url": true,
"not_types": [
"m.sticker",
],
},
},
},

View file

@ -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;
}

View file

@ -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 }
</AuxPanel>
);

View file

@ -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 = <span>&nbsp;-&nbsp;</span>;
let title = '';
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
title = this.state.widgetPageTitle;
}
return (
<span>
<b>{ name }</b>
<span>{ title ? titleSpacer : '' }{ title }</span>
</span>
);
}
_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 = (
<div className='mx_AppTileBody mx_AppLoading'>
<div>
<MessageSpinner msg='Loading...' />
</div>
);
@ -414,7 +534,7 @@ export default React.createClass({
);
} else {
appTileBody = (
<div className={this.state.loading ? 'mx_AppTileBody mx_AppLoading' : 'mx_AppTileBody'}>
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
{ 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 (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
{ this.props.showMenubar &&
<div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}>
<span className="mx_AppTileMenuBarTitle">
<TintableSvgButton
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
{ this.props.showMinimise && <TintableSvgButton
src={windowStateIcon}
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Minimize apps')}
width="10"
height="10"
/>
<b>{ this.formatAppTileName() }</b>
{ this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName() && (
<span>&nbsp;-&nbsp;{ this.state.widgetPageTitle }</span>
) }
onClick={this._onMinimiseClick}
/> }
{ this.props.showTitle && this._getTileTitle() }
</span>
<span className="mx_AppTileMenuBarWidgets">
{ /* Snapshot widget */ }
{ showPictureSnapshotButton && <TintableSvgButton
src={showPictureSnapshotIcon}
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Picture')}
onClick={this._onSnapshotClick}
width="10"
height="10"
/> }
{ /* Edit widget */ }
{ showEditButton && <TintableSvgButton
src="img/edit_green.svg"
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
className={"mx_AppTileMenuBarWidget " +
(this.props.showDelete ? "mx_AppTileMenuBarWidgetPadding" : "")}
title={_t('Edit')}
onClick={this._onEditClick}
width="10"
@ -486,18 +619,71 @@ export default React.createClass({
/> }
{ /* Delete widget */ }
<TintableSvgButton
{ this.props.showDelete && <TintableSvgButton
src={deleteIcon}
className={deleteClasses}
title={_t(deleteWidgetLabel)}
onClick={this._onDeleteClick}
width="10"
height="10"
/>
/> }
</span>
</div>
</div> }
{ appTileBody }
</div>
);
},
});
}
}
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: [],
};

View file

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

View file

@ -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 (
<span className="mx_MImageBody" ref="body">
<a href={contentUrl} onClick={this.onClick}>
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
alt={content.body}
onError={this.onImageError}
onLoad={this.props.onWidgetLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
</a>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>
);
}
render() {
const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
@ -265,19 +302,7 @@ module.exports = React.createClass({
}
if (thumbUrl) {
return (
<span className="mx_MImageBody" ref="body">
<a href={contentUrl} onClick={this.onClick}>
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
alt={content.body}
onError={this.onImageError}
onLoad={this.props.onWidgetLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
</a>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>
);
return this._messageContent(contentUrl, thumbUrl, content);
} else if (content.body) {
return (
<span className="mx_MImageBody">
@ -291,5 +316,5 @@ module.exports = React.createClass({
</span>
);
}
},
});
}
}

View file

@ -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 = <RoomTooltip
className='mx_RoleButton_tooltip'
label={tooltipBody} />;
}
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 (
<span className='mx_MStickerBody' ref='body'
style={{
height: placeholderFixupHeight,
}}>
<div className={'mx_MStickerBody_image_container'}>
{ this.state.placeholderVisible &&
<div
className={'mx_MStickerBody_placeholder ' + this.state.placeholderClasses}
style={{
top: placeholderTop,
left: placeholderLeft,
}}
>
<TintableSVG
src={'img/icons-show-stickers.svg'}
width={placeholderSize}
height={placeholderSize} />
</div> }
<img
className={'mx_MStickerBody_image ' + this.state.imageClasses}
src={contentUrl}
ref='image'
alt={content.body}
onLoad={this._onImageLoad}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
/>
{ tooltip }
</div>
</span>
);
}
// Empty to prevent default behaviour of MImageBody
onClick() {
}
}

View file

@ -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 <BodyType ref="body" mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />;
return <BodyType
ref="body" mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />;
},
});

View file

@ -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 = <div
onClick={this.onClickAddWidget}
role="button"
tabIndex="0"
role='button'
tabIndex='0'
className={this.state.apps.length<2 ?
"mx_AddWidget_button mx_AddWidget_button_full_width" :
"mx_AddWidget_button"
'mx_AddWidget_button mx_AddWidget_button_full_width' :
'mx_AddWidget_button'
}
title={_t('Add a widget')}>
[+] { _t('Add a widget') }
@ -255,8 +263,8 @@ module.exports = React.createClass({
}
return (
<div className="mx_AppsDrawer">
<div id="apps" className="mx_AppsContainer">
<div className={'mx_AppsDrawer' + (this.props.hide ? ' mx_AppsDrawer_hidden' : '')}>
<div id='apps' className='mx_AppsContainer'>
{ apps }
</div>
{ this._canUserModify() && addWidget }

View file

@ -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 (

View file

@ -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

View file

@ -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 =
<div key="controls_hangup" className="mx_MessageComposer_hangup" onClick={this.onHangupClick}>
@ -298,19 +287,6 @@ export default class MessageComposer extends React.Component {
</div>;
}
// Apps
if (this.props.showApps) {
hideAppsButton =
<div key="controls_hide_apps" className="mx_MessageComposer_apps" onClick={this.onHideAppsClick} title={_t("Hide Apps")}>
<TintableSvg src="img/icons-hide-apps.svg" width="35" height="35" />
</div>;
} else {
showAppsButton =
<div key="show_apps" className="mx_MessageComposer_apps" onClick={this.onShowAppsClick} title={_t("Show Apps")}>
<TintableSvg src="img/icons-show-apps.svg" width="35" height="35" />
</div>;
}
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 = <Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />;
}
controls.push(
<MessageComposerInput
ref={(c) => 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(

View file

@ -392,7 +392,7 @@ module.exports = React.createClass({
let manageIntegsButton;
if (this.props.room && this.props.room.roomId && this.props.inRoom) {
manageIntegsButton = <ManageIntegsButton
roomId={this.props.room.roomId}
room={this.props.room}
/>;
}

View file

@ -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 (
<AccessibleButton onClick={this._launchManageIntegrations}
className='mx_Stickers_contentPlaceholder'>
<p>{ _t("You don't currently have any stickerpacks enabled") }</p>
<p className='mx_Stickers_addLink'>Add some now</p>
<img src='img/stickerpack-placeholder.png' alt={_t('Add a stickerpack')} />
</AccessibleButton>
);
}
_errorStickerpickerContent() {
return (
<div style={{"text-align": "center"}} className="error">
<p> { this.state.imError } </p>
</div>
);
}
_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 = (
<div className='mx_Stickers_content_container'>
<div
id='stickersContent'
className='mx_Stickers_content'
style={{
border: 'none',
height: this.popoverHeight,
width: this.popoverWidth,
}}
>
<AppTile
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}
room={this.props.room}
type={stickerpickerWidget.content.type}
fullWidth={true}
userId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
creatorUserId={MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
show={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
showMinimise={true}
showDelete={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
whitelistCapabilities={['m.sticker']}
/>
</div>
</div>
);
} 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 =
<div
id='stickersButton'
key="controls_hide_stickers"
className="mx_MessageComposer_stickers mx_Stickers_hideStickers"
onClick={this._onHideStickersClick}
ref='target'
title={_t("Hide Stickers")}>
<TintableSvg src="img/icons-hide-stickers.svg" width="35" height="35" />
</div>;
} else {
// Show show-stickers button
stickersButton =
<div
id='stickersButton'
key="constrols_show_stickers"
className="mx_MessageComposer_stickers"
onClick={this._onShowStickersClick}
title={_t("Show Stickers")}>
<TintableSvg src="img/icons-show-stickers.svg" width="35" height="35" />
</div>;
}
return stickersButton;
}
}

View file

@ -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",

View file

@ -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'),

91
src/utils/widgets.js Normal file
View file

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