aboutsummaryrefslogblamecommitdiff
path: root/src/main/assets/embed.js
blob: 0ef76bc2dfed4399abf1a123ca8f6ce222ed86a2 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11









                                                                            
                                                                   















                                                                                        
                                                                              




                                                                                              
                                                                                                                    




















                                                                                          
                                                  

                                                
                           






                                               
                                                                      









































                                                                               


                                                              



















                                                                                                                                                                                                                                                                                                                                   



















                                                                                                 
 



                                                        




                                      
                                                              

                                                                                                 





                                   








                                                                                








                                                                                             








                                                                                             






                                                                                                                                                                                                                                  

                                           



                                                                          

                                                                                             
                                                                                                                    




                                              
                                             









                                                                         




                                                                                                           



                                 
                                                                                                    
                                                                     


                                            
                   



                                   






                                                                              


                                 












                                                                     

    








                                                                              
                       
                                                                         
                                                                                                             
                                             








                                                                                            
         

                                         























                                                                                     
                                                                        
















                                                                

                                                           
                            
                                          


                                                                              
 
                                  
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="/${mid || messageId}${replyPart}">${match}</a>`;
  };
}

/**
 * Given "txt" message in unescaped plaintext with Juick markup, this function
 * returns escaped formatted HTML string.
 *
 * @param {string} txt text message
 * @param {string} messageId current message id
 * @param {boolean} isCode set when message contains *code tag
 * @returns {string} formatted message
 */
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/>' },
    ]);
}
/**
 * @external RegExpExecArray
 */

/**
 * @callback MakeNodeCallback
 * @param { HTMLAnchorElement } aNode a DOM node of the link
 * @param { RegExpExecArray } reResult Result of RegExp execution
 * @param { HTMLDivElement} div target DOM element which can be updated by callback function
 * @returns { HTMLDivElement } updated DOM element
 */

/**
 * @typedef { object } LinkFormatData
 * @property { string } id Format identifier
 * @property { string } name Format description
 * @property { RegExp } re Regular expression to match expected hyperlinks
 * @property { string } className list of CSS classes which
 *   will be added to the target DOM element
 * @property { MakeNodeCallback } makeNode callback function called when a target link is matched
 */

/**
 * Get supported embeddable formats
 *
 * @returns {LinkFormatData[]} list of supported formats
 */
function getEmbeddableLinkTypes() {
  return [
    {
      name: 'Jpeg and png images',
      id: 'embed_jpeg_and_png_images',
      className: 'picture compact',
      re: /\.(jpe?g|png|svg)(:[a-zA-Z]+)?(?:\?[\w&;\?=]*)?$/i,
      makeNode: function(aNode, reResult, div) {
        // dirty fix for dropbox urls
        let url = aNode.href.endsWith('dl=0') ? aNode.href.replace('dl=0', 'raw=1') : aNode.href;
        div.innerHTML = `<a href="${url}"><img src="${url}"></a>`;
        return div;
      }
    },
    {
      name: 'Gif images',
      id: 'embed_gif_images',
      className: 'picture compact',
      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',
      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',
      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',
      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',
      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',
      re: /^(?:https?:)?\/\/(?:www\.)?(?:mobile\.)?twitter\.com\/([\w-]+)\/status(?:es)?\/([\d]+)/i,
      makeNode: function(aNode, reResult, div) {
        fetch('https://beta.juick.com/api/oembed?url=' + reResult[0])
          .then(response => response.json())
          .then(json => {
            div.innerHTML = json.html;
          });
        return div;
      }
    },
    {
      name: 'Instagram media',
      id: 'embed_instagram_images',
      className: 'picture compact',
      re: /https?:\/\/www\.?instagram\.com(\/p\/\w+)\/?/i,
      makeNode: function(aNode, reResult, div) {
        let [url, postId] = reResult;
        let mediaUrl = `https://instagr.am${postId}/media`;
        div.innerHTML = `<a href="${aNode.href}"><img src="${mediaUrl}"></a>`;
        return div;
      }
    },
    {
      name: 'Telegram posts',
      id: 'embed_telegram_posts',
      className: 'tg compact',
      re: /https?:\/\/t\.me\/(\S+)/i,
      makeNode: function(aNode, reResult, div) {
        let [url, post] = reResult;
        // innerHTML cannot insert scripts, so...
        let script = document.createElement('script');
        script.src = 'https://telegram.org/js/telegram-widget.js?18';
        script.setAttribute('data-telegram-post', post);
        script.setAttribute('data-tme-mode', 'data-tme-mode');
        script.setAttribute('data-width', '100%');
        script.charset = 'utf-8';
        div.appendChild(script);
        return div;
      }
    },
  ];
}

/**
 * Embed a link
 *
 * @param { HTMLAnchorElement } aNode a DOM node of the link
 * @param { LinkFormatData[] } linkTypes supported link types
 * @param { HTMLElement } container a target DOM element with the link content
 * @param { boolean } afterNode where to insert new DOM node
 * @returns { boolean } `true` when some link was embedded
 */
function embedLink(aNode, linkTypes, container, afterNode = false) {
  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) {
    anyEmbed = linkTypes.some((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);
    }
  }
}

/**
 * Embed all the links in all messages/replies on the page.
 */
export function embedAll() {
  let beforeNodeSelector = '.msg-txt + *';
  let allLinksSelector = '.msg-txt a';
  Array.from(document.querySelectorAll('#content .msg-cont')).forEach(msg => {
    embedLinksToX(msg, beforeNodeSelector, allLinksSelector);
  });
}

export const format = juickFormat;