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

323 lines
11 KiB
React
Raw Normal View History

import Vue from 'vue'
2021-06-07 13:16:10 +00:00
import { unescape, flattenDeep } from 'lodash'
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
2021-06-10 15:52:01 +00:00
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import StillImage from 'src/components/still-image/still-image.vue'
2021-06-07 13:16:10 +00:00
import MentionLink from 'src/components/mention_link/mention_link.vue'
2021-06-14 07:30:08 +00:00
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import './rich_content.scss'
2021-06-12 17:42:17 +00:00
/**
* RichContent, The Über-powered component for rendering Post HTML.
*
* This takes post HTML and does multiple things to it:
* - Converts mention links to <MentionLink>-s
* - Removes mentions from beginning and end (hellthread style only)
* - Replaces emoji shortcodes with <StillImage>'d images.
*
* Stuff like removing mentions from beginning and end is done so that they could
* be either replaced by collapsible <MentionsLine> or moved to separate place.
* 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 Vue.component('RichContent', {
name: 'RichContent',
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
}
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render (h) {
// Pre-process HTML
const { newHtml: html } = preProcessPerLine(this.html, this.greentext, this.handleLinks)
let currentMentions = null // Current chain of mentions, we group all mentions together
// to collapse too many mentions in a row
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
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 <StillImage
{...{ attrs: getAttrs(tag) }}
class="img"
/>
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
const linkData = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
attrs.target = '_blank'
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
return <a {...{ attrs }}>
{ children.map(processItem) }
</a>
}
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 === 1) {
return <MentionsLine mentions={ currentMentions } />
} else {
return ''
}
2021-06-07 13:16:10 +00:00
}
// Processor to use with html_tree_converter
2021-06-10 15:52:01 +00:00
const processItem = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
2021-06-08 10:42:16 +00:00
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
2021-06-08 08:38:44 +00:00
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
return currentMentions !== null ? item.trim() : item
2021-06-08 08:38:44 +00:00
}
currentMentions = null
if (item.includes(':')) {
2021-06-18 18:42:46 +00:00
item = ['', processTextForEmoji(
item,
this.emoji,
({ shortcode, url }) => {
return <StillImage
2021-06-07 09:49:54 +00:00
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
/>
}
2021-06-15 11:43:44 +00:00
)]
}
2021-06-18 18:42:46 +00:00
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
2021-06-15 11:43:44 +00:00
const [opener, children, closer] = item
const Tag = getTagName(opener)
const attrs = getAttrs(opener)
2021-06-07 13:16:10 +00:00
switch (Tag) {
case 'br':
currentMentions = null
break
case 'img': // replace images with StillImage
2021-06-07 13:16:10 +00:00
return renderImage(opener)
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
2021-06-07 13:16:10 +00:00
if (attrs['class'] && attrs['class'].includes('mention')) {
2021-06-15 22:20:20 +00:00
// Handling mentions here
return renderMention(attrs, children)
2021-06-15 22:20:20 +00:00
} else {
// Everything else will be handled in reverse pass
currentMentions = null
2021-06-10 15:52:01 +00:00
return item // We'll handle it later
2021-06-07 13:16:10 +00:00
}
case 'span':
if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
return ['', children.map(processItem), '']
}
}
2021-06-15 22:20:20 +00:00
if (children !== undefined) {
2021-06-15 11:43:44 +00:00
return [opener, children.map(processItem), closer]
} else {
2021-06-15 11:43:44 +00:00
return item
}
}
}
2021-06-10 15:52:01 +00: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 14:20:21 +00:00
if (emptyText) return item
2021-06-10 15:52:01 +00:00
if (!encounteredTextReverse) encounteredTextReverse = true
2021-06-18 18:42:46 +00:00
return unescape(item)
2021-06-10 15:52:01 +00:00
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
2021-06-15 11:43:44 +00:00
const Tag = opener === '' ? '' : getTagName(opener)
2021-06-10 15:52:01 +00:00
switch (Tag) {
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
const attrs = getAttrs(opener)
// should only be this
if (attrs['class'] && attrs['class'].includes('hashtag')) {
return renderHashtag(attrs, children, encounteredTextReverse)
2021-06-15 22:20:20 +00:00
} else {
attrs.target = '_blank'
const newChildren = [...children].reverse().map(processItemReverse).reverse()
return <a {...{ attrs }}>
{ newChildren }
</a>
2021-06-10 15:52:01 +00:00
}
2021-06-15 11:43:44 +00:00
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
2021-06-15 22:20:20 +00:00
html.includes('freenode') && console.log('PASS2', children)
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
2021-06-15 11:43:44 +00:00
return <Tag {...{ attrs: getAttrs(opener) }}>
2021-06-15 22:20:20 +00:00
{ newChildren }
2021-06-15 11:43:44 +00:00
</Tag>
} else {
return <Tag/>
2021-06-10 15:52:01 +00:00
}
}
return item
}
2021-06-15 11:43:44 +00:00
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
2021-06-11 08:05:28 +00: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?
const result = <span class="RichContent">
2021-06-15 11:43:44 +00:00
{ pass2 }
2021-06-11 08:05:28 +00:00
</span>
const event = {
lastTags,
writtenMentions,
writtenTags
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
}
})
const getLinkData = (attrs, children, index) => {
return {
index,
url: attrs.href,
hashtag: attrs['data-tag'],
content: flattenDeep(children).join('')
}
}
2021-06-10 15:52:01 +00:00
/** Pre-processing HTML
*
* Currently this does two things:
* - add green/cyantexting
* - wrap and mark last line containing only mentions as ".lastMentionsLine" for
* more compact hellthreads.
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
* @param {Boolean} handleLinks - whether to handle links or not
2021-06-10 15:52:01 +00:00
*/
export const preProcessPerLine = (html, greentext, handleLinks) => {
const lastMentions = []
const greentextHandle = new Set(['p', 'div'])
2021-06-10 15:52:01 +00:00
2021-06-13 19:22:59 +00:00
const lines = convertHtmlToLines(html)
const newHtml = lines.reverse().map((item, index, array) => {
// Going over each line in reverse to detect last mentions,
// keeping non-text stuff as-is
2021-06-10 15:52:01 +00:00
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 blocquotes, code etc
item.level.every(l => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
) {
2021-06-10 15:52:01 +00: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>`
}
}
// Converting that line part into tree
2021-06-10 15:52:01 +00:00
const tree = convertHtmlToTree(string)
const process = (item) => {
if (Array.isArray(item)) {
const [opener, children, closer] = item
const tag = getTagName(opener)
if (tag === 'span' || tag === 'p') {
// For span and p we need to go deeper
return [opener, [...children].map(process), closer]
} else {
2021-06-10 15:52:01 +00:00
return [opener, children, closer]
}
}
if (typeof item === 'string') {
return item
}
}
// We now processed our tree, now we need to mark line as lastMentions
const result = [...tree].map(process)
2021-06-10 15:52:01 +00:00
return flattenDeep(result).join('')
2021-06-10 15:52:01 +00:00
}).reverse().join('')
return { newHtml, lastMentions }
}