From f883d2f75cd3c404115bd2c98b6d3c8d7ff10ef6 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 11 Jun 2021 03:11:58 +0300
Subject: [PATCH] better handling of hellthreads with mentions at bottom

---
 src/components/mention_link/mention_link.js   |   6 -
 src/components/mention_link/mention_link.scss |   4 -
 .../mentions_line/mentions_line.scss          |   1 +
 src/components/rich_content/rich_content.jsx  | 198 +++++++++---------
 src/components/status/status.js               |  16 +-
 src/components/status/status.vue              |   6 +-
 src/components/status_body/status_body.js     |  24 +--
 src/components/status_body/status_body.vue    |  13 +-
 .../status_content/status_content.js          |   7 +-
 .../status_content/status_content.vue         |   5 +-
 10 files changed, 138 insertions(+), 142 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 00b9e388..eec116db 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -28,11 +28,6 @@ const MentionLink = {
     userScreenName: {
       required: false,
       type: String
-    },
-    firstMention: {
-      required: false,
-      type: Boolean,
-      default: false
     }
   },
   methods: {
@@ -89,7 +84,6 @@ const MentionLink = {
         {
           '-you': this.isYou,
           '-highlighted': this.highlight,
-          '-firstMention': this.firstMention,
           '-oldStyle': this.oldStyle
         },
         this.highlightType
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 9df1ccfe..1be3e7c5 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -38,10 +38,6 @@
   .new {
     margin-right: 0.25em;
 
-    &.-firstMention {
-      display: none;
-    }
-
     &.-you {
       & .shortName,
       & .full {
diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index 735502de..59f75fbb 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -1,5 +1,6 @@
 .MentionsLine {
   .showMoreLess {
+    white-space: normal;
     &.-newStyle {
       line-height: 1.5;
       font-size: inherit;
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 590fea0f..8972c494 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -33,22 +33,23 @@ export default Vue.component('RichContent', {
       default: false
     },
     // Whether to hide last mentions (hellthreads)
-    hideLastMentions: {
-      required: false,
-      type: Boolean,
-      default: false
-    },
-    // Whether to hide first mentions
-    hideFirstMentions: {
+    hideMentions: {
       required: false,
       type: Boolean,
       default: false
     }
   },
+  // NEVER EVER TOUCH DATA INSIDE RENDER
   render (h) {
     // Pre-process HTML
-    const html = preProcessPerLine(this.html, this.greentext, this.hideLastMentions)
-    console.log(this.hideFirstMentions, this.hideLastMentions)
+    const { newHtml: html, lastMentions } = preProcessPerLine(this.html, this.greentext, this.hideLastMentions)
+    const firstMentions = [] // Mentions that appear in the beginning 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
+    // unique index for vue "tag" property
+    let mentionIndex = 0
+    let tagsIndex = 0
 
     const renderImage = (tag) => {
       return <StillImage
@@ -57,20 +58,37 @@ export default Vue.component('RichContent', {
       />
     }
 
+    const renderHashtag = (attrs, children, encounteredTextReverse) => {
+      const linkData = getLinkData(attrs, children, tagsIndex++)
+      writtenTags.push(linkData)
+      attrs.target = '_blank'
+      if (!encounteredTextReverse) {
+        lastTags.push(linkData)
+        attrs['data-parser-last'] = true
+      }
+      return <a {...{ attrs }}>
+        { children.map(processItem) }
+      </a>
+    }
+
     const renderMention = (attrs, children, encounteredText) => {
-      return (this.hideFirstMentions && !encounteredText)
-        ? ''
-        : <MentionLink
+      const linkData = getLinkData(attrs, children, mentionIndex++)
+      writtenMentions.push(linkData)
+      if (!encounteredText) {
+        firstMentions.push(linkData)
+        return ''
+      } else {
+        return <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
+    // Processor to use with html_tree_converter
     const processItem = (item, index, array, what) => {
       // Handle text nodes - just add emoji
       if (typeof item === 'string') {
@@ -104,12 +122,22 @@ export default Vue.component('RichContent', {
       if (Array.isArray(item)) {
         const [opener, children] = item
         const Tag = getTagName(opener)
+        const attrs = getAttrs(opener)
         switch (Tag) {
+          case 'span': // replace images with StillImage
+            if (attrs['class'] && attrs['class'].includes('lastMentions')) {
+              if (firstMentions.length > 0) {
+                break
+              } else {
+                return ''
+              }
+            } else {
+              break
+            }
           case 'img': // replace images with StillImage
             return renderImage(opener)
           case 'a': // replace mentions with MentionLink
             if (!this.handleLinks) break
-            const attrs = getAttrs(opener)
             if (attrs['class'] && attrs['class'].includes('mention')) {
               return renderMention(attrs, children, encounteredText)
             } else if (attrs['class'] && attrs['class'].includes('hashtag')) {
@@ -132,17 +160,9 @@ 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') {
@@ -166,14 +186,37 @@ export default Vue.component('RichContent', {
       }
       return item
     }
-    return <span class="RichContent">
+
+    const event = {
+      firstMentions,
+      lastMentions,
+      lastTags,
+      writtenMentions,
+      writtenTags
+    }
+
+    const result = <span class="RichContent">
       { this.$slots.prefix }
       { convertHtmlToTree(html).map(processItem).reverse().map(processItemReverse).reverse() }
       { this.$slots.suffix }
     </span>
+
+    // 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('')
+  }
+}
+
 /** Pre-processing HTML
  *
  * Currently this does two things:
@@ -183,13 +226,13 @@ export default Vue.component('RichContent', {
  *
  * @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
+export const preProcessPerLine = (html, greentext) => {
+  const lastMentions = []
 
-  return convertHtmlToLines(html).reverse().map((item, index, array) => {
+  const newHtml = convertHtmlToLines(html).reverse().map((item, index, array) => {
+    // Going over each line in reverse to detect last mentions,
+    // keeping non-text stuff as-is
     if (!item.text) return item
     const string = item.text
 
@@ -205,6 +248,7 @@ export const preProcessPerLine = (html, greentext, removeLastMentions) => {
       }
     }
 
+    // Converting that line part into tree
     const tree = convertHtmlToTree(string)
 
     // If line has loose text, i.e. text outside a mention or a tag
@@ -215,18 +259,23 @@ export const preProcessPerLine = (html, greentext, removeLastMentions) => {
       if (Array.isArray(item)) {
         const [opener, children, closer] = item
         const tag = getTagName(opener)
+        // If we have a link we probably have mentions
         if (tag === 'a') {
           const attrs = getAttrs(opener)
           if (attrs['class'] && attrs['class'].includes('mention')) {
+            // Got mentions
             hasMentions = true
             return [opener, children, closer]
           } else {
+            // Not a mention? Means we have loose text or whatever
             hasLooseText = true
             return [opener, children, closer]
           }
         } else if (tag === 'span' || tag === 'p') {
-          return [opener, [...children].reverse().map(process).reverse(), closer]
+          // For span and p we need to go deeper
+          return [opener, [...children].map(process), closer]
         } else {
+          // Everything else equals to a loose text
           hasLooseText = true
           return [opener, children, closer]
         }
@@ -234,82 +283,43 @@ export const preProcessPerLine = (html, greentext, removeLastMentions) => {
 
       if (typeof item === 'string') {
         if (item.trim() !== '') {
+          // only meaningful strings are loose text
           hasLooseText = true
         }
         return item
       }
     }
 
-    const result = [...tree].reverse().map(process).reverse()
+    // We now processed our tree, now we need to mark line as lastMentions
+    const result = [...tree].map(process)
 
-    if (removeLastMentions && hasMentions && !hasLooseText && !lastMentionsMarked) {
-      lastMentionsMarked = true
-      return ''
+    // Only check last (first since list is reversed) line
+    if (hasMentions && !hasLooseText && index === 0) {
+      let mentionIndex = 0
+      const process = (item) => {
+        if (Array.isArray(item)) {
+          const [opener, children] = item
+          const tag = getTagName(opener)
+          if (tag === 'a') {
+            const attrs = getAttrs(opener)
+            lastMentions.push(getLinkData(attrs, children, mentionIndex++))
+          } else if (children) {
+            children.forEach(process)
+          }
+        }
+      }
+      result.forEach(process)
+      // we DO need mentions here so that we conditionally remove them if don't
+      // have first mentions
+      return ['<span class="lastMentions">', flattenDeep(result).join(''), '</span>'].join('')
     } else {
       return flattenDeep(result).join('')
     }
   }).reverse().join('')
+
+  return { newHtml, lastMentions }
 }
 
 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
-
-  let encounteredText = false
-  let processingFirstMentions = true
-  let index = 0 // unique index for vue "tag" property
-
-  const getLinkData = (attrs, children, index) => {
-    return {
-      index,
-      url: attrs.href,
-      hashtag: attrs['data-tag'],
-      content: flattenDeep(children).join('')
-    }
-  }
-
-  // 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') {
-      const emptyText = item.trim() === ''
-      if (emptyText) return
-      if (!encounteredText) {
-        encounteredText = true
-        processingFirstMentions = false
-      }
-      // Encountered text? That means tags we've been collectings aren't "last"!
-      lastTags.splice(0)
-      lastMentions.splice(0)
-      return
-    }
-    // Handle tag nodes
-    if (Array.isArray(item)) {
-      const [opener, children] = item
-      const Tag = getTagName(opener)
-      if (Tag !== 'a') return children && children.forEach(processItem)
-      const attrs = getAttrs(opener)
-      if (attrs['class']) {
-        const linkData = getLinkData(attrs, children, index++)
-        if (attrs['class'].includes('mention')) {
-          if (processingFirstMentions) {
-            firstMentions.push(linkData)
-          }
-          writtenMentions.push(linkData)
-          lastMentions.push(linkData)
-        } else if (attrs['class'].includes('hashtag')) {
-          lastTags.push(linkData)
-          writtenTags.push(linkData)
-        }
-        return // Stop processing, we don't care about link's contents
-      }
-      children && children.forEach(processItem)
-    }
-  }
-  convertHtmlToTree(html).forEach(processItem)
-  return { firstMentions, writtenMentions, writtenTags, lastTags, lastMentions }
 }
diff --git a/src/components/status/status.js b/src/components/status/status.js
index bab818fc..5b178c2e 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -19,7 +19,6 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import { muteWordHits } from '../../services/status_parser/status_parser.js'
 import { unescape, uniqBy } from 'lodash'
-import { getHeadTailLinks } from 'src/components/rich_content/rich_content.jsx'
 
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -101,7 +100,8 @@ const Status = {
       userExpanded: false,
       mediaPlaying: [],
       suspendable: true,
-      error: null
+      error: null,
+      headTailLinks: null
     }
   },
   computed: {
@@ -168,9 +168,6 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
-    headTailLinks () {
-      return getHeadTailLinks(this.status.raw_html)
-    },
     mentions () {
       return this.status.attentions.filter(attn => {
         return attn.screen_name !== this.replyToName &&
@@ -182,6 +179,7 @@ const Status = {
       }))
     },
     alsoMentions () {
+      if (!this.headTailLinks) return []
       const set = new Set(this.headTailLinks.writtenMentions.map(m => m.url))
       return this.headTailLinks.writtenMentions.filter(mention => {
         return !set.has(mention.url)
@@ -196,9 +194,6 @@ const Status = {
     hasMentionsLine () {
       return this.mentionsLine.length > 0
     },
-    hideLastMentions () {
-      return this.headTailLinks.firstMentions.length === 0
-    },
     muted () {
       if (this.statusoid.user.id === this.currentUser.id) return false
       const { status } = this
@@ -346,6 +341,9 @@ const Status = {
     },
     removeMediaPlaying (id) {
       this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
+    },
+    setHeadTailLinks (headTailLinks) {
+      this.headTailLinks = headTailLinks
     }
   },
   watch: {
@@ -356,7 +354,7 @@ const Status = {
           // Post is above screen, match its top to screen top
           window.scrollBy(0, rect.top - 100)
         } else if (rect.height >= (window.innerHeight - 50)) {
-          // Post we want to see is taller than screen so match its top to screen top
+          // Post we wahttp://localhost:8080/users/hj/dmsnt to see is taller than screen so match its top to screen top
           window.scrollBy(0, rect.top - 100)
         } else if (rect.bottom > window.innerHeight - 50) {
           // Post is below screen, match its bottom to screen bottom
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 0190d864..507e4192 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -305,11 +305,11 @@
             :no-heading="noHeading"
             :highlight="highlight"
             :focused="isFocused"
-            :hide-first-mentions="mentionsOwnLine && isReply"
-            :hide-last-mentions="hideLastMentions"
-            :head-tail-links="headTailLinks"
+            :hide-mentions="mentionsOwnLine && (isReply || true)"
             @mediaplay="addMediaPlaying($event)"
             @mediapause="removeMediaPlaying($event)"
+            @parseReady="setHeadTailLinks"
+            ref="content"
           />
 
           <div
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 2fc9abbf..7433619b 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -3,6 +3,7 @@ import RichContent, { getHeadTailLinks } from 'src/components/rich_content/rich_
 import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
+import { set } from 'vue'
 import {
   faFile,
   faMusic,
@@ -27,11 +28,7 @@ const StatusContent = {
     'noHeading',
     'fullContent',
     'singleLine',
-    // if this was computed at upper level it can be passed here, otherwise
-    // it will be in this component
-    'headTailLinks',
-    'hideFirstMentions',
-    'hideLastMentions'
+    'hideMentions'
   ],
   data () {
     return {
@@ -39,9 +36,9 @@ const StatusContent = {
       showingLongSubject: false,
       // not as computed because it sets the initial state which will be changed later
       expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
-      headTailLinksComputed: this.headTailLinks
-        ? this.headTailLinks
-        : getHeadTailLinks(this.status.raw_html)
+      headTailLinks: null,
+      firstMentions: [],
+      lastMentions: []
     }
   },
   computed: {
@@ -81,12 +78,6 @@ const StatusContent = {
     attachmentTypes () {
       return this.status.attachments.map(file => fileType.fileType(file.mimetype))
     },
-    mentionsFirst () {
-      return this.headTailLinksComputed.firstMentions
-    },
-    mentionsLast () {
-      return this.headTailLinksComputed.lastMentions
-    },
     ...mapGetters(['mergedConfig'])
   },
   components: {
@@ -107,6 +98,11 @@ const StatusContent = {
         this.expandingSubject = !this.expandingSubject
       }
     },
+    setHeadTailLinks (headTailLinks) {
+      set(this, 'headTailLinks', headTailLinks)
+      set(this, 'firstMentions', headTailLinks.firstMentions)
+      set(this, 'lastMentions', headTailLinks.lastMentions)
+    },
     generateTagLink (tag) {
       return `/tag/${tag}`
     }
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index bd599a8c..68f6701f 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -48,20 +48,21 @@
             :html="status.raw_html"
             :emoji="status.emojis"
             :handle-links="true"
+            :hide-mentions="hideMentions"
             :greentext="mergedConfig.greentext"
-            :hide-first-mentions="hideFirstMentions"
-            :hide-last-mentions="hideLastMentions"
+            @parseReady="setHeadTailLinks"
+            ref="text"
           >
             <template v-slot:prefix>
               <MentionsLine
-                v-if="!hideFirstMentions && mentionsFirst"
-                :mentions="mentionsFirst"
+                v-if="!hideMentions && firstMentions && firstMentions.length > 0"
+                :mentions="firstMentions"
               />
             </template>
             <template v-slot:suffix>
               <MentionsLine
-                v-if="!hideFirstMentions && mentionsLast"
-                :mentions="mentionsLast"
+                v-if="!hideMentions && lastMentions.length > 0 && firstMentions.length === 0"
+                :mentions="lastMentions"
               />
             </template>
           </RichContent>
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 64cc6d44..11a4974b 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -32,9 +32,7 @@ const StatusContent = {
     'noHeading',
     'fullContent',
     'singleLine',
-    'hideFirstMentions',
-    'hideLastMentions',
-    'headTailLinks'
+    'hideMentions'
   ],
   computed: {
     hideAttachments () {
@@ -94,6 +92,9 @@ const StatusContent = {
     StatusBody
   },
   methods: {
+    setHeadTailLinks (headTailLinks) {
+      this.$emit('parseReady', headTailLinks)
+    },
     setMedia () {
       const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
       return () => this.$store.dispatch('setMedia', attachments)
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index c32bbbfb..feb34d2c 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -4,9 +4,8 @@
     <StatusBody
       :status="status"
       :single-line="singleLine"
-      :hide-first-mentions="hideFirstMentions"
-      :hide-last-mentions="hideLastMentions"
-      :head-tail-links="headTailLinks"
+      :hide-mentions="hideMentions"
+      @parseReady="setHeadTailLinks"
     >
       <div v-if="status.poll && status.poll.options">
         <poll :base-poll="status.poll" />