import { flattenDeep, unescape as ldUnescape } from 'lodash' import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue' import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js' import MentionsLine from 'src/components/mentions_line/mentions_line.vue' import StillImage from 'src/components/still-image/still-image.vue' import StillImageEmojiPopover from 'src/components/still-image/still-image-emoji-popover.vue' import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js' import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js' import { getAttrs, getTagName, processTextForEmoji, } from 'src/services/html_converter/utility.service.js' import './rich_content.scss' const MAYBE_LINE_BREAKING_ELEMENTS = [ 'blockquote', 'br', 'hr', 'ul', 'ol', 'li', 'p', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', ] /** * RichContent, The Über-powered component for rendering Post HTML. * * This takes post HTML and does multiple things to it: * - Groups all mentions into , this affects all mentions regardles * of where they are (beginning/middle/end), even single mentions are converted * to a containing single . * - Replaces emoji shortcodes with 'd images. * * There are two problems with this component's architecture: * 1. Parsing HTML and rendering are inseparable. Attempts to separate the two * proven to be a massive overcomplication due to amount of things done here. * 2. We need to output both render and some extra data, which seems to be imp- * possible in vue. Current solution is to emit 'parseReady' event when parsing * is done within render() function. * * Apart from that one small hiccup with emit in render this _should_ be vue3-ready */ export default { name: 'RichContent', components: { MentionsLine, HashtagLink, }, props: { // Original html content html: { required: true, type: String, }, attentions: { required: false, default: () => [], }, // Emoji object, as in status.emojis, note the "s" at the end... emoji: { required: true, type: Array, }, // Whether to handle links or not (posts: yes, everything else: no) handleLinks: { required: false, type: Boolean, default: false, }, // Meme arrows greentext: { required: false, type: Boolean, default: false, }, // Faint style (for notifs) faint: { required: false, type: Boolean, default: false, }, // Collapse newlines collapse: { required: false, type: Boolean, default: false, }, /* Content comes from current instance * * This is used for emoji stealing popover. * By default we assume it is, so that steal * emoji button isn't shown where it probably * should not be. */ isLocal: { required: false, type: Boolean, default: true, }, // Allow wide emoji (max 3:1 ratio) allowNonSquareEmoji: { required: false, type: Boolean, default: false, }, pauseMfm: { required: false, type: Boolean, default: false, }, scaleMfm: { required: false, type: Boolean, default: false, }, }, // NEVER EVER TOUCH DATA INSIDE RENDER render() { // Pre-process HTML const { newHtml: html } = preProcessPerLine(this.html, this.greentext) let currentMentions = null // Current chain of mentions, we group all mentions together // This is used to recover spacing removed when parsing mentions let lastSpacing = '' const lastTags = [] // Tags that appear at the end of post body const writtenMentions = [] // All mentions that appear in post body const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine) // to collapse too many mentions in a row const writtenTags = [] // All tags that appear in post body // unique index for vue "tag" property let mentionIndex = 0 let tagsIndex = 0 const renderImage = (tag) => { return } const renderHashtag = (attrs, children, encounteredTextReverse) => { const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++) writtenTags.push(linkData) if (!encounteredTextReverse) { lastTags.push(linkData) } const { url, tag, content } = linkData return } const renderMention = (attrs, children) => { const linkData = getLinkData(attrs, children, mentionIndex++) linkData.notifying = this.attentions.some( (a) => a.statusnet_profile_url === linkData.url, ) writtenMentions.push(linkData) if (currentMentions === null) { currentMentions = [] } currentMentions.push(linkData) if (currentMentions.length > MENTIONS_LIMIT) { invisibleMentions.push(linkData) } if (currentMentions.length === 1) { return } else { return '' } } // Processor to use with html_tree_converter const processItem = (item, index, array) => { // Handle text nodes - just add emoji if (typeof item === 'string') { const emptyText = item.trim() === '' if (item.includes('\n')) { currentMentions = null } if (emptyText) { // don't include spaces when processing mentions - we'll include them // in MentionsLine lastSpacing = item // Don't remove last space in a container (fixes poast mentions) return index !== array.length - 1 && currentMentions !== null ? item.trim() : item } currentMentions = null if (item.includes(':')) { item = [ '', processTextForEmoji(item, this.emoji, ({ shortcode, url }) => { return ( ) }), ] } return item } // Handle tag nodes if (Array.isArray(item)) { const [opener, children, closer] = item let Tag = getTagName(opener) if (Tag.toLowerCase() === 'script') Tag = 'js-exploit' if (Tag.toLowerCase() === 'style') Tag = 'css-exploit' const fullAttrs = getAttrs(opener, () => true) const attrs = getAttrs(opener) const previouslyMentions = currentMentions !== null /* During grouping of mentions we trim all the empty text elements * This padding is added to recover last space removed in case * we have a tag right next to mentions */ const mentionsLinePadding = // Padding is only needed if we just finished parsing mentions previouslyMentions && // Don't add padding if content is string and has padding already !( children && typeof children[0] === 'string' && children[0].match(/^\s/) ) ? lastSpacing : '' if (MAYBE_LINE_BREAKING_ELEMENTS.includes(Tag)) { // all the elements that can cause a line change currentMentions = null } else if (Tag === 'img') { // replace images with StillImage return ['', [mentionsLinePadding, renderImage(opener)], ''] } else if (Tag === 'a' && this.handleLinks) { // replace mentions with MentionLink if (fullAttrs.class?.includes('mention')) { // Handling mentions here return renderMention(attrs, children) } else { currentMentions = null } } else if (Tag === 'span') { if (this.handleLinks && fullAttrs.class?.includes('h-card')) { return ['', children.map(processItem), ''] } } if (children !== undefined) { return [ '', [mentionsLinePadding, [opener, children.map(processItem), closer]], '', ] } else { return ['', [mentionsLinePadding, item], ''] } } } // Processor for back direction (for finding "last" stuff, just easier this way) let encounteredTextReverse = false const processItemReverse = (item, index, array) => { // Handle text nodes - just add emoji if (typeof item === 'string') { const emptyText = item.trim() === '' if (emptyText) return item if (!encounteredTextReverse) encounteredTextReverse = true return ldUnescape(item) } else if (Array.isArray(item)) { // Handle tag nodes const [opener, children] = item const Tag = opener === '' ? '' : getTagName(opener) switch (Tag) { case 'a': { // replace mentions with MentionLink if (!this.handleLinks) break const fullAttrs = getAttrs(opener, () => true) const attrs = getAttrs(opener, () => true) // should only be this if ( fullAttrs.class?.includes('hashtag') || // Pleroma style fullAttrs.rel === 'tag' // Mastodon style ) { return renderHashtag(attrs, children, encounteredTextReverse) } else { attrs.target = '_blank' const newChildren = [...children] .reverse() .map(processItemReverse) .reverse() return {newChildren} } } case '': return [...children].reverse().map(processItemReverse).reverse() } // Render tag as is if (children !== undefined) { const newChildren = Array.isArray(children) ? [...children].reverse().map(processItemReverse).reverse() : children const attrs = getAttrs(opener) const newAttrs = { ...attrs } const fullAttrs = getAttrs(opener, () => true) const classname = fullAttrs['class'] const isMFM = classname?.startsWith('mfm-') if (isMFM) { const mfmOperator = /^mfm-(\w+)$/.exec(classname)?.[1] newAttrs['class'] = [ 'mfm', this.pauseMfm ? '-pause' : '', this.scaleMfm ? '-scale' : '', ] .filter(Boolean) .join(' ') newAttrs['data-mfm-operator'] = mfmOperator switch (mfmOperator) { case 'position': { const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 0 const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 0 newAttrs.style = [ 'transform:', `translate(calc(${x} * (var(--emoji-size) / 2)), `, `calc(${y} * (var(--emoji-size) / 2)))`, ].join(' ') break } case 'scale': { const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 1 const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 1 newAttrs.style = ['transform:', `scale(${x}, ${y})`].join(' ') break } case 'rotate': { const deg = Number.parseFloat(fullAttrs['data-mfm-deg']) || 0 newAttrs.style = [ `transform: rotate(${deg}deg)`, 'transform-origin: center', ].join(';') break } case 'bg': { const color = fullAttrs['data-mfm-color'] || 0 newAttrs.style = [`background-color: #${color}`].join(' ') break } case 'fg': { const color = fullAttrs['data-mfm-color'] || 0 newAttrs.style = [`color: #${color}`].join(';') break } case 'spin': { const speed = fullAttrs['data-mfm-speed'] || '1s' const delay = fullAttrs['data-mfm-delay'] || 0 const left = fullAttrs['data-mfm-left'] != null const alternate = fullAttrs['data-mfm-alternate'] != null const y = fullAttrs['data-mfm-y'] != null const x = fullAttrs['data-mfm-x'] != null const anim = [ x ? 'mfm-spinX' : null, y ? 'mfm-spinY' : null, 'mfm-spin', ].filter((a) => a)[0] const direction = [ alternate ? 'alternate' : null, left ? 'reverse' : null, 'normal', ].filter((a) => a)[0] newAttrs.style = [ `animation-name: ${anim}`, `animation-duration: ${speed}`, 'animation-iteration-count: infinite', `animation-delay: ${delay}`, `animation-direction: ${direction}`, 'animation-fill-mode: none', 'animation-timing-function: linear', ].join(';') break } case 'flip': { newAttrs.style = 'transform: scaleX(-1)' break } case 'border': { const width = fullAttrs['data-mfm-width'] || '0' const style = fullAttrs['data-mfm-style'] || 'solid' const color = fullAttrs['data-mfm-color'] || 'transparent' const radius = fullAttrs['data-mfm-radius'] || '0' const noclip = fullAttrs['data-mfm-noclip'] || false newAttrs.style = [ `border: ${width} ${style} ${color}`, `border-radius: ${radius}`, `overflow: ${noclip ? 'visible' : 'clip'}`, ].join(';') break } case 'tada': case 'jelly': case 'twitch': case 'shake': case 'jump': case 'bounce': case 'rainbow': { const speed = fullAttrs['data-mfm-speed'] || '1s' const delay = fullAttrs['data-mfm-delay'] || 0 const rules = [ `animation-name: mfm-${mfmOperator}`, `animation-duration: ${speed}`, 'animation-iteration-count: infinite', `animation-delay: ${delay}`, 'animation-direction: normal', 'animation-fill-mode: none', 'animation-timing-function: linear', ].join(';') newAttrs.style = rules break } case 'sparkle': case 'x2': case 'x3': case 'x4': // handled by css break default: console.warn('Unsupported MFM operator:', mfmOperator, opener) break } } return {newChildren} } else { return } } return item } const pass1 = convertHtmlToTree(html).map(processItem) const pass2 = [...pass1].reverse().map(processItemReverse).reverse() // DO NOT USE SLOTS they cause a re-render feedback loop here. // slots updated -> rerender -> emit -> update up the tree -> rerender -> ... // at least until vue3? const result = ( {this.collapse ? pass2.map((x) => { if (typeof x === 'string') return x.replaceAll('\n', ' ') if (!Array.isArray(x)) return x return x.map((y) => (y.type === 'br' ? ' ' : y)) }) : pass2} ) const event = { lastTags, writtenMentions, writtenTags, invisibleMentions, } // DO NOT MOVE TO UPDATE. BAD IDEA. this.$emit('parseReady', event) return result }, } const getLinkData = (attrs, children, index) => { const stripTags = (item) => { if (typeof item === 'string') { return item } else { return item[1].map(stripTags).join('') } } const textContent = children.map(stripTags).join('') return { index, url: attrs.href, tag: attrs['data-tag'], content: flattenDeep(children).join(''), textContent, } } /** Pre-processing HTML * * Currently this does one thing: * - add green/cyantexting * * @param {String} html - raw HTML to process * @param {Boolean} greentext - whether to enable greentexting or not */ export const preProcessPerLine = (html, greentext) => { const greentextHandle = new Set(['p', 'div']) const lines = convertHtmlToLines(html) const newHtml = lines .reverse() .map((item, index, array) => { if (!item.text) return item const string = item.text // Greentext stuff if ( // Only if greentext is engaged greentext && // Only handle p's and divs. Don't want to affect blockquotes, code etc item.level.every((l) => greentextHandle.has(l)) && // Only if line begins with '>' or '<' (string.includes('>') || string.includes('<')) ) { const cleanedString = string .replaceAll(/<[^>]+?>/gi, '') // remove all tags .replaceAll(/@\w+/gi, '') // remove mentions (even failed ones) .trim() if (cleanedString.startsWith('>')) { return `${string}` } else if (cleanedString.startsWith('<')) { return `${string}` } } return string }) .reverse() .join('') return { newHtml } }