Embedding discourse comments in a React Single Page App

in react

Discourse provides a simple way to embed a topic's posts in another website as comments. Problem was it didn't work for my React single page app. Here's how I fixed it

What Went Wrong

The recommendation on discourse site is to use the following code to embed the comments:

<div id='discourse-comments'></div>

<script type="text/javascript">
  DiscourseEmbed = { discourseUrl: 'http://discourse.example.com/',
                     discourseEmbedUrl: 'http://example.com/blog/entry-123.html' };

  (function() {
    var d = document.createElement('script'); d.type = 'text/javascript'; d.async = true;
    d.src = DiscourseEmbed.discourseUrl + 'javascripts/embed.js';
    (document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(d);
  })();
</script>

As the code is injected once to the template it causes a single script to be loaded. The code for the javascripts/embed.js file on my discourse site is:

(function() {

  var DE = window.DiscourseEmbed || {};
  var comments = document.getElementById('discourse-comments');
  var iframe = document.createElement('iframe');

  ['discourseUrl', 'discourseEmbedUrl', 'discourseUserName'].forEach(function(i) {
    if (window[i]) { DE[i] = DE[i] || window[i]; }
  });

  var queryParams = {};

  if (DE.discourseEmbedUrl) {
    if (DE.discourseEmbedUrl.indexOf('/') === 0) {
      console.error("discourseEmbedUrl must be a full URL, not a relative path");
    }

    queryParams.embed_url = encodeURIComponent(DE.discourseEmbedUrl);
  }

  if (DE.discourseUserName) {
    queryParams.discourse_username = DE.discourseUserName;
  }

  if (DE.topicId) {
    queryParams.topic_id = DE.topicId;
  }

  var src = DE.discourseUrl + 'embed/comments';
  var keys = Object.keys(queryParams);
  if (keys.length > 0) {
    src += "?";

    for (var i=0; i<keys.length; i++) {
      if (i > 0) { src += "&"; }

      var k = keys[i];
      src += k + "=" + queryParams[k];
    }
  }

  iframe.src = src;
  iframe.id = 'discourse-embed-frame';
  iframe.width = "100%";
  iframe.frameBorder = "0";
  iframe.scrolling = "no";
  comments.appendChild(iframe);

  // Thanks http://amendsoft-javascript.blogspot.ca/2010/04/find-x-and-y-coordinate-of-html-control.html
  function findPosY(obj)
  {
    var top = 0;
    if(obj.offsetParent)
    {
        while(1)
        {
          top += obj.offsetTop;
          if(!obj.offsetParent)
            break;
          obj = obj.offsetParent;
        }
    }
    else if(obj.y)
    {
        top += obj.y;
    }
    return top;
  }

  function normalizeUrl(url) {
    return url.toLowerCase().replace(/^https?(\:\/\/)?/, '');
  }

  function postMessageReceived(e) {
    if (!e) { return; }
    if (normalizeUrl(DE.discourseUrl).indexOf(normalizeUrl(e.origin)) === -1) { return; }

    if (e.data) {
      if (e.data.type === 'discourse-resize' && e.data.height) {
        iframe.height = e.data.height + "px";
      }

      if (e.data.type === 'discourse-scroll' && e.data.top) {
        // find iframe offset
        var destY = findPosY(iframe) + e.data.top;
        window.scrollTo(0, destY);
      }
    }
  }
  window.addEventListener('message', postMessageReceived, false);

})();

This code does not have any listeners for URL changes or user clicks, so when the page is changed that change does not affect the embedded comments.

One potential solution could be to call the script a second time on each page change. But I don't think it's a good idea since the code adds a global event handler on window via the line:

window.addEventListener('message', postMessageReceived, false);

Calling this multiple times without removing the previous event listener would add multiple listeners we don't need.

A Working React Component

All we need for this code to work in a single page app is to change the iFrame's src address to match discourseEmbedUrl.

This is easily done in React. We take discourseEmbedUrl as a prop and every time it changes React will re-render the iFrame. The event handler is added and removed using the component's lifecycle methods componentDidMount and componentWillUnmount.

The entire component (taking just a little over 100 lines):

import React from 'react';
import PropTypes from 'prop-types';

export default class DiscussionBox extends React.Component {

  static propTypes = {
    discourseUrl: PropTypes.string.isRequired,
    discourseEmbedUrl: PropTypes.string.isRequired,
  };

  constructor(props) {
    super(props);
    this.postMessageReceived = this.postMessageReceived.bind(this);
  }

  componentDidMount() {
    this.DiscourseEmbed = {
      discourseUrl: this.props.discourseUrl,
      discourseEmbedUrl: this.props.discourseEmbedUrl,
    };
    window.addEventListener('message', this.postMessageReceived, false);
  }

  componentWillUnmount() {
    window.removeEventListener('message', this.postMessageReceived);
  }

  getIframeSource() {
    const { discourseUrl, discourseEmbedUrl, discourseUserName } = this.props;
    const queryParams = {};

    if (discourseEmbedUrl) {
      if (discourseEmbedUrl.indexOf('/') === 0) {
        console.error('discourseEmbedUrl must be a full URL, not a relative path');
      }

      queryParams.embed_url = encodeURIComponent(discourseEmbedUrl);
    }

    if (discourseUserName) {
      queryParams.discourse_username = discourseUserName;
    }

    let src = discourseUrl + 'embed/comments';
    const keys = Object.keys(queryParams);
    if (keys.length > 0) {
      src += '?';

      for (let i = 0; i < keys.length; i++) {
        if (i > 0) { src += '&'; }

        const k = keys[i];
        src += k + '=' + queryParams[k];
      }
    }

    return src;
  }

  postMessageReceived(e) {
    if (!e) { return; }

    const iframe = this.iframe;
    const { discourseUrl } = this.props;

    if (normalizeUrl(discourseUrl).indexOf(normalizeUrl(e.origin)) === -1) { return; }

    if (e.data) {
      if (e.data.type === 'discourse-resize' && e.data.height) {
        iframe.height = e.data.height + 'px';
      }

      if (e.data.type === 'discourse-scroll' && e.data.top) {
        // find iframe offset
        const destY = findPosY(iframe) + e.data.top;
        window.scrollTo(0, destY);
      }
    }
  }

  render() {
    return (
      <div
        className='discussion-box-container'
      >
        <iframe
          title='discussion box'
          ref={(el) => { this.iframe = el; }}
          src={this.getIframeSource()}
          id='discourse-embed-frame'
          width='100%'
          frameBorder='0'
        />
      </div>
    );
  }
}

function normalizeUrl(url) {
  return url.toLowerCase().replace(/^https?(\:\/\/)?/, '');
}

function findPosY(obj) {
  let top = 0;
  if (obj.offsetParent) {
    while (1) {
      top += obj.offsetTop;
      if (!obj.offsetParent) {
        break;
      }
      obj = obj.offsetParent;
    }
  } else if (obj.y) {
    top += obj.y;
  }
  return top;
}

Comments