logo

pleroma-fe

My custom branche(s) on git.pleroma.social/pleroma/pleroma-fe git clone https://hacktivis.me/git/pleroma-fe.git

rich_content.jsx (11872B)


  1. import { unescape, flattenDeep } from 'lodash'
  2. import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
  3. import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
  4. import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
  5. import StillImage from 'src/components/still-image/still-image.vue'
  6. import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
  7. import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
  8. import './rich_content.scss'
  9. const MAYBE_LINE_BREAKING_ELEMENTS = [
  10. 'blockquote',
  11. 'br',
  12. 'hr',
  13. 'ul',
  14. 'ol',
  15. 'li',
  16. 'p',
  17. 'table',
  18. 'tbody',
  19. 'td',
  20. 'th',
  21. 'thead',
  22. 'tr',
  23. 'h1',
  24. 'h2',
  25. 'h3',
  26. 'h4',
  27. 'h5'
  28. ]
  29. /**
  30. * RichContent, The Über-powered component for rendering Post HTML.
  31. *
  32. * This takes post HTML and does multiple things to it:
  33. * - Groups all mentions into <MentionsLine>, this affects all mentions regardles
  34. * of where they are (beginning/middle/end), even single mentions are converted
  35. * to a <MentionsLine> containing single <MentionLink>.
  36. * - Replaces emoji shortcodes with <StillImage>'d images.
  37. *
  38. * There are two problems with this component's architecture:
  39. * 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
  40. * proven to be a massive overcomplication due to amount of things done here.
  41. * 2. We need to output both render and some extra data, which seems to be imp-
  42. * possible in vue. Current solution is to emit 'parseReady' event when parsing
  43. * is done within render() function.
  44. *
  45. * Apart from that one small hiccup with emit in render this _should_ be vue3-ready
  46. */
  47. export default {
  48. name: 'RichContent',
  49. components: {
  50. MentionsLine,
  51. HashtagLink
  52. },
  53. props: {
  54. // Original html content
  55. html: {
  56. required: true,
  57. type: String
  58. },
  59. attentions: {
  60. required: false,
  61. default: () => []
  62. },
  63. // Emoji object, as in status.emojis, note the "s" at the end...
  64. emoji: {
  65. required: true,
  66. type: Array
  67. },
  68. // Whether to handle links or not (posts: yes, everything else: no)
  69. handleLinks: {
  70. required: false,
  71. type: Boolean,
  72. default: false
  73. },
  74. // Meme arrows
  75. greentext: {
  76. required: false,
  77. type: Boolean,
  78. default: false
  79. },
  80. // Faint style (for notifs)
  81. faint: {
  82. required: false,
  83. type: Boolean,
  84. default: false
  85. }
  86. },
  87. // NEVER EVER TOUCH DATA INSIDE RENDER
  88. render () {
  89. // Pre-process HTML
  90. const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
  91. let currentMentions = null // Current chain of mentions, we group all mentions together
  92. // This is used to recover spacing removed when parsing mentions
  93. let lastSpacing = ''
  94. const lastTags = [] // Tags that appear at the end of post body
  95. const writtenMentions = [] // All mentions that appear in post body
  96. const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
  97. // to collapse too many mentions in a row
  98. const writtenTags = [] // All tags that appear in post body
  99. // unique index for vue "tag" property
  100. let mentionIndex = 0
  101. let tagsIndex = 0
  102. const renderImage = (tag) => {
  103. return <StillImage
  104. {...getAttrs(tag)}
  105. class="img"
  106. />
  107. }
  108. const renderHashtag = (attrs, children, encounteredTextReverse) => {
  109. const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
  110. writtenTags.push(linkData)
  111. if (!encounteredTextReverse) {
  112. lastTags.push(linkData)
  113. }
  114. const { url, tag, content } = linkData
  115. return <HashtagLink url={url} tag={tag} content={content}/>
  116. }
  117. const renderMention = (attrs, children) => {
  118. const linkData = getLinkData(attrs, children, mentionIndex++)
  119. linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
  120. writtenMentions.push(linkData)
  121. if (currentMentions === null) {
  122. currentMentions = []
  123. }
  124. currentMentions.push(linkData)
  125. if (currentMentions.length > MENTIONS_LIMIT) {
  126. invisibleMentions.push(linkData)
  127. }
  128. if (currentMentions.length === 1) {
  129. return <MentionsLine mentions={ currentMentions } />
  130. } else {
  131. return ''
  132. }
  133. }
  134. // Processor to use with html_tree_converter
  135. const processItem = (item, index, array, what) => {
  136. // Handle text nodes - just add emoji
  137. if (typeof item === 'string') {
  138. const emptyText = item.trim() === ''
  139. if (item.includes('\n')) {
  140. currentMentions = null
  141. }
  142. if (emptyText) {
  143. // don't include spaces when processing mentions - we'll include them
  144. // in MentionsLine
  145. lastSpacing = item
  146. // Don't remove last space in a container (fixes poast mentions)
  147. return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item
  148. }
  149. currentMentions = null
  150. if (item.includes(':')) {
  151. item = ['', processTextForEmoji(
  152. item,
  153. this.emoji,
  154. ({ shortcode, url }) => {
  155. return <StillImage
  156. class="emoji img"
  157. src={url}
  158. title={`:${shortcode}:`}
  159. alt={`:${shortcode}:`}
  160. />
  161. }
  162. )]
  163. }
  164. return item
  165. }
  166. // Handle tag nodes
  167. if (Array.isArray(item)) {
  168. const [opener, children, closer] = item
  169. let Tag = getTagName(opener)
  170. if (Tag.toLowerCase() === 'script') Tag = 'js-exploit'
  171. if (Tag.toLowerCase() === 'style') Tag = 'css-exploit'
  172. const fullAttrs = getAttrs(opener, () => true)
  173. const attrs = getAttrs(opener)
  174. const previouslyMentions = currentMentions !== null
  175. /* During grouping of mentions we trim all the empty text elements
  176. * This padding is added to recover last space removed in case
  177. * we have a tag right next to mentions
  178. */
  179. const mentionsLinePadding =
  180. // Padding is only needed if we just finished parsing mentions
  181. previouslyMentions &&
  182. // Don't add padding if content is string and has padding already
  183. !(children && typeof children[0] === 'string' && children[0].match(/^\s/))
  184. ? lastSpacing
  185. : ''
  186. if (MAYBE_LINE_BREAKING_ELEMENTS.includes(Tag)) {
  187. // all the elements that can cause a line change
  188. currentMentions = null
  189. } else if (Tag === 'img') { // replace images with StillImage
  190. return ['', [mentionsLinePadding, renderImage(opener)], '']
  191. } else if (Tag === 'a' && this.handleLinks) { // replace mentions with MentionLink
  192. if (fullAttrs.class && fullAttrs.class.includes('mention')) {
  193. // Handling mentions here
  194. return renderMention(attrs, children)
  195. } else {
  196. currentMentions = null
  197. }
  198. } else if (Tag === 'span') {
  199. if (this.handleLinks && fullAttrs.class && fullAttrs.class.includes('h-card')) {
  200. return ['', children.map(processItem), '']
  201. }
  202. }
  203. if (children !== undefined) {
  204. return [
  205. '',
  206. [
  207. mentionsLinePadding,
  208. [opener, children.map(processItem), closer]
  209. ],
  210. ''
  211. ]
  212. } else {
  213. return ['', [mentionsLinePadding, item], '']
  214. }
  215. }
  216. }
  217. // Processor for back direction (for finding "last" stuff, just easier this way)
  218. let encounteredTextReverse = false
  219. const processItemReverse = (item, index, array, what) => {
  220. // Handle text nodes - just add emoji
  221. if (typeof item === 'string') {
  222. const emptyText = item.trim() === ''
  223. if (emptyText) return item
  224. if (!encounteredTextReverse) encounteredTextReverse = true
  225. return unescape(item)
  226. } else if (Array.isArray(item)) {
  227. // Handle tag nodes
  228. const [opener, children] = item
  229. const Tag = opener === '' ? '' : getTagName(opener)
  230. switch (Tag) {
  231. case 'a': { // replace mentions with MentionLink
  232. if (!this.handleLinks) break
  233. const fullAttrs = getAttrs(opener, () => true)
  234. const attrs = getAttrs(opener, () => true)
  235. // should only be this
  236. if (
  237. (fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style
  238. (fullAttrs.rel === 'tag') // Mastodon style
  239. ) {
  240. return renderHashtag(attrs, children, encounteredTextReverse)
  241. } else {
  242. attrs.target = '_blank'
  243. const newChildren = [...children].reverse().map(processItemReverse).reverse()
  244. return <a {...attrs}>
  245. { newChildren }
  246. </a>
  247. }
  248. }
  249. case '':
  250. return [...children].reverse().map(processItemReverse).reverse()
  251. }
  252. // Render tag as is
  253. if (children !== undefined) {
  254. const newChildren = Array.isArray(children)
  255. ? [...children].reverse().map(processItemReverse).reverse()
  256. : children
  257. return <Tag {...getAttrs(opener)}>
  258. { newChildren }
  259. </Tag>
  260. } else {
  261. return <Tag/>
  262. }
  263. }
  264. return item
  265. }
  266. const pass1 = convertHtmlToTree(html).map(processItem)
  267. const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
  268. // DO NOT USE SLOTS they cause a re-render feedback loop here.
  269. // slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
  270. // at least until vue3?
  271. const result = <span class={['RichContent', this.faint ? '-faint' : '']}>
  272. { pass2 }
  273. </span>
  274. const event = {
  275. lastTags,
  276. writtenMentions,
  277. writtenTags,
  278. invisibleMentions
  279. }
  280. // DO NOT MOVE TO UPDATE. BAD IDEA.
  281. this.$emit('parseReady', event)
  282. return result
  283. }
  284. }
  285. const getLinkData = (attrs, children, index) => {
  286. const stripTags = (item) => {
  287. if (typeof item === 'string') {
  288. return item
  289. } else {
  290. return item[1].map(stripTags).join('')
  291. }
  292. }
  293. const textContent = children.map(stripTags).join('')
  294. return {
  295. index,
  296. url: attrs.href,
  297. tag: attrs['data-tag'],
  298. content: flattenDeep(children).join(''),
  299. textContent
  300. }
  301. }
  302. /** Pre-processing HTML
  303. *
  304. * Currently this does one thing:
  305. * - add green/cyantexting
  306. *
  307. * @param {String} html - raw HTML to process
  308. * @param {Boolean} greentext - whether to enable greentexting or not
  309. */
  310. export const preProcessPerLine = (html, greentext) => {
  311. const greentextHandle = new Set(['p', 'div'])
  312. const lines = convertHtmlToLines(html)
  313. const newHtml = lines.reverse().map((item, index, array) => {
  314. if (!item.text) return item
  315. const string = item.text
  316. // Greentext stuff
  317. if (
  318. // Only if greentext is engaged
  319. greentext &&
  320. // Only handle p's and divs. Don't want to affect blockquotes, code etc
  321. item.level.every(l => greentextHandle.has(l)) &&
  322. // Only if line begins with '>' or '<'
  323. (string.includes('&gt;') || string.includes('&lt;'))
  324. ) {
  325. const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
  326. .replace(/@\w+/gi, '') // remove mentions (even failed ones)
  327. .trim()
  328. if (cleanedString.startsWith('&gt;')) {
  329. return `<span class='greentext'>${string}</span>`
  330. } else if (cleanedString.startsWith('&lt;')) {
  331. return `<span class='cyantext'>${string}</span>`
  332. }
  333. }
  334. return string
  335. }).reverse().join('')
  336. return { newHtml }
  337. }