From aec867b30036fd039113e4197ca98566447efec6 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 12:08:31 +0300
Subject: [PATCH] Moved greentext to RichContent, improved how first mentions
 are restored, now shows mentions not uh, mention in post body

---
 src/components/mention_link/mention_link.js   |   1 +
 src/components/mentions_line/mentions_line.js |   8 +-
 .../mentions_line/mentions_line.vue           |  14 +--
 src/components/rich_content/rich_content.jsx  | 112 +++++++++++++++++-
 src/components/status/status.js               |  25 +++-
 src/components/status/status.vue              |   4 +-
 src/components/status_body/status_body.js     |  60 ++--------
 src/components/status_body/status_body.vue    |  20 ++--
 8 files changed, 166 insertions(+), 78 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index acd0f584..559250c5 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -6,6 +6,7 @@ const MentionLink = {
   name: 'MentionLink',
   props: {
     url: {
+      required: true,
       type: String
     },
     content: {
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
index 4b20d861..e52045ec 100644
--- a/src/components/mentions_line/mentions_line.js
+++ b/src/components/mentions_line/mentions_line.js
@@ -4,7 +4,7 @@ import { mapGetters } from 'vuex'
 const MentionsLine = {
   name: 'MentionsLine',
   props: {
-    attentions: {
+    mentions: {
       required: true,
       type: Array
     }
@@ -20,11 +20,11 @@ const MentionsLine = {
     limit () {
       return 6
     },
-    mentions () {
-      return this.attentions.slice(0, this.limit)
+    mentionsComputed () {
+      return this.mentions.slice(0, this.limit)
     },
     extraMentions () {
-      return this.attentions.slice(this.limit)
+      return this.mentions.slice(this.limit)
     },
     manyMentions () {
       return this.extraMentions.length > 0
diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue
index 58f3de6f..f4b3abb9 100644
--- a/src/components/mentions_line/mentions_line.vue
+++ b/src/components/mentions_line/mentions_line.vue
@@ -1,11 +1,11 @@
 <template>
   <span class="MentionsLine">
     <MentionLink
-      v-for="mention in mentions"
-      :key="mention.statusnet_profile_url"
+      v-for="mention in mentionsComputed"
+      :key="mention.index"
       class="mention-link"
-      :content="mention.statusnet_profile_url"
-      :url="mention.statusnet_profile_url"
+      :content="mention.content"
+      :url="mention.url"
       :first-mention="false"
     /><span
       v-if="manyMentions"
@@ -17,10 +17,10 @@
       >
         <MentionLink
           v-for="mention in extraMentions"
-          :key="mention.statusnet_profile_url"
+          :key="mention.index"
           class="mention-link"
-          :content="mention.statusnet_profile_url"
-          :url="mention.statusnet_profile_url"
+          :content="mention.content"
+          :url="mention.url"
           :first-mention="false"
         />
       </span><button
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index bb7ae739..db24ca0e 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,6 +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 StillImage from 'src/components/still-image/still-image.vue'
 import MentionLink from 'src/components/mention_link/mention_link.vue'
 
@@ -24,15 +25,25 @@ export default Vue.component('RichContent', {
       required: false,
       type: Boolean,
       default: false
+    },
+    // Meme arrows
+    greentext: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   render (h) {
+    // Pre-process HTML
+    const html = this.greentext ? addGreentext(this.html) : this.html
+
     const renderImage = (tag) => {
       return <StillImage
         {...{ attrs: getAttrs(tag) }}
         class="img"
       />
     }
+
     const renderMention = (attrs, children, encounteredText) => {
       return <MentionLink
         url={attrs.href}
@@ -41,10 +52,12 @@ export default Vue.component('RichContent', {
       />
     }
 
+    // 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) => {
-      // Handle text noes - just add emoji
+      // Handle text nodes - just add emoji
       if (typeof item === 'string') {
         const emptyText = item.trim() === ''
         if (emptyText) {
@@ -72,6 +85,7 @@ export default Vue.component('RichContent', {
           return unescapedItem
         }
       }
+
       // Handle tag nodes
       if (Array.isArray(item)) {
         const [opener, children] = item
@@ -84,8 +98,14 @@ export default Vue.component('RichContent', {
             const attrs = getAttrs(opener)
             if (attrs['class'] && attrs['class'].includes('mention')) {
               return renderMention(attrs, children, encounteredText)
+            } else {
+              attrs.target = '_blank'
+              return <a {...{ attrs }}>
+                { children.map(processItem) }
+              </a>
             }
         }
+
         // Render tag as is
         if (children !== undefined) {
           return <Tag {...{ attrs: getAttrs(opener) }}>
@@ -97,7 +117,95 @@ export default Vue.component('RichContent', {
       }
     }
     return <span class="RichContent">
-      { convertHtml(this.html).map(processItem) }
+      { this.$slots.prefix }
+      { convertHtml(html).map(processItem) }
+      { 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>`
+        } else {
+          return string
+        }
+      })
+    } else {
+      return html
+    }
+  } catch (e) {
+    console.error('Failed to process status html', e)
+    return html
+  }
+}
+
+export const getHeadTailLinks = (html) => {
+  // Exported object properties
+  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
+
+  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 mini_html_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)
+      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)
+        } 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)
+    }
+  }
+  convertHtml(html).forEach(processItem)
+  return { firstMentions, writtenMentions, writtenTags, lastTags }
+}
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 54472525..0498f28a 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -19,6 +19,7 @@ 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 {
@@ -166,17 +167,33 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
-    mentionsOwnLine () {
-      return this.mergedConfig.mentionsOwnLine
+    headTailLinks () {
+      return getHeadTailLinks(this.status.raw_html)
     },
     mentions () {
       return this.status.attentions.filter(attn => {
         return attn.screen_name !== this.replyToName &&
           attn.screen_name !== this.status.user.screen_name
+      }).map(attn => ({
+        url: attn.statusnet_profile_url,
+        content: attn.screen_name,
+        userId: attn.id
+      }))
+    },
+    alsoMentions () {
+      const set = new Set(this.headTailLinks.writtenMentions.map(m => m.url))
+      return this.headTailLinks.writtenMentions.filter(mention => {
+        return !set.has(mention.url)
       })
     },
-    hasMentions () {
-      return this.mentions.length > 0
+    mentionsLine () {
+      return this.mentionsOwnLine ? this.mentions : this.alsoMentions
+    },
+    mentionsOwnLine () {
+      return this.mergedConfig.mentionsOwnLine
+    },
+    hasMentionsLine () {
+      return this.mentionsLine.length > 0
     },
     muted () {
       if (this.statusoid.user.id === this.currentUser.id) return false
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index f27fbaa5..05d22232 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -294,7 +294,7 @@
             </div>
 
             <div
-              v-if="hasMentions && mentionsOwnLine"
+              v-if="hasMentionsLine"
               class="heading-mentions-row"
             >
               <div
@@ -316,7 +316,7 @@
                   </span>
                 </span>
                 <MentionsLine
-                  :attentions="mentions"
+                  :mentions="mentionsLine"
                   class="mentions-line"
                 />
               </div>
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 3c092ac7..dbabd208 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -1,8 +1,6 @@
 import fileType from 'src/services/file_type/file_type.service'
-import RichContent from 'src/components/rich_content/rich_content.jsx'
+import RichContent, { getHeadTailLinks } from 'src/components/rich_content/rich_content.jsx'
 import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
-import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -28,7 +26,10 @@ const StatusContent = {
     'focused',
     'noHeading',
     'fullContent',
-    'singleLine'
+    'singleLine',
+    // if this was computed at upper level it can be passed here, otherwise
+    // it will be in this component
+    'headTailLinks'
   ],
   data () {
     return {
@@ -72,44 +73,18 @@ const StatusContent = {
     showingMore () {
       return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
     },
-    postBodyHtml () {
-      const html = this.status.raw_html
-
-      if (this.mergedConfig.greentext) {
-        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>`
-              } else {
-                return string
-              }
-            })
-          } else {
-            return html
-          }
-        } catch (e) {
-          console.error('Failed to process status html', e)
-          return html
-        }
-      } else {
-        return html
-      }
-    },
     attachmentTypes () {
       return this.status.attachments.map(file => fileType.fileType(file.mimetype))
     },
     mentionsOwnLine () {
       return this.mergedConfig.mentionsOwnLine
     },
+    headTailLinksComputed () {
+      if (this.headTailLinks) return this.headTailLinks
+      return getHeadTailLinks(this.status.raw_html)
+    },
     mentions () {
-      return this.status.attentions
+      return this.headTailLinksComputed.firstMentions
     },
     ...mapGetters(['mergedConfig'])
   },
@@ -124,21 +99,6 @@ const StatusContent = {
     })
   },
   methods: {
-    linkClicked (event) {
-      const target = event.target.closest('.status-content a')
-      if (target) {
-        if (target.rel.match(/(?:^|\s)tag(?:$|\s)/) || target.className.match(/hashtag/)) {
-          // Extract tag name from dataset or link url
-          const tag = target.dataset.tag || extractTagFromUrl(target.href)
-          if (tag) {
-            const link = this.generateTagLink(tag)
-            this.$router.push(link)
-            return
-          }
-        }
-        window.open(target.href, '_blank')
-      }
-    },
     toggleShowMore () {
       if (this.mightHideBecauseTall) {
         this.showingTall = !this.showingTall
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index fae2d594..ff919211 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -10,7 +10,6 @@
           class="media-body summary"
           :html="status.summary_raw_html"
           :emoji="status.emojis"
-          @click.prevent="linkClicked"
         />
         <button
           v-if="longSubject && showingLongSubject"
@@ -43,19 +42,22 @@
           class="text-wrapper"
           v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
         >
-          <MentionsLine
-            v-if="!mentionsOwnLine"
-            :attentions="status.attentions"
-            class="mentions-line"
-          />
           <RichContent
             :class="{ '-single-line': singleLine }"
             class="text media-body"
-            :html="postBodyHtml"
+            :html="status.raw_html"
             :emoji="status.emojis"
             :handle-links="true"
-            @click.prevent="linkClicked"
-          />
+            :greentext="mergedConfig.greentext"
+          >
+            <template v-slot:prefix>
+              <MentionsLine
+                v-if="!mentionsOwnLine"
+                :mentions="mentions"
+                class="mentions-line"
+              />
+            </template>
+          </RichContent>
         </span>
 
         <button