pleroma-fe/src/components/rich_content/rich_content.jsx

566 lines
18 KiB
React
Raw Normal View History

2026-01-06 17:32:22 +02:00
import { flattenDeep, unescape as ldUnescape } from 'lodash'
2026-01-08 17:26:52 +02:00
2026-01-06 16:23:17 +02:00
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'
2026-01-06 17:32:22 +02:00
import StillImage from 'src/components/still-image/still-image.vue'
2026-01-06 16:23:17 +02:00
import StillImageEmojiPopover from 'src/components/still-image/still-image-emoji-popover.vue'
2026-01-29 20:40:00 +02:00
2026-01-06 16:23:17 +02:00
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
2026-01-06 16:22:52 +02:00
import {
2026-01-06 16:23:17 +02:00
getAttrs,
2026-01-06 16:22:52 +02:00
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',
2026-01-06 16:22:52 +02:00
'h5',
]
2021-06-12 20:42:17 +03:00
/**
* RichContent, The Über-powered component for rendering Post HTML.
*
* This takes post HTML and does multiple things to it:
* - Groups all mentions into <MentionsLine>, this affects all mentions regardles
* of where they are (beginning/middle/end), even single mentions are converted
* to a <MentionsLine> containing single <MentionLink>.
2021-06-12 20:42:17 +03:00
* - Replaces emoji shortcodes with <StillImage>'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
*/
2022-03-16 22:13:21 +02:00
export default {
name: 'RichContent',
components: {
MentionsLine,
2026-01-06 16:22:52 +02:00
HashtagLink,
},
props: {
// Original html content
html: {
required: true,
2026-01-06 16:22:52 +02:00
type: String,
},
attentions: {
required: false,
2026-01-06 16:22:52 +02:00
default: () => [],
},
// Emoji object, as in status.emojis, note the "s" at the end...
emoji: {
required: true,
2026-01-06 16:22:52 +02:00
type: Array,
},
// Whether to handle links or not (posts: yes, everything else: no)
handleLinks: {
required: false,
type: Boolean,
2026-01-06 16:22:52 +02:00
default: false,
},
// Meme arrows
greentext: {
required: false,
type: Boolean,
2026-01-06 16:22:52 +02:00
default: false,
},
// Faint style (for notifs)
faint: {
required: false,
type: Boolean,
2026-01-06 16:22:52 +02:00
default: false,
2025-08-19 16:35:40 +03:00
},
// Collapse newlines
collapse: {
required: false,
type: Boolean,
2026-01-06 16:22:52 +02:00
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.
*/
2025-08-06 22:24:49 +03:00
isLocal: {
required: false,
type: Boolean,
2026-01-06 16:22:52 +02:00
default: true,
},
2026-05-10 17:21:49 +03:00
// Allow wide emoji (max 3:1 ratio)
allowNonSquareEmoji: {
required: false,
type: Boolean,
default: false,
},
2026-07-07 11:40:37 +03:00
pauseMfm: {
required: false,
type: Boolean,
default: false,
},
scaleMfm: {
required: false,
type: Boolean,
default: false,
},
},
// NEVER EVER TOUCH DATA INSIDE RENDER
2026-01-06 16:22:52 +02:00
render() {
// Pre-process HTML
2022-02-03 23:13:28 +02:00
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
2021-08-18 20:54:04 +03:00
// 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) => {
2026-01-06 16:22:52 +02:00
return <StillImage {...getAttrs(tag)} class="img" />
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
2022-03-22 16:40:45 +02:00
const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
const { url, tag, content } = linkData
2026-01-06 16:22:52 +02:00
return <HashtagLink url={url} tag={tag} content={content} />
}
const renderMention = (attrs, children) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
2026-01-06 16:22:52 +02:00
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) {
2026-01-06 16:22:52 +02:00
return <MentionsLine mentions={currentMentions} />
} else {
return ''
}
2021-06-07 16:16:10 +03:00
}
// Processor to use with html_tree_converter
2021-06-10 18:52:01 +03:00
const processItem = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
2021-06-08 13:42:16 +03:00
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
2021-06-08 11:38:44 +03:00
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
2021-08-18 20:54:04 +03:00
lastSpacing = item
2022-02-03 22:23:28 +02:00
// Don't remove last space in a container (fixes poast mentions)
2026-01-06 16:22:52 +02:00
return index !== array.length - 1 && currentMentions !== null
? item.trim()
: item
2021-06-08 11:38:44 +03:00
}
2021-08-15 18:11:38 +03:00
currentMentions = null
if (item.includes(':')) {
2026-01-06 16:22:52 +02:00
item = [
'',
processTextForEmoji(item, this.emoji, ({ shortcode, url }) => {
return (
<StillImageEmojiPopover
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
shortcode={shortcode}
isLocal={this.isLocal}
/>
)
}),
]
}
2021-06-18 21:42:46 +03:00
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
2021-06-15 14:43:44 +03:00
const [opener, children, closer] = item
let Tag = getTagName(opener)
2023-06-05 21:57:36 +03:00
if (Tag.toLowerCase() === 'script') Tag = 'js-exploit'
if (Tag.toLowerCase() === 'style') Tag = 'css-exploit'
2022-11-27 00:11:54 +02:00
const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener)
2021-08-18 20:54:04 +03:00
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 =
2026-01-06 16:22:52 +02:00
// 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
2026-01-06 16:22:52 +02:00
} else if (Tag === 'img') {
// replace images with StillImage
return ['', [mentionsLinePadding, renderImage(opener)], '']
2026-01-06 16:22:52 +02:00
} else if (Tag === 'a' && this.handleLinks) {
// replace mentions with MentionLink
if (fullAttrs.class && fullAttrs.class.includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
currentMentions = null
}
} else if (Tag === 'span') {
2026-01-06 16:22:52 +02:00
if (
this.handleLinks &&
fullAttrs.class &&
fullAttrs.class.includes('h-card')
) {
return ['', children.map(processItem), '']
}
}
2021-06-16 01:20:20 +03:00
if (children !== undefined) {
2021-08-18 20:54:04 +03:00
return [
2021-08-23 21:36:18 +03:00
'',
2026-01-06 16:22:52 +02:00
[mentionsLinePadding, [opener, children.map(processItem), closer]],
'',
2021-08-18 20:54:04 +03:00
]
} else {
2021-08-18 20:54:04 +03:00
return ['', [mentionsLinePadding, item], '']
}
}
}
2021-06-10 18:52:01 +03:00
// Processor for back direction (for finding "last" stuff, just easier this way)
let encounteredTextReverse = false
const processItemReverse = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
2021-06-12 17:20:21 +03:00
if (emptyText) return item
2021-06-10 18:52:01 +03:00
if (!encounteredTextReverse) encounteredTextReverse = true
2026-01-06 17:32:22 +02:00
return ldUnescape(item)
2021-06-10 18:52:01 +03:00
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
2021-06-15 14:43:44 +03:00
const Tag = opener === '' ? '' : getTagName(opener)
2021-06-10 18:52:01 +03:00
switch (Tag) {
2026-01-06 16:22:52 +02:00
case 'a': {
// replace mentions with MentionLink
2021-06-10 18:52:01 +03:00
if (!this.handleLinks) break
2022-11-27 00:11:54 +02:00
const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener, () => true)
2021-06-10 18:52:01 +03:00
// should only be this
if (
2022-11-27 00:11:54 +02:00
(fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style
2026-01-06 16:22:52 +02:00
fullAttrs.rel === 'tag' // Mastodon style
) {
2021-06-10 18:52:01 +03:00
return renderHashtag(attrs, children, encounteredTextReverse)
2021-06-16 01:20:20 +03:00
} else {
attrs.target = '_blank'
2026-01-06 16:22:52 +02:00
const newChildren = [...children]
.reverse()
.map(processItemReverse)
.reverse()
2021-06-16 01:20:20 +03:00
2026-01-06 16:22:52 +02:00
return <a {...attrs}>{newChildren}</a>
2021-06-10 18:52:01 +03:00
}
2022-11-26 23:38:06 +02:00
}
2021-06-15 14:43:44 +03:00
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
2021-06-16 01:20:20 +03:00
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
2026-05-12 22:15:48 +03:00
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]
2026-07-07 11:40:37 +03:00
newAttrs['class'] = [
'mfm',
this.pauseMfm ? '-pause' : '',
this.scaleMfm ? '-scale' : '',
2026-07-13 17:41:20 +03:00
]
2026-08-04 00:19:34 +03:00
.filter(Boolean)
2026-07-13 17:41:20 +03:00
.join(' ')
2026-05-12 22:15:48 +03:00
newAttrs['data-mfm-operator'] = mfmOperator
2026-07-13 17:41:20 +03:00
switch (mfmOperator) {
2026-05-12 22:15:48 +03:00
case 'position': {
2026-07-07 11:40:37 +03:00
const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 0
const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 0
2026-05-12 22:15:48 +03:00
newAttrs.style = [
'transform:',
`translate(calc(${x} * (var(--emoji-size) / 2)), `,
`calc(${y} * (var(--emoji-size) / 2)))`,
2026-05-12 22:15:48 +03:00
].join(' ')
break
}
case 'scale': {
2026-07-07 11:40:37 +03:00
const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 1
const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 1
2026-07-13 17:41:20 +03:00
newAttrs.style = ['transform:', `scale(${x}, ${y})`].join(' ')
2026-05-12 22:15:48 +03:00
break
}
case 'rotate': {
2026-07-07 11:40:37 +03:00
const deg = Number.parseFloat(fullAttrs['data-mfm-deg']) || 0
2026-05-12 22:15:48 +03:00
newAttrs.style = [
`transform: rotate(${deg}deg)`,
'transform-origin: center',
].join(';')
2026-05-12 22:15:48 +03:00
break
}
case 'bg': {
const color = fullAttrs['data-mfm-color'] || 0
2026-07-13 17:41:20 +03:00
newAttrs.style = [`background-color: #${color}`].join(' ')
2026-05-12 22:15:48 +03:00
break
}
case 'fg': {
const color = fullAttrs['data-mfm-color'] || 0
2026-07-13 17:41:20 +03:00
newAttrs.style = [`color: #${color}`].join(';')
2026-05-12 22:15:48 +03:00
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
2026-05-12 22:15:48 +03:00
const y = fullAttrs['data-mfm-y'] != null
const x = fullAttrs['data-mfm-x'] != null
2026-05-12 22:15:48 +03:00
const anim = [
x ? 'mfm-spinX' : null,
y ? 'mfm-spinY' : null,
2026-07-13 17:41:20 +03:00
'mfm-spin',
].filter((a) => a)[0]
const direction = [
alternate ? 'alternate' : null,
left ? 'reverse' : null,
'normal',
2026-07-13 17:41:20 +03:00
].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(';')
2026-05-12 22:15:48 +03:00
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}`,
2026-07-13 17:41:20 +03:00
`overflow: ${noclip ? 'visible' : 'clip'}`,
].join(';')
break
}
case 'tada':
case 'jelly':
2026-05-12 22:15:48 +03:00
case 'twitch':
case 'shake':
case 'jump':
2026-07-07 11:40:37 +03:00
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
2026-05-12 22:15:48 +03:00
break
}
2026-07-10 12:52:50 +03:00
case 'sparkle':
case 'x2':
case 'x3':
case 'x4':
// handled by css
break
2026-05-12 22:15:48 +03:00
default:
2026-07-10 12:52:50 +03:00
console.warn('Unsupported MFM operator:', mfmOperator, opener)
2026-05-12 22:15:48 +03:00
break
}
}
return <Tag {...newAttrs}>{newChildren}</Tag>
2021-06-15 14:43:44 +03:00
} else {
2026-01-06 16:22:52 +02:00
return <Tag />
2021-06-10 18:52:01 +03:00
}
}
return item
}
2021-06-15 14:43:44 +03:00
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
2025-08-19 16:35:40 +03:00
2021-06-11 11:05:28 +03:00
// 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?
2026-01-06 16:22:52 +02:00
const result = (
2026-05-13 16:12:52 +03:00
<span
class={[
'RichContent',
this.faint ? '-faint' : '',
this.allowNonSquareEmoji ? '-allow-non-square-emoji' : '',
]}
>
2026-01-06 16:22:52 +02:00
{this.collapse
? pass2.map((x) => {
2026-07-22 16:03:48 +03:00
if (typeof x === 'string') return x.replace(/\n/g, ' ')
if (!Array.isArray(x)) return x
2026-01-06 16:22:52 +02:00
return x.map((y) => (y.type === 'br' ? ' ' : y))
})
: pass2}
</span>
)
2021-06-11 11:05:28 +03:00
const event = {
lastTags,
writtenMentions,
writtenTags,
2026-01-06 16:22:52 +02:00
invisibleMentions,
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
2026-01-06 16:22:52 +02:00
},
2022-03-16 22:13:21 +02:00
}
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(''),
2026-01-06 16:22:52 +02:00
textContent,
}
}
2021-06-10 18:52:01 +03:00
/** Pre-processing HTML
*
* Currently this does one thing:
2021-06-10 18:52:01 +03:00
* - 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'])
2021-06-10 18:52:01 +03:00
2021-06-13 22:22:59 +03:00
const lines = convertHtmlToLines(html)
2026-01-06 16:22:52 +02:00
const newHtml = lines
.reverse()
.map((item, index, array) => {
if (!item.text) return item
const string = item.text
2021-06-10 18:52:01 +03:00
2026-01-06 16:22:52 +02:00
// Greentext stuff
if (
// Only if greentext is engaged
greentext &&
// Only handle p's and divs. Don't want to affect blockquotes, code etc
2026-01-06 16:22:52 +02:00
item.level.every((l) => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
2026-01-06 16:22:52 +02:00
) {
const cleanedString = string
.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
2021-06-10 18:52:01 +03:00
}
2026-01-06 16:22:52 +02:00
return string
})
.reverse()
.join('')
return { newHtml }
}