function htmlEscape(html) { return html.replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>') } 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) { fromNode.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 let result = domainRe.exec(url) || [] if (result.length > 0) { return result[1] } } function urlReplace(match, p1, p2, p3) { let isBrackets = (p1 !== undefined) return (isBrackets) ? `${p1}` : `${extractDomain(match)}` } function urlReplaceInCode(match, p1, p2, p3) { let isBrackets = (p1 !== undefined) return (isBrackets) ? `${match}` : `${match}` } function messageReplyReplace(messageId) { return function(match, mid, rid) { let replyPart = (rid && rid != '0') ? '#' + rid : '' return `${match}` } } /** * 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\.)(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[-\S+*&@#/%=~|$?!:;,.])*(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[\S+*&@#/%=~|$]))\))|\b(?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[-\S+*&@#/%=~|$?!:;,.])*(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[\S+*&@#/%=~|$]))/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: '@$1' }, ]) : formatText(txt, [ { pr: 0, re: /((?:^(?:>|>)\s?[\s\S]+?$\n?)+)/gmi, brackets: true, with: ['', '', 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: '@$1' }, { pr: 2, re: /\B\*([^\n]+?)\*((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['', ''] }, { pr: 2, re: /\B\/([^\n]+?)\/((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['', ''] }, { pr: 2, re: /\b_([^\n]+?)_((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['', ''] }, { pr: 3, re: /\n/g, with: '
' }, ]) } /** * @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 = `` 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 = `` 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 = `` 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 = `` 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 [, v, args, plist] = reResult let iframeUrl if (plist) { iframeUrl = 'https://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 = `https://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|x)\.com\/([\w-]+)\/status(?:es)?\/([\d]+)/i, makeNode: function(aNode, reResult, div) { const wrong_prefix = 'https://x.com' const correct_prefix = 'https://twitter.com' const twitter_url = reResult[0].startsWith(wrong_prefix) ? reResult[0].replace(wrong_prefix, correct_prefix) : reResult[0] fetch('https://x.juick.com/oembed?url=' + twitter_url) .then(response => response.json()) .then(json => { div.innerHTML = json.html }).catch(console.log) 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 [, postId] = reResult let mediaUrl = `https://instagr.am${postId}/media` div.innerHTML = `` return div } }, { name: 'Telegram posts', id: 'embed_telegram_posts', className: 'tg compact', re: /https?:\/\/t\.me\/(\S+)/i, makeNode: function(aNode, reResult, div) { let [, 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).every(aNode => { let isEmbedded = embedLink(aNode, embeddableLinkTypes, container) // stop on first embedded link anyEmbed = anyEmbed || isEmbedded return !(anyEmbed) }) return anyEmbed } /** * Embed first link from supplied 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 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 => { let hasMedia = msg.querySelector('.msg-media') || msg.querySelector('.ir') if (!hasMedia) { embedLinksToX(msg, beforeNodeSelector, allLinksSelector) } }) } /** * Embed URLs to container * @param {Element[]} urls * @param {HTMLDivElement} embedContainer */ export function embedUrls(urls, embedContainer) { embedLinks(urls, embedContainer) } export const format = juickFormat