aboutsummaryrefslogblamecommitdiff
path: root/vnext/src/utils/embed.js
blob: f7f55eb79f1e9632a6bc8d63e874872b7505a1c5 (plain) (tree)































































                                                                                                                    
                           






                                               
                                                                      

































                                                                               
                                                                       





























































































                                                                                                                                                                                                                                                                                                                                   



                                                                          




























                                                                                                              
























                                                                                                                                  















































                                                                                                             
                                                                        
































                                                                                


                                                 

                                                           
                            





                                                             
                                  
function insertAfter(newNode, referenceNode) {
  referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}

function setContent(containerNode, ...newNodes) {
  removeAllFrom(containerNode);
  newNodes.forEach(n => containerNode.appendChild(n));
  return containerNode;
}

function removeAllFrom(fromNode) {
  for (let c; c = fromNode.lastChild; ) { fromNode.removeChild(c); }
}

function htmlEscape(html) {
  let textarea = document.createElement('textarea');
  textarea.textContent = html;
  return textarea.innerHTML;
}

// rules :: [{pr: number, re: RegExp, with: string}]
// rules :: [{pr: number, re: RegExp, with: Function}]
// rules :: [{pr: number, re: RegExp, brackets: true, with: [string, string]}]
// rules :: [{pr: number, re: RegExp, brackets: true, with: [string, string, Function]}]
function formatText(txt, rules) {
  let idCounter = 0;
  function nextId() { return idCounter++; }
  function ft(txt, rules) {
    let matches = rules.map(r => { r.re.lastIndex = 0; return [r, r.re.exec(txt)]; })
                       .filter(([,m]) => m !== null)
                       .sort(([r1,m1],[r2,m2]) => (r1.pr - r2.pr) || (m1.index - m2.index));
    if (matches && matches.length > 0) {
      let [rule, match] = matches[0];
      let subsequentRules = rules.filter(r => r.pr >= rule.pr);
      let idStr = `<>(${nextId()})<>`;
      let outerStr = txt.substring(0, match.index) + idStr + txt.substring(rule.re.lastIndex);
      let innerStr = (rule.brackets)
        ? (() => { let [l ,r ,f] = rule.with; return l + ft((f ? f(match[1]) : match[1]), subsequentRules) + r; })()
        : match[0].replace(rule.re, rule.with);
      return ft(outerStr, subsequentRules).replace(idStr, innerStr);
    }
    return txt;
  }
  return ft(htmlEscape(txt), rules); // idStr above relies on the fact the text is escaped
}

function fixWwwLink(url) {
  return url.replace(/^(?!([a-z]+:)?\/\/)/i, '//');
}

function makeNewNode(embedType, aNode, reResult) {
  const withClasses = el => {
    if (embedType.className) {
      el.classList.add(...embedType.className.split(' '));
    }
    return el;
  };
  return embedType.makeNode(aNode, reResult, withClasses(document.createElement('div')));
}

function makeIframe(src, w, h, scrolling='no') {
  let iframe = document.createElement('iframe');
  iframe.style.width = w;
  iframe.style.height = h;
  iframe.frameBorder = '0';
  iframe.scrolling = scrolling;
  iframe.setAttribute('allowFullScreen', '');
  iframe.src = src;
  iframe.innerHTML = 'Cannot show iframes.';
  return iframe;
}

function makeResizableToRatio(element, ratio) {
  element.setAttribute('data-ratio', ratio);
  makeResizable(element, w => w * element.getAttribute('data-ratio'));
}

// calcHeight :: Number -> Number -- calculate element height for a given width
function makeResizable(element, calcHeight) {
  const setHeight = el => {
    if (document.body.contains(el) && (el.offsetWidth > 0)) {
      el.style.height = (calcHeight(el.offsetWidth)).toFixed(2) + 'px';
    }
  };
  window.addEventListener('resize', () => setHeight(element));
  setHeight(element);
}

function extractDomain(url) {
  const domainRe = /^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/\n]+)/i;
  return domainRe.exec(url)[1];
}

function urlReplace(match, p1, p2, p3) {
  let isBrackets = (p1 !== undefined);
  return (isBrackets)
    ? `<a href="${fixWwwLink(p2 || p3)}">${p1}</a>`
    : `<a href="${fixWwwLink(match)}">${extractDomain(match)}</a>`;
}

function urlReplaceInCode(match, p1, p2, p3) {
  let isBrackets = (p1 !== undefined);
  return (isBrackets)
    ? `<a href="${fixWwwLink(p2 || p3)}">${match}</a>`
    : `<a href="${fixWwwLink(match)}">${match}</a>`;
}

function messageReplyReplace(messageId) {
  return function(match, mid, rid) {
    let replyPart = (rid && rid != '0') ? '#' + rid : '';
    return `<a href="/m/${mid || messageId}${replyPart}">${match}</a>`;
  };
}

/**
 * Given "txt" message in unescaped plaintext with Juick markup, this function
 * returns escaped formatted HTML string.
 *
 * @param {string} txt
 * @param {string} messageId - current message id
 * @param {boolean} isCode
 * @returns {string}
 */
function juickFormat(txt, messageId, isCode) {
  const urlRe = /(?:\[([^\]\[]+)\](?:\[([^\]]+)\]|\(((?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[-\w+*&@#/%=~|$?!:;,.])*(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[\w+*&@#/%=~|$]))\))|\b(?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[-\w+*&@#/%=~|$?!:;,.])*(?:\([-\w+*&@#/%=~|$?!:;,.]*\)|[\w+*&@#/%=~|$]))/gi;
  const bqReplace = m => m.replace(/^(?:>|&gt;)\s?/gmi, '');
  return (isCode)
    ? formatText(txt, [
      { pr: 1, re: urlRe, with: urlReplaceInCode },
      { pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) },
      { pr: 1, re: /\B@([\w-]+)\b/gi, with: '<a href="/$1">@$1</a>' },
    ])
    : formatText(txt, [
      { pr: 0, re: /((?:^(?:>|&gt;)\s?[\s\S]+?$\n?)+)/gmi, brackets: true, with: ['<q>', '</q>', bqReplace] },
      { pr: 1, re: urlRe, with: urlReplace },
      { pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) },
      { pr: 1, re: /\B@([\w-]+)\b/gi, with: '<a href="/$1">@$1</a>' },
      { pr: 2, re: /\B\*([^\n]+?)\*((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<b>', '</b>'] },
      { pr: 2, re: /\B\/([^\n]+?)\/((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<i>', '</i>'] },
      { pr: 2, re: /\b\_([^\n]+?)\_((?=\s)|(?=$)|(?=[!\"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<span class="u">', '</span>'] },
      { pr: 3, re: /\n/g, with: '<br/>' },
    ]);
}

function getEmbeddableLinkTypes() {
  return [
    {
      name: 'Jpeg and png images',
      id: 'embed_jpeg_and_png_images',
      className: 'picture compact',
      ctsDefault: false,
      re: /\.(jpe?g|png|svg)(:[a-zA-Z]+)?(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>`;
        return div;
      }
    },
    {
      name: 'Gif images',
      id: 'embed_gif_images',
      className: 'picture compact',
      ctsDefault: true,
      re: /\.gif(:[a-zA-Z]+)?(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>`;
        return div;
      }
    },
    {
      name: 'Video (webm, mp4, ogv)',
      id: 'embed_webm_and_mp4_videos',
      className: 'video compact',
      ctsDefault: false,
      re: /\.(webm|mp4|m4v|ogv)(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<video src="${aNode.href}" title="${aNode.href}" controls></video>`;
        return div;
      }
    },
    {
      name: 'Audio (mp3, ogg, weba, opus, m4a, oga, wav)',
      id: 'embed_sound_files',
      className: 'audio singleColumn',
      ctsDefault: false,
      re: /\.(mp3|ogg|weba|opus|m4a|oga|wav)(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        div.innerHTML = `<audio src="${aNode.href}" title="${aNode.href}" controls></audio>`;
        return div;
      }
    },
    {
      name: 'YouTube videos (and playlists)',
      id: 'embed_youtube_videos',
      className: 'youtube resizableV singleColumn',
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.|m\.|gaming\.)?(?:youtu(?:(?:\.be\/|be\.com\/(?:v|embed)\/)([-\w]+)|be\.com\/watch)((?:(?:\?|&(?:amp;)?)(?:\w+=[-\.\w]*[-\w]))*)|youtube\.com\/playlist\?list=([-\w]*)(&(amp;)?[-\w\?=]*)?)/i,
      makeNode: function(aNode, reResult, div) {
        let [url, v, args, plist] = reResult;
        let iframeUrl;
        if (plist) {
          iframeUrl = '//www.youtube-nocookie.com/embed/videoseries?list=' + plist;
        } else {
          let pp = {}; args.replace(/^\?/, '')
                           .split('&')
                           .map(s => s.split('='))
                           .forEach(z => pp[z[0]] = z[1]);
          let embedArgs = {
            rel: '0',
            enablejsapi: '1',
            origin: `${window.location.protocol}//${window.location.host}`
          };
          if (pp.t) {
            const tre = /^(?:(\d+)|(?:(\d+)h)?(?:(\d+)m)?(\d+)s|(?:(\d+)h)?(\d+)m|(\d+)h)$/i;
            let [, t, h, m, s, h1, m1, h2] = tre.exec(pp.t);
            embedArgs['start'] = (+t) || ((+(h || h1 || h2 || 0))*60*60 + (+(m || m1 || 0))*60 + (+(s || 0)));
          }
          if (pp.list) {
            embedArgs['list'] = pp.list;
          }
          v = v || pp.v;
          let argsStr = Object.keys(embedArgs)
                              .map(k => `${k}=${embedArgs[k]}`)
                              .join('&');
          iframeUrl = `//www.youtube-nocookie.com/embed/${v}?${argsStr}`;
        }
        let iframe = makeIframe(iframeUrl, '100%', '360px');
        iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
        return setContent(div, iframe);
      }
    },
    {
      name: 'Vimeo videos',
      id: 'embed_vimeo_videos',
      className: 'vimeo resizableV',
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?(?:player\.)?vimeo\.com\/(?:video\/|album\/[\d]+\/video\/)?([\d]+)/i,
      makeNode: function(aNode, reResult, div) {
        let iframe = makeIframe('//player.vimeo.com/video/' + reResult[1], '100%', '360px');
        iframe.onload = () => makeResizableToRatio(iframe, 9.0 / 16.0);
        return setContent(div, iframe);
      }
    },
    {
      name: 'Twitter',
      id: 'embed_twitter_status',
      className: 'twi compact',
      ctsDefault: false,
      re: /^(?:https?:)?\/\/(?:www\.)?(?:mobile\.)?twitter\.com\/([\w-]+)\/status(?:es)?\/([\d]+)/i,
      makeNode: function(aNode, reResult, div) {
        let [url, userId, postId] = reResult;
        url = url.replace('mobile.','');

        div.innerHTML = `<blockquote class="twitter-tweet" data-lang="en"><a href="${url}"></a></blockquote>`;

        if (window.twttr) {
          // https://developer.twitter.com/en/docs/twitter-for-websites/javascript-api/guides/scripting-loading-and-initialization
          window.twttr.widgets.load(div);
        } else {
          // innerHTML cannot insert scripts, so...
          let script = document.createElement('script');
          script.src = 'https://platform.twitter.com/widgets.js';
          script.charset = 'utf-8';
          div.appendChild(script);
        }

        return div;
      }
    }
  ];
}

function embedLink(aNode, linkTypes, container, afterNode) {
  let anyEmbed = false;
  let linkId = (aNode.href.replace(/^https?:/i, '').replace(/\'/gi,''));
  let sameEmbed = container.querySelector(`*[data-linkid='${linkId}']`); // do not embed the same thing twice
  if (sameEmbed === null) {
    anyEmbed = [].some.call(linkTypes, function(linkType) {
        let reResult = linkType.re.exec(aNode.href);
        if (reResult) {
          if (linkType.match && (linkType.match(aNode, reResult) === false)) { return false; }
          let newNode = makeNewNode(linkType, aNode, reResult);
          if (!newNode) { return false; }
          newNode.setAttribute('data-linkid', linkId);
          if (afterNode) {
            insertAfter(newNode, afterNode);
          } else {
            container.appendChild(newNode);
          }
          aNode.classList.add('embedLink');
          return true;
        }
    });
  }
  return anyEmbed;
}

function embedLinks(aNodes, container) {
  let anyEmbed = false;
  let embeddableLinkTypes = getEmbeddableLinkTypes();
  Array.from(aNodes).forEach(aNode => {
    let isEmbedded = embedLink(aNode, embeddableLinkTypes, container);
    anyEmbed = anyEmbed || isEmbedded;
  });
  return anyEmbed;
}

/**
 * Embed all the links inside element "x" that match to "allLinksSelector".
 * All the embedded media is placed inside "div.embedContainer".
 * "div.embedContainer" is inserted before an element matched by "beforeNodeSelector"
 * if not present. Existing container is used otherwise.
 *
 * @param {Element} x
 * @param {string} beforeNodeSelector
 * @param {string} allLinksSelector
 */
export function embedLinksToX(x, beforeNodeSelector, allLinksSelector) {
  let isCtsPost = false;
  let allLinks = x.querySelectorAll(allLinksSelector);

  let existingContainer = x.querySelector('div.embedContainer');
  if (existingContainer) {
    embedLinks(allLinks, existingContainer);
  } else {
    let embedContainer = document.createElement('div');
    embedContainer.className = 'embedContainer';

    let anyEmbed = embedLinks(allLinks, embedContainer);
    if (anyEmbed) {
      let beforeNode = x.querySelector(beforeNodeSelector);
      x.insertBefore(embedContainer, beforeNode);
    }
  }
}

function embedLinksToArticles() {
  let beforeNodeSelector = 'nav.l';
  let allLinksSelector = 'p:not(.ir) a, pre a';
  Array.from(document.querySelectorAll('#content article')).forEach(article => {
    embedLinksToX(article, beforeNodeSelector, allLinksSelector);
  });
}

function embedLinksToPost() {
  let beforeNodeSelector = '.msg-txt + *';
  let allLinksSelector = '.msg-txt a';
  Array.from(document.querySelectorAll('#content .msg-cont')).forEach(msg => {
    embedLinksToX(msg, beforeNodeSelector, allLinksSelector);
  });
}

export function embedUrls(urls, embedContainer) {
  embedLinks(urls, embedContainer);
}

/**
 * Embed all the links in all messages/replies on the page.
 */
export function embedAll() {
  if (document.querySelector('#content article[data-mid]')) {
    embedLinksToArticles();
  } else {
    embedLinksToPost();
  }
}

export const format = juickFormat;