diff --git a/.jshintignore b/.jshintignore index 6240b0092..f37de4d03 100644 --- a/.jshintignore +++ b/.jshintignore @@ -9,6 +9,8 @@ node_modules/ # supersedes JSHint. flow-typed/ react/ +modules/API/ +modules/transport/ # The following are checked by ESLint with the minimum configuration which does # not supersede JSHint but take advantage of advanced language features such as diff --git a/app.js b/app.js index 0f65aaf25..0d82d480c 100644 --- a/app.js +++ b/app.js @@ -18,7 +18,7 @@ window.toastr = require("toastr"); import UI from "./modules/UI/UI"; import settings from "./modules/settings/Settings"; import conference from './conference'; -import API from './modules/API/API'; +import API from './modules/API'; import translation from "./modules/translation/translation"; import remoteControl from "./modules/remotecontrol/RemoteControl"; diff --git a/modules/API/API.js b/modules/API/API.js index 436435734..f0111c325 100644 --- a/modules/API/API.js +++ b/modules/API/API.js @@ -1,8 +1,9 @@ -/* global APP, getConfigParamsFromUrl */ - -import postisInit from 'postis'; - import * as JitsiMeetConferenceEvents from '../../ConferenceEvents'; +import { transport } from '../transport'; + +import { API_ID } from './constants'; + +declare var APP: Object; /** * List of the available commands. @@ -17,23 +18,6 @@ let commands = {}; */ let initialScreenSharingState = false; -/** - * JitsiMeetExternalAPI id - unique for a webpage. - */ -const jitsiMeetExternalApiId - = getConfigParamsFromUrl().jitsi_meet_external_api_id; - -/** - * Postis instance. Used to communicate with the external application. If - * undefined, then API is disabled. - */ -let postis; - -/** - * Object that will execute sendMessage. - */ -const target = window.opener || window.parent; - /** * Initializes supported commands. * @@ -55,8 +39,17 @@ function initCommands() { 'remote-control-event': event => APP.remoteControl.onRemoteControlAPIEvent(event) }; - Object.keys(commands).forEach( - key => postis.listen(key, args => commands[key](...args))); + transport.on('event', event => { + const { name, data } = event; + + if (name && commands[name]) { + commands[name](...data); + + return true; + } + + return false; + }); } /** @@ -72,25 +65,13 @@ function onDesktopSharingEnabledChanged(enabled = false) { } } -/** - * Sends message to the external application. - * - * @param {Object} message - The message to be sent. - * @returns {void} - */ -function sendMessage(message) { - if (postis) { - postis.send(message); - } -} - /** * Check whether the API should be enabled or not. * * @returns {boolean} */ function shouldBeEnabled() { - return typeof jitsiMeetExternalApiId === 'number'; + return typeof API_ID === 'number'; } /** @@ -106,21 +87,6 @@ function toggleScreenSharing() { } } -/** - * Sends event object to the external application that has been subscribed for - * that event. - * - * @param {string} name - The name event. - * @param {Object} object - Data associated with the event. - * @returns {void} - */ -function triggerEvent(name, object) { - sendMessage({ - method: name, - params: object - }); -} - /** * Implements API class that communicates with external API class and provides * interface to access Jitsi Meet features by external applications that embed @@ -142,42 +108,42 @@ class API { return; } - if (!postis) { - APP.conference.addListener( - JitsiMeetConferenceEvents.DESKTOP_SHARING_ENABLED_CHANGED, - onDesktopSharingEnabledChanged); - this._initPostis(); - } + /** + * Current status (enabled/disabled) of API. + */ + this.enabled = true; + + APP.conference.addListener( + JitsiMeetConferenceEvents.DESKTOP_SHARING_ENABLED_CHANGED, + onDesktopSharingEnabledChanged); + + initCommands(); } /** - * Initializes postis library. + * Sends message to the external application. * + * @param {string} name - The name of the event. + * @param {Object} data - The data to be sent. * @returns {void} - * - * @private */ - _initPostis() { - const postisOptions = { - window: target - }; - - if (typeof jitsiMeetExternalApiId === 'number') { - postisOptions.scope - = `jitsi_meet_external_api_${jitsiMeetExternalApiId}`; + _sendEvent(name, data = {}) { + if (this.enabled) { + transport.sendEvent({ + name, + data + }); } - postis = postisInit(postisOptions); - initCommands(); } /** * Notify external application (if API is enabled) that message was sent. * - * @param {string} body - Message body. + * @param {string} message - Message body. * @returns {void} */ - notifySendingChatMessage(body) { - triggerEvent('outgoing-message', { 'message': body }); + notifySendingChatMessage(message) { + this._sendEvent('outgoing-message', { message }); } /** @@ -194,14 +160,12 @@ class API { return; } - triggerEvent( - 'incoming-message', - { - 'from': id, - 'message': body, - 'nick': nick, - 'stamp': ts - }); + this._sendEvent('incoming-message', { + from: id, + nick, + message: body, + stamp: ts + }); } /** @@ -212,7 +176,7 @@ class API { * @returns {void} */ notifyUserJoined(id) { - triggerEvent('participant-joined', { id }); + this._sendEvent('participant-joined', { id }); } /** @@ -223,7 +187,7 @@ class API { * @returns {void} */ notifyUserLeft(id) { - triggerEvent('participant-left', { id }); + this._sendEvent('participant-left', { id }); } /** @@ -231,39 +195,36 @@ class API { * nickname. * * @param {string} id - User id. - * @param {string} displayName - User nickname. + * @param {string} displayname - User nickname. * @returns {void} */ - notifyDisplayNameChanged(id, displayName) { - triggerEvent( - 'display-name-change', - { - displayname: displayName, - id - }); + notifyDisplayNameChanged(id, displayname) { + this._sendEvent('display-name-change', { + id, + displayname + }); } /** * Notify external application (if API is enabled) that the conference has * been joined. * - * @param {string} room - The room name. + * @param {string} roomName - The room name. * @returns {void} */ - notifyConferenceJoined(room) { - triggerEvent('video-conference-joined', { roomName: room }); + notifyConferenceJoined(roomName) { + this._sendEvent('video-conference-joined', { roomName }); } /** * Notify external application (if API is enabled) that user changed their * nickname. * - * @param {string} room - User id. - * @param {string} displayName - User nickname. + * @param {string} roomName - User id. * @returns {void} */ - notifyConferenceLeft(room) { - triggerEvent('video-conference-left', { roomName: room }); + notifyConferenceLeft(roomName) { + this._sendEvent('video-conference-left', { roomName }); } /** @@ -273,7 +234,7 @@ class API { * @returns {void} */ notifyReadyToClose() { - triggerEvent('video-ready-to-close', {}); + this._sendEvent('video-ready-to-close', {}); } /** @@ -283,22 +244,17 @@ class API { * @returns {void} */ sendRemoteControlEvent(event) { - sendMessage({ - method: 'remote-control-event', - params: event - }); + this._sendEvent('remote-control-event', event); } /** - * Removes the listeners. + * Disposes the allocated resources. * * @returns {void} */ dispose() { - if (postis) { - postis.destroy(); - postis = undefined; - + if (this.enabled) { + this.enabled = false; APP.conference.removeListener( JitsiMeetConferenceEvents.DESKTOP_SHARING_ENABLED_CHANGED, onDesktopSharingEnabledChanged); diff --git a/modules/API/constants.js b/modules/API/constants.js new file mode 100644 index 000000000..8ecb608bd --- /dev/null +++ b/modules/API/constants.js @@ -0,0 +1,7 @@ +declare var getConfigParamsFromUrl: Function; + +/** + * JitsiMeetExternalAPI id - unique for a webpage. + */ +export const API_ID + = getConfigParamsFromUrl().jitsi_meet_external_api_id; diff --git a/modules/API/index.js b/modules/API/index.js new file mode 100644 index 000000000..198a53aca --- /dev/null +++ b/modules/API/index.js @@ -0,0 +1,2 @@ +export default from './API'; +export * from './constants'; diff --git a/modules/transport/.eslintrc.js b/modules/transport/.eslintrc.js new file mode 100644 index 000000000..28bcd9f77 --- /dev/null +++ b/modules/transport/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + 'extends': '../../react/.eslintrc.js' +}; diff --git a/modules/transport/PostMessageTransportBackend.js b/modules/transport/PostMessageTransportBackend.js new file mode 100644 index 000000000..906d7e34e --- /dev/null +++ b/modules/transport/PostMessageTransportBackend.js @@ -0,0 +1,141 @@ +import Postis from 'postis'; + +/** + * The list of methods of incomming postis messages that we have to support for + * backward compatability for the users that are directly sending messages to + * Jitsi Meet (without using external_api.js) + * + * @type {string[]} + */ +const legacyIncomingMethods = [ 'display-name', 'toggle-audio', 'toggle-video', + 'toggle-film-strip', 'toggle-chat', 'toggle-contact-list', + 'toggle-share-screen', 'video-hangup', 'email', 'avatar-url' ]; + +/** + * The list of methods of outgoing postis messages that we have to support for + * backward compatability for the users that are directly listening to the + * postis messages send by Jitsi Meet(without using external_api.js). + * + * @type {string[]} + */ +const legacyOutgoingMethods = [ 'display-name-change', 'incoming-message', + 'outgoing-message', 'participant-joined', 'participant-left', + 'video-ready-to-close', 'video-conference-joined', + 'video-conference-left' ]; + +/** + * The postis method used for all messages. + * + * @type {string} + */ +const POSTIS_METHOD_NAME = 'data'; + +/** + * The default options for postis. + * + * @type {Object} + */ +const defaultPostisOptions = { + window: window.opener || window.parent +}; + +/** + * Implements message transport using the postMessage API. + */ +export default class PostMessageTransportBackend { + /** + * Creates new PostMessageTransportBackend instance. + * + * @param {Object} options - Optional parameters for configuration of the + * transport. + */ + constructor(options = {}) { + const postisOptions = Object.assign({}, defaultPostisOptions, options); + + this.postis = Postis(postisOptions); + + // backward compatability + legacyIncomingMethods.forEach(method => + this.postis.listen(method, + params => this._onPostisDataReceived(method, params))); + + this.postis.listen(POSTIS_METHOD_NAME, data => + this._dataReceivedCallBack(data)); + + this._dataReceivedCallBack = () => { + // do nothing until real callback is set; + }; + } + + /** + * Handles incomming legacy postis data. + * + * @param {string} method - The method property from postis data object. + * @param {Any} params - The params property from postis data object. + * @returns {void} + */ + _onPostisDataReceived(method, params = {}) { + const newData = { + data: { + name: method, + data: params + } + }; + + this._dataReceivedCallBack(newData); + } + + /** + * Sends the passed data via postis using the old format. + * + * @param {Object} data - The data to be sent. + * @returns {void} + */ + _sendLegacyData(data) { + const method = data.name; + + if (method && legacyOutgoingMethods.indexOf(method) !== -1) { + this.postis.send({ + method, + params: data.data + }); + } + } + + /** + * Disposes the allocated resources. + * + * @returns {void} + */ + dispose() { + this.postis.destroy(); + } + + /** + * Sends the passed data. + * + * @param {Object} data - The data to be sent. + * @returns {void} + */ + send(data) { + this.postis.send({ + method: POSTIS_METHOD_NAME, + params: data + }); + + // For the legacy use case we don't need any new fields defined in + // Transport class. That's why we are passing only the original object + // passed by the consumer of the Transport class which is data.data. + this._sendLegacyData(data.data); + } + + /** + * Sets the callback for receiving data. + * + * @param {Function} callback - The new callback. + * @returns {void} + */ + setDataReceivedCallback(callback) { + this._dataReceivedCallBack = callback; + } +} diff --git a/modules/transport/Transport.js b/modules/transport/Transport.js new file mode 100644 index 000000000..2fa0ae3d9 --- /dev/null +++ b/modules/transport/Transport.js @@ -0,0 +1,243 @@ +import { + MESSAGE_TYPE_EVENT, + MESSAGE_TYPE_RESPONSE, + MESSAGE_TYPE_REQUEST +} from './constants'; + +/** + * Stores the currnet transport that have to be used. + */ +export default class Transport { + /** + * Creates new instance. + * + * @param {Object} options - Optional parameters for configuration of the + * transport. + */ + constructor(options = {}) { + const { transport } = options; + + this._requestID = 0; + + this._responseHandlers = new Map(); + + this._listeners = new Map(); + + this._unprocessedMessages = new Set(); + + this.addListener = this.on; + + if (transport) { + this.setTransport(transport); + } + } + + /** + * Disposes the current transport. + * + * @returns {void} + */ + _disposeTransport() { + if (this._transport) { + this._transport.dispose(); + this._transport = null; + } + } + + /** + * Handles incomming data from the transport. + * + * @param {Object} data - The data. + * @returns {void} + */ + _onDataReceived(data) { + if (data.type === MESSAGE_TYPE_RESPONSE) { + const handler = this._responseHandlers.get(data.id); + + if (handler) { + handler(data); + this._responseHandlers.delete(data.id); + } + + return; + } + + if (data.type === MESSAGE_TYPE_REQUEST) { + this.emit('request', data.data, (result, error) => { + this._transport.send({ + type: MESSAGE_TYPE_RESPONSE, + result, + error, + id: data.id + }); + }); + } else { + this.emit('event', data.data); + } + } + + /** + * Disposes the allocated resources. + * + * @returns {void} + */ + dispose() { + this._responseHandlers.clear(); + this._unprocessedMessages.clear(); + this.removeAllListeners(); + this._disposeTransport(); + } + + /** + * Calls each of the listeners registered for the event named eventName, in + * the order they were registered, passing the supplied arguments to each. + * + * @param {string} eventName - The name of the event. + * @returns {boolean} True if the event had listeners, false otherwise. + */ + emit(eventName, ...args) { + const listenersForEvent = this._listeners.get(eventName); + + if (!listenersForEvent || listenersForEvent.size === 0) { + this._unprocessedMessages.add(args); + + return false; + } + + let isProcessed = false; + + listenersForEvent.forEach(listener => { + isProcessed = listener(...args) || isProcessed; + }); + + if (!isProcessed) { + this._unprocessedMessages.add(args); + } + } + + /** + * Adds the listener function to the listeners collection for the event + * named eventName. + * + * @param {string} eventName - The name of the event. + * @param {Function} listener - The listener that will be added. + * @returns {Transport} References to the instance of Transport class, so + * that calls can be chained. + */ + on(eventName, listener) { + let listenersForEvent = this._listeners.get(eventName); + + if (!listenersForEvent) { + listenersForEvent = new Set(); + this._listeners.set(eventName, listenersForEvent); + } + + listenersForEvent.add(listener); + + this._unprocessedMessages.forEach(args => { + if (listener(...args)) { + this._unprocessedMessages.delete(args); + } + }); + + return this; + } + + /** + * Removes all listeners, or those of the specified eventName. + * + * @param {string} [eventName] - The name of the event. + * @returns {Transport} References to the instance of Transport class, so + * that calls can be chained. + */ + removeAllListeners(eventName) { + if (eventName) { + this._listeners.delete(eventName); + } else { + this._listeners.clear(); + } + + return this; + } + + /** + * Removes the listener function from the listeners collection for the event + * named eventName. + * + * @param {string} eventName - The name of the event. + * @param {Function} listener - The listener that will be removed. + * @returns {Transport} References to the instance of Transport class, so + * that calls can be chained. + */ + removeListener(eventName, listener) { + const listenersForEvent = this._listeners.get(eventName); + + if (listenersForEvent) { + listenersForEvent.delete(listener); + } + + return this; + } + + /** + * Sends the passed data. + * + * @param {Object} data - The data to be sent. + * @returns {void} + */ + sendEvent(data = {}) { + if (this._transport) { + this._transport.send({ + type: MESSAGE_TYPE_EVENT, + data + }); + } + } + + /** + * Sending request. + * + * @param {Object} data - The data for the request. + * @returns {Promise} + */ + sendRequest(data) { + if (!this._transport) { + return Promise.reject(new Error('No transport defined!')); + } + this._requestID++; + const id = this._requestID; + + return new Promise((resolve, reject) => { + this._responseHandlers.set(this._requestID, response => { + const { result, error } = response; + + if (result) { + resolve(result); + } else if (error) { + reject(error); + } else { // no response + reject(new Error('Unexpected response format!')); + } + }); + + this._transport.send({ + id, + type: MESSAGE_TYPE_REQUEST, + data + }); + }); + } + + /** + * Changes the current transport. + * + * @param {Object} transport - The new transport that will be used. + * @returns {void} + */ + setTransport(transport) { + this._disposeTransport(); + this._transport = transport; + this._transport.setDataReceivedCallback( + this._onDataReceived.bind(this)); + } +} diff --git a/modules/transport/constants.js b/modules/transport/constants.js new file mode 100644 index 000000000..02a125a26 --- /dev/null +++ b/modules/transport/constants.js @@ -0,0 +1,20 @@ +/** + * The message type for events. + * + * @type {string} + */ +export const MESSAGE_TYPE_EVENT = 'event'; + +/** + * The message type for responses. + * + * @type {string} + */ +export const MESSAGE_TYPE_RESPONSE = 'response'; + +/** + * The message type for requests. + * + * @type {string} + */ +export const MESSAGE_TYPE_REQUEST = 'request'; diff --git a/modules/transport/index.js b/modules/transport/index.js new file mode 100644 index 000000000..cb7ae6e14 --- /dev/null +++ b/modules/transport/index.js @@ -0,0 +1,31 @@ +import { API_ID } from '../API'; +import { getJitsiMeetGlobalNS } from '../util/helpers'; + +import Transport from './Transport'; +import PostMessageTransportBackend from './PostMessageTransportBackend'; + +/** + * Option for the default low level transport. + * + * @type {Object} + */ +const postMessageOptions = {}; + +if (typeof API_ID === 'number') { + postMessageOptions.scope + = `jitsi_meet_external_api_${API_ID}`; +} + +export const transport = new Transport({ + transport: new PostMessageTransportBackend(postMessageOptions) +}); + +/** + * Sets the transport to passed transport. + * + * @param {Object} newTransport - The new transport. + * @returns {void} + */ +getJitsiMeetGlobalNS().useNewExternalTransport = function(newTransport) { + transport.setTransport(newTransport); +}; diff --git a/modules/util/helpers.js b/modules/util/helpers.js index 55408151a..9c333d6fa 100644 --- a/modules/util/helpers.js +++ b/modules/util/helpers.js @@ -74,3 +74,18 @@ export function debounce(fn, wait = 0, options = {}) { } }; } + +/** + * Returns the namespace for all global variables, functions, etc that we need. + * + * @returns {Object} the namespace. + * + * NOTE: After reactifying everything this should be the only place where + * we store everything that needs to be global (for some reason). + */ +export function getJitsiMeetGlobalNS() { + if(!window.JitsiMeetGlobalNS) { + window.JitsiMeetGlobalNS = { }; + } + return window.JitsiMeetGlobalNS; +} diff --git a/react/index.web.js b/react/index.web.js index 785afb653..d8ef1424e 100644 --- a/react/index.web.js +++ b/react/index.web.js @@ -3,6 +3,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { transport } from '../modules/transport'; + import config from './config'; import { App } from './features/app'; @@ -34,4 +36,5 @@ window.addEventListener('beforeunload', () => { APP.logCollectorStarted = false; } APP.API.dispose(); + transport.dispose(); });