client.js

// vim: ts=4:sw=4:expandtab
/* global forsta, ifrpc */


/**
 * @namespace forsta
 */
self.forsta = self.forsta || {};

/**
 * @namespace forsta.messenger
 */
forsta.messenger = forsta.messenger || {};


/**
 * The various internal attributes a thread model has.
 *
 * @typedef {Object} ThreadAttributes
 * @property {string} id - The thread identifier (UUID).
 * @property {('conversation'|'announcement')} type - The type of thread this is.
 * @property {string} title - Optional title for this thread.
 * @property {string} distribution - The normalized tag expression for this thread.
 */


/**
 * A case-sensitive string value in UUID format.  E.g. "93fad4a7-bfa6-4f84-ab4d-4ed67e1d2658"
 *
 * @typedef {string} UUID
 */


/**
 * Milliseconds since Jan 1 1970 UTC.
 *
 * @typedef {number} Timestamp
 */


/**
 * The ID for a user in the Forsta ecosystem.
 *
 * @typedef {UUID} UserID
 */


/**
 * The ID for a messaging thread.  Every thread must have a unique identifier which is shared
 * amongst peers to distinguish conversations.
 *
 * @typedef {UUID} ThreadID
 */


/**
 * The ID for a message.
 *
 * @typedef {UUID} MessageID
 */


/**
 * Forsta tag expressions are any combination of tag identifiers (user or group).
 * They can include logical operators such as plus "+" and minus "-" to explicitly add
 * and remove certain users or groups from a distribution.  E.g...
 * >     (@macy:acme + @jim:nasa + @sales:nsa) - @drunk:uncle
 *
 * @see {@link https://docs.forsta.io/docs/tag-expressions}
 *
 * @typedef {string} TagExpression
 */


/**
 * Forsta JSON message exchange payload.  This is the main specification for how messages must be formatted
 * for communication in the Forsta ecosystem.
 *
 * @external ExchangePayload
 * @see {@link https://docs.google.com/document/d/e/2PACX-1vTv9Bahr0MyWiZT6B2xvUpBj0c3NGne0ZPeU40Kyn0UHMlYXVlEb1U5jgVCI0t9FkChVwYRCwTBTTiY/pub}
 */


/**
 * Forsta JWT. A Forsta JSON Web Token that is used to authenticate users with the client. JWTs
 * are created using the Atlas API login command. Examples on calling the API are available
 * as shell script and PHP.
 *
 * @external JWT
 * @see [Atlas API Login]{@link https://atlas.forsta.io/}
 * @see [JWT Shell Example]{@link https://github.com/ForstaLabs/developer-examples/tree/master/scripts}
 * @see [JWT PHP Example]{@link https://github.com/ForstaLabs/developer-examples/tree/master/php}
 */

(function() {
    'use strict';

    const ns = forsta.messenger;

    /**
     * The Forsta messenger client class.
     *
     * @memberof forsta.messenger
     * @fires init
     * @fires loaded
     * @fires thread-message
     * @fires thread-message-readmark
     * @fires provisioningrequired
     * @fires provisioningerror
     * @fires provisioningdone
     *
     * @example
     * const client = new forsta.messenger.Client(document.querySelector('#myDivId'),
     *                                            {orgEphemeralToken: 'secret'});
     */
    forsta.messenger.Client = class Client {

        /**
         * The client application has been initialized.  This is emitted shortly after successfully
         * starting up, but before the messenger is fully loaded.  Use the `loaded` event to wait
         * for the client application to be completely available.
         *
         * @event init
         * @type {object}
         */

        /**
         * The client application is fully loaded and ready to be controlled.
         *
         * @event loaded
         * @type {object}
         */

        /**
         * This event is emitted if the application requires the user to perform provisioning of
         * their Identity Key.
         *
         * @event provisioningrequired
         * @type {object}
         */

        /**
         * If an error occurs during provisioning it will be emitted using this event.
         *
         * @event provisioningerror
         * @type {object}
         * @property {Error} error - The error object.
         */

        /**
         * When provisioning has finished successfully this event is emitted.
         *
         * @event provisioningdone
         * @type {object}
         */

        /**
         * Thread message event.  Emitted when a new message is added, either by sending
         * or receiving.
         *
         * @event thread-message
         * @type {object}
         * @property {MessageID} id - The message ID.
         * @property {ThreadID} threadId - The ID of the thread this message belongs to.
         */

        /**
         * Thread message readmark change event.  Read marks indicate the most recent messages read
         * by a peer for a given thread.  They are timestamp values that correspond to the message
         * timestamps.
         *
         * @event thread-message-readmark
         * @type {object}
         * @property {ThreadID} threadId - The ID of the thread this readmark pertains to.
         * @property {UserID} source - The peer user id that sent the readmark.
         * @property {Timestamp} readmark - The timestamp of the readmark.  E.g. How far the user
         *                                  has read to.
         */

        /**
         * Auth is a single value union.  Only ONE property should be set.
         *
         *
         * @typedef {Object} ClientAuth
         * @property {string} [orgEphemeralToken] - Org ephemeral user token created at
         *                                          {@link https://app.forsta.io/authtokens}.
         * @property {string} [jwt] - An existing JSON Web Token for a Forsta user account. Note that
         *                            the JWT may be updated during use.  Subscribe to the `jwtupdate`
         *                            event to handle updates made during extended use.
         */

        /**
         * Information about the ephemeral user that will be created or reused for this session.
         *
         *
         * @typedef {Object} EphemeralUserInfo
         * @property {string} [firstName] - First name of the user.
         * @property {string} [lastName] - Last name of the user.
         * @property {string} [email] - Email of the user.
         * @property {string} [phone] - Phone of the user.  NOTE: Should be SMS capable.
         * @property {string} [salt] - Random value used to distinguish user accounts in advanced use-cases.
         */

        /**
         * @typedef {Object} ClientOptions
         * @property {Function} [onInit] - Callback to run when client is first initialized.
         * @property {Function} [onLoaded] - Callback to run when client is fully loaded and ready to use.
         * @property {string} [url=https://app.forsta.io/@] - Override the default site url.
         * @property {bool} showNav - Unhide the navigation panel used for thread selection.
         * @property {bool} showHeader - Unhide the header panel.
         * @property {bool} showThreadAside - Unhide the optional right aside panel containing thread info.
         * @property {bool} showThreadHeader - Unhide the thread header panel.
         * @property {EphemeralUserInfo} ephemeralUserInfo - Details about the ephemeral user to be created or used.
         *                                                   Only relevant when orgEphemeralToken auth is used.
         * @property {null|ThreadID} openThreadId - Force the messenger to open a specific thread on
         *                                          startup.  If the value is `null` it will force
         *                                          the messenger to not open any thread.
         */


        /**
         * @param {Element} el - Element where the messenger should be loaded.
         *                       {@link https://developer.mozilla.org/en-US/docs/Web/API/Element}
         * @param {ClientAuth} auth - Auth configuration for Forsta user account.
         * @param {ClientOptions} [options]
         */
        constructor(el, auth, options) {
            if (!(el instanceof Element)) {
                throw new TypeError('el argument must be an Element');
            }
            if (!auth) {
                throw new TypeError('auth argument missing');
            }
            this.auth = auth;
            this.options = options || {};
            this.onInit = this.options.onInit;
            this.onLoaded = this.options.onLoaded;
            this._iframe = document.createElement('iframe');
            this._iframe.style.border = 'none';
            this._iframe.style.width = '100%';
            this._iframe.style.height = '100%';
            const desiredFeatures = new Set([
                'camera',
                'microphone',
                'fullscreen',
                'autoplay',
                'display-capture',
                'geolocation',
                'speaker',
                'vibrate'
            ]);
            if (document.featurePolicy && document.featurePolicy.allowedFeatures) {
                const allowed = new Set(document.featurePolicy.allowedFeatures());
                for (const x of Array.from(desiredFeatures)) {
                    if (!allowed.has(x)) {
                        desiredFeatures.delete(x);
                    }
                }
            }
            this._iframe.setAttribute('allow', Array.from(desiredFeatures).join('; '));
            if (desiredFeatures.has('fullscreen')) {
                // Legacy fullscreen mode required too.
                this._iframe.setAttribute('allowfullscreen', 'true');
            }
            this._iframe.src = this.options.url || 'https://app.forsta.io/@';
            el.appendChild(this._iframe);
            this._rpc = ifrpc.init(this._iframe.contentWindow, {acceptOpener: true});
            this._idbGateway = new ns.IDBGateway(this._rpc);
            const _this = this;
            this._rpc.addEventListener('init', function(data) {
                const ev = this;
                _this._onClientInit(ev.source, data);
            });
            if (this.onLoaded) {
                this._rpc.addEventListener('loaded', () => this.onLoaded(this));
            }
        }

        async _onClientInit(frame, data) {
            const config = {
                auth: this.auth
            };
            if (data.scope === 'main') {
                Object.assign(config, {
                    showNav: !!this.options.showNav,
                    showHeader: !!this.options.showHeader,
                    showThreadAside: !!this.options.showThreadAside,
                    showThreadHeader: !!this.options.showThreadHeader,
                    ephemeralUser: this.options.ephemeralUserInfo,
                    openThreadId: this.options.openThreadId,
                });
                if (this._rpcEarlyEvents) {
                    for (const x of this._rpcEarlyEvents) {
                        this._rpc.addEventListener(x.event, x.callback);
                    }
                    delete this._rpcEarlyEvents;
                }
            }
            await this._rpc.invokeCommandWithFrame(frame, 'configure', config);
            if (data.scope === 'main' && this.onInit) {
                await this.onInit(this);
            }
        }

        /**
         * Add an event listener.
         *
         * @param {string} event - Name of the event to listen to.
         * @param {Function} callback - Callback function to invoke.
         */
        addEventListener(event, callback) {
            if (!this._rpc) {
                if (!this._rpcEarlyEvents) {
                    this._rpcEarlyEvents = [];
                }
                this._rpcEarlyEvents.push({event, callback});
            } else {
                this._rpc.addEventListener(event, callback);
            }
        }

        /**
         * Remove an event listener.
         *
         * @param {string} event - Name of the event to stop listening to.
         * @param {Function} callback - Callback function used with {@link addEventListener}.
         */
        removeEventListener(event, callback) {
            if (!this._rpc) {
                this._rpcEarlyEvents = this._rpcEarlyEvents.filter(x =>
                    !(x.event === event && x.callback === callback));
            } else {
                this._rpc.removeEventListener(event, callback);
            }
        }

        /**
         * Expand or collapse the navigation panel.
         *
         * @param {bool} [collapse] - Force the desired collapse state.
         */
        async navPanelToggle(collapse) {
            await this._rpc.invokeCommand('nav-panel-toggle', collapse);
        }

        /**
         * Select or create a thread.  If the tag `expression` argument matches an
         * existing thread it will be opened, otherwise a new thread will be created.
         *
         * @param {TagExpression} expression - The thread distribution to match on or create.
         * @param {ThreadAttributes} [attrs] - Optional attributes to be applied to the resulting
         *                                     thread.
         * @returns {string} The thread ID that was opened or created.
         */
        async threadStartWithExpression(expression, attrs) {
            const id = await this._rpc.invokeCommand('thread-ensure', expression, attrs);
            await this._rpc.invokeCommand('thread-open', id);
            return id;
        }

        /**
         * Ensure that a thread exists matching the expression argument.
         *
         * @param {TagExpression} expression - The thread distribution to match on or create.
         * @param {ThreadAttributes} [attrs] - Optional attributes to be applied to the resulting
         *                                     thread.
         * @returns {string} The thread ID created or matching the expression provided.
         */
        async threadEnsure(expression, attrs) {
            return await this._rpc.invokeCommand('thread-ensure', expression, attrs);
        }

        /**
         * Make a new thread.
         *
         * @param {TagExpression} expression - The thread distribution to create.
         * @param {ThreadAttributes} [attrs] - Optional attributes to be applied to the resulting
         *                                     thread.
         * @returns {string} The thread ID created or matching the expression provided.
         */
        async threadMake(expression, attrs) {
            return await this._rpc.invokeCommand('thread-make', expression, attrs);
        }

        /**
         * Open a thread by its `ID`.
         *
         * @param {string} id - The thread ID to open.
         */
        async threadOpen(id) {
            await this._rpc.invokeCommand('thread-open', id);
        }

        /**
         * Set the expiration time for messages in a thread.  When this value is set to a non-zero
         * value, messages will expire from the thread after they are read.  Set this value to `0`
         * to disable the expiration behavior.
         *
         * @param {string} id - The thread ID to update.
         * @param {number} expiration - Expiration time in seconds.  The expiration timer starts
         * when the message is read by the recipient.
         */
        async threadSetExpiration(id, expiration) {
            await this._rpc.invokeCommand('thread-set-expiration', id, expiration);
        }

        /**
         * List threads known to this client.
         *
         * @returns {string[]} - List of thread IDs.
         */
        async threadList() {
            return await this._rpc.invokeCommand('thread-list');
        }

        /**
         * List the attributes of a thread.
         *
         * @param {ThreadID} id - The thread ID to update.
         *
         * @returns {string[]} - List of thread attibutes.
         */
        async threadListAttributes(id) {
            return await this._rpc.invokeCommand('thread-list-attributes', id);
        }

        /**
         * Get the value of a thread attribute.
         *
         * @param {ThreadID} id - The thread ID to update.
         * @param {string} attr - The thread attribute to get.
         *
         * @returns {*} - The value of the thread attribute.
         */
        async threadGetAttribute(id, attr) {
            return await this._rpc.invokeCommand('thread-get-attribute', id, attr);
        }

        /**
         * Set the value of a thread attribute.
         *
         * @param {ThreadID} id - The thread ID to update.
         * @param {string} attr - The thread attribute to update.
         * @param {*} value - The value to set.
         */
        async threadSetAttribute(id, attr, value) {
            await this._rpc.invokeCommand('thread-set-attribute', id, attr, value);
        }

        /**
         * Archive a thread.
         *
         * @param {ThreadID} id - The thread ID to archive.
         */
        async threadArchive(id, options) {
            await this._rpc.invokeCommand('thread-archive', id, options);
        }

        /**
         * Restore an archived thread to normal status.
         *
         * @param {ThreadID} id - The thread ID to restore from the archives.
         */
        async threadRestore(id, options) {
            await this._rpc.invokeCommand('thread-restore', id, options);
        }

        /**
         * Expunging a thread deletes it and all its messages forever.
         *
         * @param {ThreadID} id - The thread ID to expunge.
         */
        async threadExpunge(id, options) {
            await this._rpc.invokeCommand('thread-expunge', id, options);
        }

        /**
         * Delete local copy of messages for a thread.
         *
         * @param {ThreadID} id - The thread ID to delete messages from.
         */
        async threadDestroyMessages(id) {
            await this._rpc.invokeCommand('thread-destroy-messages', id);
        }

        /**
         * Send a message to a thread.
         *
         * @param {ThreadID} id - The thread ID to send a message to.
         * @param {string} plainText - Plain text message to send.
         * @param {string} [html] - HTML version of message to send.
         * @param {Array} [attachments] - Array of attachment objects.
         * @param {Object} [attrs] - Message attributes.
         * @param {Object} [options] - Send options.
         *
         * @returns {string} - The ID of the message sent.
         */
        async threadSendMessage(id, plainText, html, attachments, attrs, options) {
            return await this._rpc.invokeCommand('thread-send-message', id, plainText, html, attachments, attrs, options);
        }

        /**
         * Send a control message to a thread.
         *
         * @param {ThreadID} id - The thread ID to send a message to.
         * @param {Object} data - Object containing the control information.  Read {@link external:ExchangePayload}
         *                        for more information on control messages.
         * @param {Array} [attachments] - Array of attachment objects.
         * @param {Object} [options] - Send options.
         *
         * @returns {string} - The ID of the message sent.
         */
        async threadSendControl(id, data, attachments, options) {
            return await this._rpc.invokeCommand('thread-send-control', id, data, attachments, options);
        }

        /**
         * Send a thread update to members of a thread.
         *
         * @param {ThreadID} id - The thread ID to send a message to.
         * @param {Object} updates - Object containing the update key/value pairs.  See the `threadUpdate`
         *                           section of {@link external:ExchangePayload} for details.
         * @param {Object} [options] - Options for the thread update.
         */
        async threadSendUpdate(id, updates, options) {
            return await this._rpc.invokeCommand('thread-send-update', id, updates, options);
        }

        /**
         * Amend the distribution of a thread by adding tag expressions to it.
         *
         * @param {ThreadID} id - The thread ID to alter.
         * @param {TagExpression} expression - Snip of valid tag expression to add to the thread.
         *
         * @returns {TagExpression} - The updated tag expression.
         */
        async threadAmendDistribution(id, expression) {
            return await this._rpc.invokeCommand('thread-amend-distribution', id, expression);
        }

        /**
         * Repeal a subset of a thread's distribution.
         *
         * @param {ThreadID} id - The thread ID to alter.
         * @param {TagExpression} expression - Snip of valid tag expression to repeal from the thread.
         *
         * @returns {TagExpression} - The updated tag expression.
         */
        async threadRepealDistribution(id, expression) {
            return await this._rpc.invokeCommand('thread-repeal-distribution', id, expression);
        }

        /**
         * Add a user ID to a thread's distribution.
         *
         * @param {ThreadID} id - The thread ID to alter.
         * @param {UserID} userId - User ID to add to the thread's distribution.
         *
         * @returns {TagExpression} - The updated tag expression.
         */
        async threadAddMember(id, userId) {
            return await this._rpc.invokeCommand('thread-add-member', id, userId);
        }

        /**
         * Remove a user ID from a thread's distribution.
         *
         * @param {ThreadID} id - The thread ID to alter.
         * @param {UserID} userId - User ID to remove from the thread's distribution.
         *
         * @returns {TagExpression} - The updated tag expression.
         */
        async threadRemoveMember(id, userId) {
            return await this._rpc.invokeCommand('thread-remove-member', id, userId);
        }

        /**
         * Leave a thread.  I.e. remove yourself from a thread's distribution.
         *
         * @param {ThreadID} id - The thread ID to alter.
         */
        async threadLeave(id) {
            return await this._rpc.invokeCommand('thread-leave', id);
        }
    };
})();