From cc00af7a3102034b05ebcd4aa1fd01c6f467184a Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 18:52:01 +0300
Subject: Hellthread(tm) Certified

---
 src/components/rich_content/rich_content.jsx | 176 +++++++++++++++++++++------
 1 file changed, 140 insertions(+), 36 deletions(-)

(limited to 'src/components/rich_content/rich_content.jsx')

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index db24ca0e..590fea0f 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,7 +1,7 @@
 import Vue from 'vue'
 import { unescape, flattenDeep } from 'lodash'
-import { convertHtml, getTagName, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
+import { convertHtmlToTree, getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/html_tree_converter.service.js'
+import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
 import StillImage from 'src/components/still-image/still-image.vue'
 import MentionLink from 'src/components/mention_link/mention_link.vue'
 
@@ -31,11 +31,24 @@ export default Vue.component('RichContent', {
       required: false,
       type: Boolean,
       default: false
+    },
+    // Whether to hide last mentions (hellthreads)
+    hideLastMentions: {
+      required: false,
+      type: Boolean,
+      default: false
+    },
+    // Whether to hide first mentions
+    hideFirstMentions: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   render (h) {
     // Pre-process HTML
-    const html = this.greentext ? addGreentext(this.html) : this.html
+    const html = preProcessPerLine(this.html, this.greentext, this.hideLastMentions)
+    console.log(this.hideFirstMentions, this.hideLastMentions)
 
     const renderImage = (tag) => {
       return <StillImage
@@ -45,18 +58,20 @@ export default Vue.component('RichContent', {
     }
 
     const renderMention = (attrs, children, encounteredText) => {
-      return <MentionLink
-        url={attrs.href}
-        content={flattenDeep(children).join('')}
-        firstMention={!encounteredText}
-      />
+      return (this.hideFirstMentions && !encounteredText)
+        ? ''
+        : <MentionLink
+          url={attrs.href}
+          content={flattenDeep(children).join('')}
+          firstMention={!encounteredText}
+        />
     }
 
     // We stop treating mentions as "first" ones when we encounter
     // non-whitespace text
     let encounteredText = false
     // Processor to use with mini_html_converter
-    const processItem = (item) => {
+    const processItem = (item, index, array, what) => {
       // Handle text nodes - just add emoji
       if (typeof item === 'string') {
         const emptyText = item.trim() === ''
@@ -69,7 +84,7 @@ export default Vue.component('RichContent', {
           encounteredText = true
         }
         if (item.includes(':')) {
-          return processTextForEmoji(
+          unescapedItem = processTextForEmoji(
             unescapedItem,
             this.emoji,
             ({ shortcode, url }) => {
@@ -81,9 +96,8 @@ export default Vue.component('RichContent', {
               />
             }
           )
-        } else {
-          return unescapedItem
         }
+        return unescapedItem
       }
 
       // Handle tag nodes
@@ -98,6 +112,8 @@ export default Vue.component('RichContent', {
             const attrs = getAttrs(opener)
             if (attrs['class'] && attrs['class'].includes('mention')) {
               return renderMention(attrs, children, encounteredText)
+            } else if (attrs['class'] && attrs['class'].includes('hashtag')) {
+              return item // We'll handle it later
             } else {
               attrs.target = '_blank'
               return <a {...{ attrs }}>
@@ -116,43 +132,129 @@ export default Vue.component('RichContent', {
         }
       }
     }
+    // Processor for back direction (for finding "last" stuff, just easier this way)
+    let encounteredTextReverse = false
+    const renderHashtag = (attrs, children, encounteredTextReverse) => {
+      attrs.target = '_blank'
+      if (!encounteredTextReverse) {
+        attrs['data-parser-last'] = true
+      }
+      return <a {...{ attrs }}>
+        { children.map(processItem) }
+      </a>
+    }
+    const processItemReverse = (item, index, array, what) => {
+      // Handle text nodes - just add emoji
+      if (typeof item === 'string') {
+        const emptyText = item.trim() === ''
+        if (emptyText) return encounteredTextReverse ? item : item.trim()
+        if (!encounteredTextReverse) encounteredTextReverse = true
+        return item
+      } else if (Array.isArray(item)) {
+        // Handle tag nodes
+        const [opener, children] = item
+        const Tag = getTagName(opener)
+        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)
+            }
+        }
+      }
+      return item
+    }
     return <span class="RichContent">
       { this.$slots.prefix }
-      { convertHtml(html).map(processItem) }
+      { convertHtmlToTree(html).map(processItem).reverse().map(processItemReverse).reverse() }
       { this.$slots.suffix }
     </span>
   }
 })
 
-export const addGreentext = (html) => {
-  try {
-    if (html.includes('&gt;')) {
-      // This checks if post has '>' at the beginning, excluding mentions so that @mention >impying works
-      return processHtml(html, (string) => {
-        if (
-          string.includes('&gt;') && string
-            .replace(/<[^>]+?>/gi, '') // remove all tags
-            .replace(/@\w+/gi, '') // remove mentions (even failed ones)
-            .trim()
-            .startsWith('&gt;')
-        ) {
-          return `<span class='greentext'>${string}</span>`
+/** 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} removeLastMentions - whether to remove last mentions
+ */
+export const preProcessPerLine = (html, greentext, removeLastMentions) => {
+  // Only mark first (last) encounter
+  let lastMentionsMarked = false
+
+  return convertHtmlToLines(html).reverse().map((item, index, array) => {
+    if (!item.text) return item
+    const string = item.text
+
+    // Greentext stuff
+    if (greentext && (string.includes('&gt;') || string.includes('&lt;'))) {
+      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>`
+      }
+    }
+
+    const tree = convertHtmlToTree(string)
+
+    // If line has loose text, i.e. text outside a mention or a tag
+    // we won't touch mentions.
+    let hasLooseText = false
+    let hasMentions = false
+    const process = (item) => {
+      if (Array.isArray(item)) {
+        const [opener, children, closer] = item
+        const tag = getTagName(opener)
+        if (tag === 'a') {
+          const attrs = getAttrs(opener)
+          if (attrs['class'] && attrs['class'].includes('mention')) {
+            hasMentions = true
+            return [opener, children, closer]
+          } else {
+            hasLooseText = true
+            return [opener, children, closer]
+          }
+        } else if (tag === 'span' || tag === 'p') {
+          return [opener, [...children].reverse().map(process).reverse(), closer]
         } else {
-          return string
+          hasLooseText = true
+          return [opener, children, closer]
+        }
+      }
+
+      if (typeof item === 'string') {
+        if (item.trim() !== '') {
+          hasLooseText = true
         }
-      })
+        return item
+      }
+    }
+
+    const result = [...tree].reverse().map(process).reverse()
+
+    if (removeLastMentions && hasMentions && !hasLooseText && !lastMentionsMarked) {
+      lastMentionsMarked = true
+      return ''
     } else {
-      return html
+      return flattenDeep(result).join('')
     }
-  } catch (e) {
-    console.error('Failed to process status html', e)
-    return html
-  }
+  }).reverse().join('')
 }
 
 export const getHeadTailLinks = (html) => {
   // Exported object properties
   const firstMentions = [] // Mentions that appear in the beginning of post body
+  const lastMentions = [] // Mentions that appear at the end of post body
   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
@@ -170,7 +272,7 @@ export const getHeadTailLinks = (html) => {
     }
   }
 
-  // Processor to use with mini_html_converter
+  // Processor to use with html_tree_converter
   const processItem = (item) => {
     // Handle text nodes - stop treating mentions as "first" when text encountered
     if (typeof item === 'string') {
@@ -182,6 +284,7 @@ export const getHeadTailLinks = (html) => {
       }
       // Encountered text? That means tags we've been collectings aren't "last"!
       lastTags.splice(0)
+      lastMentions.splice(0)
       return
     }
     // Handle tag nodes
@@ -197,6 +300,7 @@ export const getHeadTailLinks = (html) => {
             firstMentions.push(linkData)
           }
           writtenMentions.push(linkData)
+          lastMentions.push(linkData)
         } else if (attrs['class'].includes('hashtag')) {
           lastTags.push(linkData)
           writtenTags.push(linkData)
@@ -206,6 +310,6 @@ export const getHeadTailLinks = (html) => {
       children && children.forEach(processItem)
     }
   }
-  convertHtml(html).forEach(processItem)
-  return { firstMentions, writtenMentions, writtenTags, lastTags }
+  convertHtmlToTree(html).forEach(processItem)
+  return { firstMentions, writtenMentions, writtenTags, lastTags, lastMentions }
 }
-- 
cgit v1.2.3-70-g09d2

