diff options
Diffstat (limited to 'vnext/src')
-rw-r--r-- | vnext/src/components/Message.js | 5 | ||||
-rw-r--r-- | vnext/src/utils/embed.js | 336 |
2 files changed, 339 insertions, 2 deletions
diff --git a/vnext/src/components/Message.js b/vnext/src/components/Message.js index d1a9c51f0..53c2a033a 100644 --- a/vnext/src/components/Message.js +++ b/vnext/src/components/Message.js @@ -1,12 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ReactMarkdown from 'react-markdown'; import moment from 'moment'; import { UserType } from './Types'; import Icon from './Icon'; import Avatar from './Avatar'; +import { format } from '../utils/embed'; + export default function Message(props) { const msg = props.data; const visitor = props.visitor; @@ -29,7 +30,7 @@ export default function Message(props) { <Tags data={msg.tags || []} /> </div> </header> - <ReactMarkdown itemProp="description" source={msg.body} /> + <p itemProp="description" dangerouslySetInnerHTML={{ __html: format(msg.body, msg.mid, (msg.tags || []).indexOf('code') >= 0) }}></p> {msg.photo && <p className="ir"><a href={`//i.juick.com/p/${msg.mid}.${msg.attach}`} data-fname={`${msg.mid}.${msg.attach}`}> <img itemProp="image" src={`//i.juick.com/p/${msg.mid}.${msg.attach}`} alt="" /></a> diff --git a/vnext/src/utils/embed.js b/vnext/src/utils/embed.js new file mode 100644 index 000000000..25c371422 --- /dev/null +++ b/vnext/src/utils/embed.js @@ -0,0 +1,336 @@ + +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.dataset['ratio'] = ratio; + makeResizable(element, w => w * element.dataset['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 + * @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(/^(?:>|>)\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: /((?:^(?:>|>)\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' }; + 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); + } + } + ]; +} + +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 + */ +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); + }); +} + +/** + * Embed all the links in all messages/replies on the page. + */ +function embedAll() { + if (document.querySelector('#content article[data-mid]')) { + embedLinksToArticles(); + } else { + embedLinksToPost(); + } +} + +exports.embedAll = embedAll; +exports.embedLinksToX = embedLinksToX; +exports.format = juickFormat; |