From 15c7a7cad779839685b1e945de899ebaad6af287 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 3 Aug 2017 11:23:06 +1000 Subject: [PATCH 1/2] Added support for embed unloading --- .../coral-embed-stream/src/actions/stream.js | 2 +- client/coral-embed/src/Snackbar.js | 74 ++++++ client/coral-embed/src/Stream.js | 164 ++++++++++++ client/coral-embed/src/StreamInterface.js | 21 ++ client/coral-embed/src/index.js | 244 ++---------------- client/coral-framework/utils/index.js | 9 +- client/coral-framework/utils/url.js | 8 + 7 files changed, 289 insertions(+), 233 deletions(-) create mode 100644 client/coral-embed/src/Snackbar.js create mode 100644 client/coral-embed/src/Stream.js create mode 100644 client/coral-embed/src/StreamInterface.js create mode 100644 client/coral-framework/utils/url.js diff --git a/client/coral-embed-stream/src/actions/stream.js b/client/coral-embed-stream/src/actions/stream.js index 7250b86f3..c20760259 100644 --- a/client/coral-embed-stream/src/actions/stream.js +++ b/client/coral-embed-stream/src/actions/stream.js @@ -1,6 +1,6 @@ import pym from 'coral-framework/services/pym'; import * as actions from '../constants/stream'; -import {buildUrl} from 'coral-framework/utils'; +import {buildUrl} from 'coral-framework/utils/url'; import queryString from 'query-string'; export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id}); diff --git a/client/coral-embed/src/Snackbar.js b/client/coral-embed/src/Snackbar.js new file mode 100644 index 000000000..c518288c6 --- /dev/null +++ b/client/coral-embed/src/Snackbar.js @@ -0,0 +1,74 @@ +const DEFAULT_STYLE = { + position: 'fixed', + cursor: 'default', + userSelect: 'none', + backgroundColor: '#323232', + zIndex: 3, + willChange: 'transform, opacity', + transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s', + pointerEvents: 'none', + padding: '12px 18px', + color: '#fff', + borderRadius: '3px 3px 0 0', + textAlign: 'center', + maxWidth: '400px', + left: '50%', + opacity: 0, + transform: 'translate(-50%, 20px)', + bottom: 0, + boxSizing: 'border-box', + fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif' +}; + +export default class Snackbar { + constructor(customStyle = {}) { + this.timeout = null; + this.el = document.createElement('div'); + this.el.id = 'coral-notif'; + + // Apply custom styles to the snackbar. + const style = Object.assign({}, DEFAULT_STYLE, customStyle); + for (let key in style) { + this.el.style[key] = style[key]; + } + } + + clear() { + this.el.style.opacity = 0; + } + + alert(message) { + const [type, text] = message.split('|'); + this.el.style.transform = 'translate(-50%, 20px)'; + this.el.style.opacity = 0; + this.el.className = `coral-notif-${type}`; + this.el.textContent = text; + + if (this.timeout) { + clearTimeout(this.timeout); + } + + this.timeout = setTimeout(() => { + this.el.style.transform = 'translate(-50%, 0)'; + this.el.style.opacity = 1; + + this.timeout = setTimeout(() => { + this.el.style.opacity = 0; + }, 7000); + }, 0); + } + + attach(el, pym) { + el.appendChild(this.el); + + // Attach the clear clear notification event to the clear method. + pym.onMessage('coral-clear-notification', this.clear.bind(this)); + + // Attach the alert to the alert method. + pym.onMessage('coral-alert', this.alert.bind(this)); + } + + remove() { + this.el.remove(); + } +} diff --git a/client/coral-embed/src/Stream.js b/client/coral-embed/src/Stream.js new file mode 100644 index 000000000..a421adf5f --- /dev/null +++ b/client/coral-embed/src/Stream.js @@ -0,0 +1,164 @@ +import queryString from 'query-string'; +import pym from 'pym.js'; +import EventEmitter from 'eventemitter2'; +import {buildUrl} from 'coral-framework/utils/url'; +import Snackbar from './Snackbar'; + +const NOTIFICATION_OFFSET = 200; + +// Build the URL to load in the pym iframe. +function buildStreamIframeUrl(talkBaseUrl, query) { + let url = [ + talkBaseUrl, + talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/' + 'embed/stream?' + ].join(''); + + url += queryString.stringify(query); + + return url; +} + +// Get dimensions of viewport. +function viewportDimensions() { + let e = window, a = 'inner'; + if (!('innerWidth' in window)) { + a = 'client'; + e = document.documentElement || document.body; + } + + return { + width: e[`${a}Width`], + height: e[`${a}Height`] + }; +} + +export default class Stream { + constructor(el, talkBaseUrl, query, opts) { + + // Create and save the options. + + this.opts = opts; + this.query = query; + + this.emitter = new EventEmitter({wildcard: true}); + this.pym = new pym.Parent(el.id, buildStreamIframeUrl(talkBaseUrl, query), { + title: opts.title, + id: `${el.id}_iframe`, + name: `${el.id}_iframe` + }); + this.snackBar = new Snackbar(opts.snackBarStyles || {}); + + // Workaround: IOS Safari ignores `width` but respects `min-width` value. + this.pym.el.firstChild.style.width = '1px'; + this.pym.el.firstChild.style.minWidth = '100%'; + + // Resize parent iframe height when child height changes + let cachedHeight; + this.pym.onMessage('height', (height) => { + if (height !== cachedHeight) { + this.pym.el.firstChild.style.height = `${height}px`; + cachedHeight = height; + } + }); + + // Attach to the events emitted by the pym parent. + if (opts.events) { + opts.events(this.emitter); + } + + this.pym.onMessage('getConfig', () => { + this.pym.sendMessage('config', JSON.stringify(opts)); + }); + + // If the auth changes, and someone is listening for it, then re-emit it. + if (opts.onAuthChanged) { + this.pym.onMessage('coral-auth-changed', (message) => { + opts.onAuthChanged(message ? JSON.parse(message) : null); + }); + } + + // Attach the snackbar to the pym parent and to the body of the page. + this.snackBar.attach(window.document.body, this.pym); + + // Remove the permalink comment id from the hash. + this.pym.onMessage('coral-view-all-comments', () => { + const search = queryString.stringify({ + ...queryString.parse(location.search), + commentId: undefined, + }); + + // Remove the commentId url param. + const url = buildUrl({...location, search}); + + // Change the url. + window.history.replaceState({}, document.title, url); + }); + + // Remove the permalink comment id from the hash. + this.pym.onMessage('coral-view-comment', (id) => { + const search = queryString.stringify({ + ...queryString.parse(location.search), + commentId: id, + }); + + // Remove the commentId url param. + const url = buildUrl({...location, search}); + + // Change the url. + window.history.replaceState({}, document.title, url); + }); + + // Helps child show notifications at the right scrollTop. + this.pym.onMessage('getPosition', () => { + const {height} = viewportDimensions(); + let position = height + document.body.scrollTop; + + if (position > NOTIFICATION_OFFSET) { + position = position - NOTIFICATION_OFFSET; + } + + this.pym.sendMessage('position', position); + }); + + // When end-user clicks link in iframe, open it in parent context + this.pym.onMessage('navigate', (url) => { + window.open(url, '_blank').focus(); + }); + + // Pass events from iframe to the event emitter. + this.pym.onMessage('event', (raw) => { + const {eventName, value} = JSON.parse(raw); + this.emitter.emit(eventName, value); + }); + + // If the user clicks outside the embed, then tell the embed. + document.addEventListener('click', this.handleClick.bind(this), true); + } + + login(token) { + this.pym.sendMessage('login', token); + } + + logout() { + this.pym.sendMessage('logout'); + } + + remove() { + + // Remove the event listeners. + + document.removeEventListener('click', this.handleClick.bind(this)); + + // Remove the snackbar. + this.snackBar.remove(); + + // Remove the pym parent. + + this.pym.remove(); + } + + handleClick() { + this.pym.sendMessage('click'); + } +} diff --git a/client/coral-embed/src/StreamInterface.js b/client/coral-embed/src/StreamInterface.js new file mode 100644 index 000000000..4c6e29970 --- /dev/null +++ b/client/coral-embed/src/StreamInterface.js @@ -0,0 +1,21 @@ +export default class StreamInterface { + constructor(stream) { + this._stream = stream; + } + + on(eventName, callback) { + return this._stream.emitter.on(eventName, callback); + } + + login(token) { + return this._stream.login(token); + } + + logout() { + return this._stream.logout(); + } + + remove() { + return this._stream.remove(); + } +} diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index dd44e72cd..2b17ed465 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -1,196 +1,10 @@ -import pym from 'pym.js'; import URLSearchParams from 'url-search-params'; - -import {buildUrl} from 'coral-framework/utils'; -import queryString from 'query-string'; -import EventEmitter from 'eventemitter2'; - -// TODO: Styles should live in a separate file -const snackbarStyles = { - position: 'fixed', - cursor: 'default', - userSelect: 'none', - backgroundColor: '#323232', - zIndex: 3, - willChange: 'transform, opacity', - transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s', - pointerEvents: 'none', - padding: '12px 18px', - color: '#fff', - borderRadius: '3px 3px 0 0', - textAlign: 'center', - maxWidth: '400px', - left: '50%', - opacity: 0, - transform: 'translate(-50%, 20px)', - bottom: 0, - boxSizing: 'border-box', - fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif' -}; +import Stream from './Stream'; +import StreamInterface from './StreamInterface'; // This function should return value of window.Coral const Coral = {}; const Talk = (Coral.Talk = {}); -let notificationTimeout = null; - -// build the URL to load in the pym iframe -function buildStreamIframeUrl(talkBaseUrl, query) { - let url = [ - talkBaseUrl, - talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/' - 'embed/stream?' - ].join(''); - - url += queryString.stringify(query); - - return url; -} - -// Set up postMessage listeners/handlers on the pymParent -// e.g. to resize the iframe, and navigate the host page -function configurePymParent(pymParent, eventEmitter, opts) { - let notificationOffset = 200; - let cachedHeight; - const snackbar = document.createElement('div'); - - // Sends config to pymChild - function sendConfig(config) { - pymParent.sendMessage('config', JSON.stringify(config)); - } - - if (opts.events) { - opts.events(eventEmitter); - } - - pymParent.onMessage('coral-auth-changed', function(message) { - if (opts.onAuthChanged) { - opts.onAuthChanged(message ? JSON.parse(message) : null); - } - }); - - // Sends config to the child - pymParent.onMessage('getConfig', function() { - sendConfig(opts || {}); - }); - - snackbar.id = 'coral-notif'; - - for (let key in snackbarStyles) { - snackbar.style[key] = snackbarStyles[key]; - } - - window.document.body.appendChild(snackbar); - - // Notify embed that there was a click outside. - document.addEventListener('click', () => { - pymParent.sendMessage('click'); - }, true); - - // Workaround: IOS Safari ignores `width` but respects `min-width` value. - pymParent.el.firstChild.style.width = '1px'; - pymParent.el.firstChild.style.minWidth = '100%'; - - // Resize parent iframe height when child height changes - pymParent.onMessage('height', function(height) { - if (height !== cachedHeight) { - pymParent.el.firstChild.style.height = `${height}px`; - cachedHeight = height; - } - }); - - pymParent.onMessage('coral-clear-notification', function() { - snackbar.style.opacity = 0; - }); - - // remove the permalink comment id from the hash - pymParent.onMessage('coral-view-all-comments', function() { - - const search = queryString.stringify({ - ...queryString.parse(location.search), - commentId: undefined, - }); - - // remove the commentId url param - const url = buildUrl({...location, search}); - - window.history.replaceState( - {}, - document.title, - url, - ); - }); - - // remove the permalink comment id from the hash - pymParent.onMessage('coral-view-comment', function(id) { - - const search = queryString.stringify({ - ...queryString.parse(location.search), - commentId: id, - }); - - // remove the commentId url param - const url = buildUrl({...location, search}); - - window.history.replaceState( - {}, - document.title, - url, - ); - }); - - pymParent.onMessage('coral-alert', function(message) { - const [type, text] = message.split('|'); - snackbar.style.transform = 'translate(-50%, 20px)'; - snackbar.style.opacity = 0; - snackbar.className = `coral-notif-${type}`; - snackbar.textContent = text; - - clearTimeout(notificationTimeout); - notificationTimeout = setTimeout(() => { - snackbar.style.transform = 'translate(-50%, 0)'; - snackbar.style.opacity = 1; - - notificationTimeout = setTimeout(() => { - snackbar.style.opacity = 0; - }, 7000); - }, 0); - }); - - // Helps child show notifications at the right scrollTop - pymParent.onMessage('getPosition', function() { - let position = viewport().height + document.body.scrollTop; - - if (position > notificationOffset) { - position = position - notificationOffset; - } - - pymParent.sendMessage('position', position); - }); - - // When end-user clicks link in iframe, open it in parent context - pymParent.onMessage('navigate', function(url) { - window.open(url, '_blank').focus(); - }); - - // Pass events from iframe to the event emitter - pymParent.onMessage('event', (raw) => { - const {eventName, value} = JSON.parse(raw); - eventEmitter.emit(eventName, value); - }); - - // get dimensions of viewport - const viewport = () => { - let e = window, a = 'inner'; - if (!('innerWidth' in window)) { - a = 'client'; - e = document.documentElement || document.body; - } - return { - width: e[`${a}Width`], - height: e[`${a}Height`] - }; - }; -} /** * Render a Talk stream @@ -219,17 +33,14 @@ function configurePymParent(pymParent, eventEmitter, opts) { * }); * ``` */ -Talk.render = function(el, opts) { +Talk.render = (el, opts) => { if (!el) { - throw new Error( - 'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.' - ); + throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'); } if (typeof el !== 'object') { - throw new Error( - `Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})` - ); + throw new Error(`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`); } + opts = opts || {}; // TODO: infer this URL without explicit user input (if possible, may have to be added at build/render time of this script) @@ -245,22 +56,27 @@ Talk.render = function(el, opts) { } // Compose the query to send down to the Talk API so it knows what to load. - let query = {}; - - let urlParams = new URLSearchParams(window.location.search); + const query = {}; + // Parse the url parameters to extract some of the information. + const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('commentId')) { query.comment_id = urlParams.get('commentId'); } + // Extract the asset id from the options. if (opts.asset_id) { query.asset_id = opts.asset_id; } + // Extract the asset url. if (opts.asset_url) { query.asset_url = opts.asset_url; - } - else { + } else if (!opts.asset_id) { + + // The asset url was not provided and the asset id was also not provided, + // we need to infer the asset url from details on the page. + try { query.asset_url = document.querySelector('link[rel="canonical"]').href; } catch (e) { @@ -276,31 +92,11 @@ Talk.render = function(el, opts) { } } - const pymParent = new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), { - title: opts.title, - id: `${el.id}_iframe`, - name: `${el.id}_iframe` - }); + // Create the new Stream. + const stream = new Stream(el, opts.talk, query, opts); - const eventEmitter = new EventEmitter({wildcard: true}); - - configurePymParent( - pymParent, - eventEmitter, - opts - ); - - return { - on(eventName, callback) { - eventEmitter.on(eventName, callback); - }, - login(token) { - pymParent.sendMessage('login', token); - }, - logout() { - pymParent.sendMessage('logout'); - } - }; + // Return the public interface for the stream. + return new StreamInterface(stream); }; export default Coral; diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index 96d9ed629..63dfd7dc4 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -169,14 +169,7 @@ export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which); -export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) { - if (search && search[0] !== '?') { - search = `?${search}`; - } else if (search === '?') { - search = ''; - } - return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`; -} +export * from './url'; /** * getSlotFragmentSpreads will return a string in the diff --git a/client/coral-framework/utils/url.js b/client/coral-framework/utils/url.js new file mode 100644 index 000000000..603a3954d --- /dev/null +++ b/client/coral-framework/utils/url.js @@ -0,0 +1,8 @@ +export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) { + if (search && search[0] !== '?') { + search = `?${search}`; + } else if (search === '?') { + search = ''; + } + return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`; +} From 325626fecff614e9a551fc20913c57b2b8be5460 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 3 Aug 2017 11:33:07 +1000 Subject: [PATCH 2/2] removed all emitter listeners when we remove the stream --- client/coral-embed/src/Stream.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/coral-embed/src/Stream.js b/client/coral-embed/src/Stream.js index a421adf5f..b81964529 100644 --- a/client/coral-embed/src/Stream.js +++ b/client/coral-embed/src/Stream.js @@ -147,14 +147,13 @@ export default class Stream { remove() { // Remove the event listeners. - document.removeEventListener('click', this.handleClick.bind(this)); + this.emitter.removeAllListeners(); // Remove the snackbar. this.snackBar.remove(); // Remove the pym parent. - this.pym.remove(); }