From e95412a03cb84d4d835047d44e55a2900cdfb0d1 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 31 May 2021 14:16:37 +0300
Subject: [PATCH 001/169] fix BooleanSetting and ChoiceSetting not working
 properly on initial launch as anon visitor (would show all as changed, empty
 selects)

---
 src/components/settings_modal/helpers/boolean_setting.js | 5 ++++-
 src/components/settings_modal/helpers/choice_setting.js  | 4 ++--
 2 files changed, 6 insertions(+), 3 deletions(-)

diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js
index 1dda49f2..dea77e10 100644
--- a/src/components/settings_modal/helpers/boolean_setting.js
+++ b/src/components/settings_modal/helpers/boolean_setting.js
@@ -18,8 +18,11 @@ export default {
     state () {
       return get(this.$parent, this.path)
     },
+    defaultState () {
+      return get(this.$parent, this.pathDefault)
+    },
     isChanged () {
-      return get(this.$parent, this.path) !== get(this.$parent, this.pathDefault)
+      return this.state !== undefined && this.state !== this.defaultState
     }
   },
   methods: {
diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js
index 042e8106..f4387e62 100644
--- a/src/components/settings_modal/helpers/choice_setting.js
+++ b/src/components/settings_modal/helpers/choice_setting.js
@@ -17,13 +17,13 @@ export default {
       return [firstSegment + 'DefaultValue', ...rest].join('.')
     },
     state () {
-      return get(this.$parent, this.path)
+      return get(this.$parent, this.path) || get(this.$parent, this.pathDefault)
     },
     defaultState () {
       return get(this.$parent, this.pathDefault)
     },
     isChanged () {
-      return get(this.$parent, this.path) !== get(this.$parent, this.pathDefault)
+      return this.state !== undefined && this.state !== this.defaultState
     }
   },
   methods: {

From 32d1a0e1813e706a298871361123636187cde9bc Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 31 May 2021 14:22:08 +0300
Subject: [PATCH 002/169] better approach

---
 src/components/settings_modal/helpers/boolean_setting.js | 9 +++++++--
 src/components/settings_modal/helpers/choice_setting.js  | 9 +++++++--
 2 files changed, 14 insertions(+), 4 deletions(-)

diff --git a/src/components/settings_modal/helpers/boolean_setting.js b/src/components/settings_modal/helpers/boolean_setting.js
index dea77e10..5c52f697 100644
--- a/src/components/settings_modal/helpers/boolean_setting.js
+++ b/src/components/settings_modal/helpers/boolean_setting.js
@@ -16,13 +16,18 @@ export default {
       return [firstSegment + 'DefaultValue', ...rest].join('.')
     },
     state () {
-      return get(this.$parent, this.path)
+      const value = get(this.$parent, this.path)
+      if (value === undefined) {
+        return this.defaultState
+      } else {
+        return value
+      }
     },
     defaultState () {
       return get(this.$parent, this.pathDefault)
     },
     isChanged () {
-      return this.state !== undefined && this.state !== this.defaultState
+      return this.state !== this.defaultState
     }
   },
   methods: {
diff --git a/src/components/settings_modal/helpers/choice_setting.js b/src/components/settings_modal/helpers/choice_setting.js
index f4387e62..a15f6bac 100644
--- a/src/components/settings_modal/helpers/choice_setting.js
+++ b/src/components/settings_modal/helpers/choice_setting.js
@@ -17,13 +17,18 @@ export default {
       return [firstSegment + 'DefaultValue', ...rest].join('.')
     },
     state () {
-      return get(this.$parent, this.path) || get(this.$parent, this.pathDefault)
+      const value = get(this.$parent, this.path)
+      if (value === undefined) {
+        return this.defaultState
+      } else {
+        return value
+      }
     },
     defaultState () {
       return get(this.$parent, this.pathDefault)
     },
     isChanged () {
-      return this.state !== undefined && this.state !== this.defaultState
+      return this.state !== this.defaultState
     }
   },
   methods: {

From 008e711e116044f76a13e35cfd616fc211a3d6f0 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 2 Jun 2021 12:15:31 +0300
Subject: [PATCH 003/169] fix favico badge not working on chrome

---
 src/services/favicon_service/favicon_service.js | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/services/favicon_service/favicon_service.js b/src/services/favicon_service/favicon_service.js
index d1ddee41..e78aa2ae 100644
--- a/src/services/favicon_service/favicon_service.js
+++ b/src/services/favicon_service/favicon_service.js
@@ -14,6 +14,7 @@ const createFaviconService = () => {
       favcanvas.width = faviconWidth
       favcanvas.height = faviconHeight
       favimg = new Image()
+      favimg.crossOrigin = 'anonymous'
       favimg.src = favicon.href
       favcontext = favcanvas.getContext('2d')
     }

From 2a2483f4c9db7142676fb9be1e2917f007301e7f Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 2 Jun 2021 12:47:54 +0300
Subject: [PATCH 004/169] handle multiple favicons (different sizes)

---
 .../favicon_service/favicon_service.js        | 69 ++++++++++---------
 1 file changed, 37 insertions(+), 32 deletions(-)

diff --git a/src/services/favicon_service/favicon_service.js b/src/services/favicon_service/favicon_service.js
index e78aa2ae..7e19629d 100644
--- a/src/services/favicon_service/favicon_service.js
+++ b/src/services/favicon_service/favicon_service.js
@@ -1,53 +1,58 @@
-import { find } from 'lodash'
-
 const createFaviconService = () => {
-  let favimg, favcanvas, favcontext, favicon
+  const favicons = []
   const faviconWidth = 128
   const faviconHeight = 128
   const badgeRadius = 32
 
   const initFaviconService = () => {
-    const nodes = document.getElementsByTagName('link')
-    favicon = find(nodes, node => node.rel === 'icon')
-    if (favicon) {
-      favcanvas = document.createElement('canvas')
-      favcanvas.width = faviconWidth
-      favcanvas.height = faviconHeight
-      favimg = new Image()
-      favimg.crossOrigin = 'anonymous'
-      favimg.src = favicon.href
-      favcontext = favcanvas.getContext('2d')
-    }
+    const nodes = document.querySelectorAll('link[rel="icon"]')
+    nodes.forEach(favicon => {
+      if (favicon) {
+        const favcanvas = document.createElement('canvas')
+        favcanvas.width = faviconWidth
+        favcanvas.height = faviconHeight
+        const favimg = new Image()
+        favimg.crossOrigin = 'anonymous'
+        favimg.src = favicon.href
+        const favcontext = favcanvas.getContext('2d')
+        favicons.push({ favcanvas, favimg, favcontext, favicon })
+      }
+    })
   }
 
   const isImageLoaded = (img) => img.complete && img.naturalHeight !== 0
 
   const clearFaviconBadge = () => {
-    if (!favimg || !favcontext || !favicon) return
+    if (favicons.length === 0) return
+    favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => {
+      if (!favimg || !favcontext || !favicon) return
 
-    favcontext.clearRect(0, 0, faviconWidth, faviconHeight)
-    if (isImageLoaded(favimg)) {
-      favcontext.drawImage(favimg, 0, 0, favimg.width, favimg.height, 0, 0, faviconWidth, faviconHeight)
-    }
-    favicon.href = favcanvas.toDataURL('image/png')
+      favcontext.clearRect(0, 0, faviconWidth, faviconHeight)
+      if (isImageLoaded(favimg)) {
+        favcontext.drawImage(favimg, 0, 0, favimg.width, favimg.height, 0, 0, faviconWidth, faviconHeight)
+      }
+      favicon.href = favcanvas.toDataURL('image/png')
+    })
   }
 
   const drawFaviconBadge = () => {
-    if (!favimg || !favcontext || !favcontext) return
-
+    if (favicons.length === 0) return
     clearFaviconBadge()
+    favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => {
+      if (!favimg || !favcontext || !favcontext) return
 
-    const style = getComputedStyle(document.body)
-    const badgeColor = `${style.getPropertyValue('--badgeNotification') || 'rgb(240, 100, 100)'}`
+      const style = getComputedStyle(document.body)
+      const badgeColor = `${style.getPropertyValue('--badgeNotification') || 'rgb(240, 100, 100)'}`
 
-    if (isImageLoaded(favimg)) {
-      favcontext.drawImage(favimg, 0, 0, favimg.width, favimg.height, 0, 0, faviconWidth, faviconHeight)
-    }
-    favcontext.fillStyle = badgeColor
-    favcontext.beginPath()
-    favcontext.arc(faviconWidth - badgeRadius, badgeRadius, badgeRadius, 0, 2 * Math.PI, false)
-    favcontext.fill()
-    favicon.href = favcanvas.toDataURL('image/png')
+      if (isImageLoaded(favimg)) {
+        favcontext.drawImage(favimg, 0, 0, favimg.width, favimg.height, 0, 0, faviconWidth, faviconHeight)
+      }
+      favcontext.fillStyle = badgeColor
+      favcontext.beginPath()
+      favcontext.arc(faviconWidth - badgeRadius, badgeRadius, badgeRadius, 0, 2 * Math.PI, false)
+      favcontext.fill()
+      favicon.href = favcanvas.toDataURL('image/png')
+    })
   }
 
   return {

From 20ce6468520e76b0fb2931a5fac368157d950b1d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 03:14:48 +0300
Subject: [PATCH 005/169] [WIP] MUCH better approach to replacing emojis with
 still versions

---
 .babelrc                                      |   4 +-
 package.json                                  |   4 +-
 src/components/rich_content/rich_content.jsx  |  66 +++++++++
 src/components/rich_content/rich_content.scss |   0
 .../status_content/status_content.js          |   6 +-
 .../status_content/status_content.vue         |  14 +-
 .../entity_normalizer.service.js              |   3 +
 .../mini_html_converter.service.js            | 137 ++++++++++++++++++
 .../mini_post_html_processor.spec.js          | 130 +++++++++++++++++
 9 files changed, 350 insertions(+), 14 deletions(-)
 create mode 100644 src/components/rich_content/rich_content.jsx
 create mode 100644 src/components/rich_content/rich_content.scss
 create mode 100644 src/services/mini_html_converter/mini_html_converter.service.js
 create mode 100644 test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js

diff --git a/.babelrc b/.babelrc
index 3c732dd1..94521147 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,5 +1,5 @@
 {
-  "presets": ["@babel/preset-env"],
-  "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-transform-vue-jsx"],
+  "presets": ["@babel/preset-env", "@vue/babel-preset-jsx"],
+  "plugins": ["@babel/plugin-transform-runtime", "lodash"],
   "comments": false
 }
diff --git a/package.json b/package.json
index 99301266..5134a8b1 100644
--- a/package.json
+++ b/package.json
@@ -47,8 +47,8 @@
     "@babel/preset-env": "^7.7.6",
     "@babel/register": "^7.7.4",
     "@ungap/event-target": "^0.1.0",
-    "@vue/babel-helper-vue-jsx-merge-props": "^1.0.0",
-    "@vue/babel-plugin-transform-vue-jsx": "^1.1.2",
+    "@vue/babel-helper-vue-jsx-merge-props": "^1.2.1",
+    "@vue/babel-preset-jsx": "^1.2.4",
     "@vue/test-utils": "^1.0.0-beta.26",
     "autoprefixer": "^6.4.0",
     "babel-eslint": "^7.0.0",
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
new file mode 100644
index 00000000..3b29eb4c
--- /dev/null
+++ b/src/components/rich_content/rich_content.jsx
@@ -0,0 +1,66 @@
+import Vue from 'vue'
+import { mapGetters } from 'vuex'
+import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
+import { convertHtml, getTagName, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
+import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import StillImage from 'src/components/still-image/still-image.vue'
+
+import './rich_content.scss'
+
+export default Vue.component('RichContent', {
+  name: 'RichContent',
+  props: {
+    html: {
+      required: true,
+      type: String
+    },
+    emoji: {
+      required: true,
+      type: Array
+    }
+  },
+  render (h) {
+    const renderImage = (tag) => {
+      return <StillImage {...{ attrs: getAttrs(tag) }} />
+    }
+    const structure = convertHtml(this.html)
+    const processItem = (item) => {
+      if (typeof item === 'string') {
+        if (item.includes(':')) {
+          return processTextForEmoji(
+            item,
+            this.emoji,
+            ({ shortcode, url }) => {
+              return <StillImage
+                class="emoji"
+                src={url}
+                title={`:${shortcode}:`}
+                alt={`:${shortcode}:`}
+              />
+            }
+          )
+        } else {
+          return item
+        }
+      }
+      if (Array.isArray(item)) {
+        const [opener, children] = item
+        const Tag = getTagName(opener)
+        if (Tag === 'img') {
+          return renderImage(opener)
+        }
+        if (children !== undefined) {
+          return <Tag {...{ attrs: getAttrs(opener) }}>
+            { children.map(processItem) }
+          </Tag>
+        } else {
+          return <Tag/>
+        }
+      }
+    }
+    return <div>
+      { structure.map(processItem) }
+    </div>
+  }
+})
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
new file mode 100644
index 00000000..e69de29b
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index a6f79d76..571f1a78 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -1,6 +1,7 @@
 import Attachment from '../attachment/attachment.vue'
 import Poll from '../poll/poll.vue'
 import Gallery from '../gallery/gallery.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
 import LinkPreview from '../link-preview/link-preview.vue'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import fileType from 'src/services/file_type/file_type.service'
@@ -125,7 +126,7 @@ const StatusContent = {
       return this.mergedConfig.maxThumbnails
     },
     postBodyHtml () {
-      const html = this.status.statusnet_html
+      const html = this.status.raw_html
 
       if (this.mergedConfig.greentext) {
         try {
@@ -164,7 +165,8 @@ const StatusContent = {
     Attachment,
     Poll,
     Gallery,
-    LinkPreview
+    LinkPreview,
+    RichContent
   },
   methods: {
     linkClicked (event) {
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 90bfaf40..c1a78db9 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,5 +1,4 @@
 <template>
-  <!-- eslint-disable vue/no-v-html -->
   <div class="StatusContent">
     <slot name="header" />
     <div
@@ -7,11 +6,11 @@
       class="summary-wrapper"
       :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
     >
-      <div
+      <RichContent
         class="media-body summary"
         @click.prevent="linkClicked"
-        v-html="status.summary_html"
-      />
+        :html="status.summary_raw_html"
+        :emoji="status.emojis"/>
       <button
         v-if="longSubject && showingLongSubject"
         class="button-unstyled -link tall-subject-hider"
@@ -40,13 +39,13 @@
       >
         {{ $t("general.show_more") }}
       </button>
-      <div
+      <RichContent
         v-if="!hideSubjectStatus"
         :class="{ 'single-line': singleLine }"
         class="status-content media-body"
         @click.prevent="linkClicked"
-        v-html="postBodyHtml"
-      />
+        :html="postBodyHtml"
+        :emoji="status.emojis"/>
       <button
         v-if="hideSubjectStatus"
         class="button-unstyled -link cw-status-hider"
@@ -127,7 +126,6 @@
     </div>
     <slot name="footer" />
   </div>
-  <!-- eslint-enable vue/no-v-html -->
 </template>
 
 <script src="./status_content.js" ></script>
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index a4ddf927..9f63feb6 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -267,6 +267,8 @@ export const parseStatus = (data) => {
     output.nsfw = data.sensitive
 
     output.statusnet_html = addEmojis(data.content, data.emojis)
+    output.raw_html = data.content
+    output.emojis = data.emojis
 
     output.tags = data.tags
 
@@ -293,6 +295,7 @@ export const parseStatus = (data) => {
       output.retweeted_status = parseStatus(data.reblog)
     }
 
+    output.summary_raw_html = escape(data.spoiler_text)
     output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
     output.external_url = data.url
     output.poll = data.poll
diff --git a/src/services/mini_html_converter/mini_html_converter.service.js b/src/services/mini_html_converter/mini_html_converter.service.js
new file mode 100644
index 00000000..00d20019
--- /dev/null
+++ b/src/services/mini_html_converter/mini_html_converter.service.js
@@ -0,0 +1,137 @@
+/**
+ * This is a not-so-tiny purpose-built HTML parser/processor. It was made for use
+ * with StatusText component for purpose of replacing tags with vue components
+ *
+ * known issue: doesn't handle CDATA so nested CDATA might not work well
+ *
+ * @param {Object} input - input data
+ * @param {(string) => string} lineProcessor - function that will be called on every line
+ * @param {{ key[string]: (string) => string}} tagProcessor - map of processors for tags
+ * @return {string} processed html
+ */
+export const convertHtml = (html) => {
+  // Elements that are implicitly self-closing
+  // https://developer.mozilla.org/en-US/docs/Glossary/empty_element
+  const emptyElements = new Set([
+    'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
+    'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
+  ])
+  // TODO For future - also parse HTML5 multi-source components?
+
+  const buffer = [] // Current output buffer
+  const levels = [['', buffer]] // How deep we are in tags and which tags were there
+  let textBuffer = '' // Current line content
+  let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag
+
+  const getCurrentBuffer = () => {
+    return levels[levels.length - 1][1]
+  }
+
+  const flushText = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
+    if (textBuffer === '') return
+    getCurrentBuffer().push(textBuffer)
+    textBuffer = ''
+  }
+
+  const handleSelfClosing = (tag) => {
+    getCurrentBuffer().push([tag])
+  }
+
+  const handleOpen = (tag) => {
+    const curBuf = getCurrentBuffer()
+    const newLevel = [tag, []]
+    levels.push(newLevel)
+    curBuf.push(newLevel)
+  }
+
+  const handleClose = (tag) => {
+    const currentTag = levels[levels.length - 1]
+    if (getTagName(levels[levels.length - 1][0]) === getTagName(tag)) {
+      currentTag.push(tag)
+      levels.pop()
+    } else {
+      getCurrentBuffer().push(tag)
+    }
+  }
+
+  for (let i = 0; i < html.length; i++) {
+    const char = html[i]
+    if (char === '<' && tagBuffer === null) {
+      flushText()
+      tagBuffer = char
+    } else if (char !== '>' && tagBuffer !== null) {
+      tagBuffer += char
+    } else if (char === '>' && tagBuffer !== null) {
+      tagBuffer += char
+      const tagFull = tagBuffer
+      tagBuffer = null
+      const tagName = getTagName(tagFull)
+      if (tagFull[1] === '/') {
+        handleClose(tagFull)
+      } else if (emptyElements.has(tagName) || tagFull[tagFull.length - 2] === '/') {
+        // self-closing
+        handleSelfClosing(tagFull)
+      } else {
+        handleOpen(tagFull)
+      }
+    } else {
+      textBuffer += char
+    }
+  }
+  if (tagBuffer) {
+    textBuffer += tagBuffer
+  }
+
+  flushText()
+  return buffer
+}
+
+// Extracts tag name from tag, i.e. <span a="b"> => span
+export const getTagName = (tag) => {
+  const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag)
+  return result && (result[1] || result[2])
+}
+
+export const processTextForEmoji = (text, emojis, processor) => {
+  const buffer = []
+  let textBuffer = ''
+  for (let i = 0; i < text.length; i++) {
+    const char = text[i]
+    if (char === ':') {
+      const next = text.slice(i + 1)
+      let found = false
+      for (let emoji of emojis) {
+        if (next.slice(0, emoji.shortcode.length + 1) === (emoji.shortcode + ':')) {
+          found = emoji
+          break
+        }
+      }
+      if (found) {
+        buffer.push(textBuffer)
+        textBuffer = ''
+        buffer.push(processor(found))
+        i += found.shortcode.length + 1
+      } else {
+        textBuffer += char
+      }
+    } else {
+      textBuffer += char
+    }
+  }
+  return buffer
+}
+
+export const getAttrs = tag => {
+  const innertag = tag
+    .substring(1, tag.length - 1)
+    .replace(new RegExp('^' + getTagName(tag)), '')
+    .replace(/\/?$/, '')
+    .trim()
+  const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=(?:"([^"]+?)"|'([^']+?)'))?/gi))
+    .map(([trash, key, value]) => [key, value])
+    .map(([k, v]) => {
+      if (!v) return [k, true]
+      return [k, v]
+    })
+  return Object.fromEntries(attrs)
+}
diff --git a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
new file mode 100644
index 00000000..41818f57
--- /dev/null
+++ b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
@@ -0,0 +1,130 @@
+import { convertHtml, processTextForEmoji } from 'src/services/mini_html_converter/mini_html_converter.service.js'
+
+describe('MiniHtmlConverter', () => {
+  describe('convertHtml', () => {
+    it('converts html into a tree structure', () => {
+      const inputOutput = '1 <p>2</p> <b>3<img src="a">4</b>5'
+      expect(convertHtml(inputOutput)).to.eql([
+        '1 ',
+        [
+          '<p>',
+          ['2'],
+          '</p>'
+        ],
+        ' ',
+        [
+          '<b>',
+          [
+            '3',
+            ['<img src="a">'],
+            '4'
+          ],
+          '</b>'
+        ],
+        '5'
+      ])
+    })
+    it('converts html to tree while preserving tag formatting', () => {
+      const inputOutput = '1 <p >2</p><b >3<img   src="a">4</b>5'
+      expect(convertHtml(inputOutput)).to.eql([
+        '1 ',
+        [
+          '<p >',
+          ['2'],
+          '</p>'
+        ],
+        [
+          '<b >',
+          [
+            '3',
+            ['<img   src="a">'],
+            '4'
+          ],
+          '</b>'
+        ],
+        '5'
+      ])
+    })
+    it('converts semi-broken html', () => {
+      const inputOutput = '1 <br> 2 <p> 42'
+      expect(convertHtml(inputOutput)).to.eql([
+        '1 ',
+        ['<br>'],
+        ' 2 ',
+        [
+          '<p>',
+          [' 42']
+        ]
+      ])
+    })
+    it('realistic case', () => {
+      const inputOutput = '<p><span class="h-card"><a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">@<span>benis</span></a></span> <span class="h-card"><a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">@<span>hj</span></a></span> nice</p>'
+      expect(convertHtml(inputOutput)).to.eql([
+        [
+          '<p>',
+          [
+            [
+              '<span class="h-card">',
+              [
+                [
+                  '<a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">',
+                  [
+                    '@',
+                    [
+                      '<span>',
+                      [
+                        'benis'
+                      ],
+                      '</span>'
+                    ]
+                  ],
+                  '</a>'
+                ]
+              ],
+              '</span>'
+            ],
+            ' ',
+            [
+              '<span class="h-card">',
+              [
+                [
+                  '<a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">',
+                  [
+                    '@',
+                    [
+                      '<span>',
+                      [
+                        'hj'
+                      ],
+                      '</span>'
+                    ]
+                  ],
+                  '</a>'
+                ]
+              ],
+              '</span>'
+            ],
+            ' nice'
+          ],
+          '</p>'
+        ]
+      ])
+    })
+  })
+  describe('processTextForEmoji', () => {
+    it('processes all emoji in text', () => {
+      const inputOutput = 'Hello from finland! :lol: We have best water! :lmao:'
+      const emojis = [
+        { shortcode: 'lol', src: 'LOL' },
+        { shortcode: 'lmao', src: 'LMAO' }
+      ]
+      const processor = ({ shortcode, src }) => ({ shortcode, src })
+      expect(processTextForEmoji(inputOutput, emojis, processor)).to.eql([
+        'Hello from finland! ',
+        { shortcode: 'lol', src: 'LOL' },
+        ' We have best water! ',
+        { shortcode: 'lmao', src: 'LMAO' }
+      ])
+    })
+  })
+})

From 5970ddf9ac5ed7b7a855ed4c025a2f5f62e256fa Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 12:22:15 +0300
Subject: [PATCH 006/169] fix escaped apostrophes

---
 src/components/rich_content/rich_content.jsx | 9 +++------
 1 file changed, 3 insertions(+), 6 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 3b29eb4c..a5c010de 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,9 +1,6 @@
 import Vue from 'vue'
-import { mapGetters } from 'vuex'
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
+import { unescape } from 'lodash'
 import { convertHtml, getTagName, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
-import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import StillImage from 'src/components/still-image/still-image.vue'
 
 import './rich_content.scss'
@@ -29,7 +26,7 @@ export default Vue.component('RichContent', {
       if (typeof item === 'string') {
         if (item.includes(':')) {
           return processTextForEmoji(
-            item,
+            unescape(item),
             this.emoji,
             ({ shortcode, url }) => {
               return <StillImage
@@ -41,7 +38,7 @@ export default Vue.component('RichContent', {
             }
           )
         } else {
-          return item
+          return unescape(item)
         }
       }
       if (Array.isArray(item)) {

From 35dedf8416b423abb867b052d37f14becd0de34a Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 12:24:11 +0300
Subject: [PATCH 007/169] lint

---
 src/components/emoji_input/emoji_input.vue       |  2 +-
 src/components/status_content/status_content.vue | 10 ++++++----
 2 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index e6f9a9d3..aa2950ce 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,9 +1,9 @@
 <template>
   <div
+    ref="root"
     v-click-outside="onClickOutside"
     class="emoji-input"
     :class="{ 'with-picker': !hideEmojiButton }"
-    ref='root'
   >
     <slot />
     <template v-if="enableEmojiPicker">
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index c1a78db9..fa095d43 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -8,9 +8,10 @@
     >
       <RichContent
         class="media-body summary"
-        @click.prevent="linkClicked"
         :html="status.summary_raw_html"
-        :emoji="status.emojis"/>
+        :emoji="status.emojis"
+        @click.prevent="linkClicked"
+      />
       <button
         v-if="longSubject && showingLongSubject"
         class="button-unstyled -link tall-subject-hider"
@@ -43,9 +44,10 @@
         v-if="!hideSubjectStatus"
         :class="{ 'single-line': singleLine }"
         class="status-content media-body"
-        @click.prevent="linkClicked"
         :html="postBodyHtml"
-        :emoji="status.emojis"/>
+        :emoji="status.emojis"
+        @click.prevent="linkClicked"
+      />
       <button
         v-if="hideSubjectStatus"
         class="button-unstyled -link cw-status-hider"

From be79643bcf6f05df500f0d6bcb773f07dd15da3d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 12:38:27 +0300
Subject: [PATCH 008/169] fix emoji processor not leaving string as-is if no
 emoji are found

---
 .../mini_html_converter.service.js            |  1 +
 .../mini_post_html_processor.spec.js          | 29 ++++++++++++++++++-
 2 files changed, 29 insertions(+), 1 deletion(-)

diff --git a/src/services/mini_html_converter/mini_html_converter.service.js b/src/services/mini_html_converter/mini_html_converter.service.js
index 00d20019..879ff544 100644
--- a/src/services/mini_html_converter/mini_html_converter.service.js
+++ b/src/services/mini_html_converter/mini_html_converter.service.js
@@ -118,6 +118,7 @@ export const processTextForEmoji = (text, emojis, processor) => {
       textBuffer += char
     }
   }
+  if (textBuffer) buffer.push(textBuffer)
   return buffer
 }
 
diff --git a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
index 41818f57..c4e3f688 100644
--- a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
+++ b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
@@ -57,7 +57,7 @@ describe('MiniHtmlConverter', () => {
         ]
       ])
     })
-    it('realistic case', () => {
+    it('realistic case 1', () => {
       const inputOutput = '<p><span class="h-card"><a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">@<span>benis</span></a></span> <span class="h-card"><a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">@<span>hj</span></a></span> nice</p>'
       expect(convertHtml(inputOutput)).to.eql([
         [
@@ -110,6 +110,24 @@ describe('MiniHtmlConverter', () => {
         ]
       ])
     })
+    it('realistic case 2', () => {
+      const inputOutput = 'Country improv: give me a city<br/>Audience: Memphis<br/>Improv troupe: come on, a better one<br/>Audience: el paso'
+      expect(convertHtml(inputOutput)).to.eql([
+        'Country improv: give me a city',
+        [
+          '<br/>'
+        ],
+        'Audience: Memphis',
+        [
+          '<br/>'
+        ],
+        'Improv troupe: come on, a better one',
+        [
+          '<br/>'
+        ],
+        'Audience: el paso'
+      ])
+    })
   })
   describe('processTextForEmoji', () => {
     it('processes all emoji in text', () => {
@@ -126,5 +144,14 @@ describe('MiniHtmlConverter', () => {
         { shortcode: 'lmao', src: 'LMAO' }
       ])
     })
+    it('leaves text as is', () => {
+      const inputOutput = 'Number one: that\'s terror'
+      const emojis = []
+      const processor = ({ shortcode, src }) => ({ shortcode, src })
+      expect(processTextForEmoji(inputOutput, emojis, processor)).to.eql([
+        'Number one: that\'s terror'
+      ])
+    })
+  })
   })
 })

From a2459c2187bc051c18715591119a48ccb0f308e7 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 12:49:54 +0300
Subject: [PATCH 009/169] move styles to richcontent

---
 src/components/rich_content/rich_content.jsx  |  9 +--
 src/components/rich_content/rich_content.scss | 70 +++++++++++++++++++
 .../status_content/status_content.vue         | 55 +--------------
 3 files changed, 76 insertions(+), 58 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index a5c010de..7bff23cd 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -19,7 +19,8 @@ export default Vue.component('RichContent', {
   },
   render (h) {
     const renderImage = (tag) => {
-      return <StillImage {...{ attrs: getAttrs(tag) }} />
+      const attrs = getAttrs(tag)
+      return <StillImage {...{ attrs }} class="img"/>
     }
     const structure = convertHtml(this.html)
     const processItem = (item) => {
@@ -30,7 +31,7 @@ export default Vue.component('RichContent', {
             this.emoji,
             ({ shortcode, url }) => {
               return <StillImage
-                class="emoji"
+                class="emoji img"
                 src={url}
                 title={`:${shortcode}:`}
                 alt={`:${shortcode}:`}
@@ -56,8 +57,8 @@ export default Vue.component('RichContent', {
         }
       }
     }
-    return <div>
+    return <span class="RichContent">
       { structure.map(processItem) }
-    </div>
+    </span>
   }
 })
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
index e69de29b..2fcd3911 100644
--- a/src/components/rich_content/rich_content.scss
+++ b/src/components/rich_content/rich_content.scss
@@ -0,0 +1,70 @@
+.RichContent {
+  font-family: var(--postFont, sans-serif);
+  line-height: 1.4em;
+  white-space: pre-wrap;
+  overflow-wrap: break-word;
+  word-wrap: break-word;
+  word-break: break-word;
+
+  blockquote {
+    margin: 0.2em 0 0.2em 2em;
+    font-style: italic;
+  }
+
+  pre {
+    overflow: auto;
+  }
+
+  code,
+  samp,
+  kbd,
+  var,
+  pre {
+    font-family: var(--postCodeFont, monospace);
+  }
+
+  p {
+    margin: 0 0 1em 0;
+  }
+
+  p:last-child {
+    margin: 0 0 0 0;
+  }
+
+  h1 {
+    font-size: 1.1em;
+    line-height: 1.2em;
+    margin: 1.4em 0;
+  }
+
+  h2 {
+    font-size: 1.1em;
+    margin: 1em 0;
+  }
+
+  h3 {
+    font-size: 1em;
+    margin: 1.2em 0;
+  }
+
+  h4 {
+    margin: 1.1em 0;
+  }
+
+  .img {
+    display: inline-block;
+  }
+
+  .emoji {
+    width: 32px;
+    height: 32px;
+  }
+
+  .img,
+  video {
+    max-width: 100%;
+    max-height: 400px;
+    vertical-align: middle;
+    object-fit: contain;
+  }
+}
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index fa095d43..31cd5a2e 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -185,16 +185,11 @@ $status-margin: 0.75em;
     }
   }
 
-  img, video {
+  video {
     max-width: 100%;
     max-height: 400px;
     vertical-align: middle;
     object-fit: contain;
-
-    &.emoji {
-      width: 32px;
-      height: 32px;
-    }
   }
 
   .summary-wrapper {
@@ -230,54 +225,6 @@ $status-margin: 0.75em;
   }
 
   .status-content {
-    font-family: var(--postFont, sans-serif);
-    line-height: 1.4em;
-    white-space: pre-wrap;
-    overflow-wrap: break-word;
-    word-wrap: break-word;
-    word-break: break-word;
-
-    blockquote {
-      margin: 0.2em 0 0.2em 2em;
-      font-style: italic;
-    }
-
-    pre {
-      overflow: auto;
-    }
-
-    code, samp, kbd, var, pre {
-      font-family: var(--postCodeFont, monospace);
-    }
-
-    p {
-      margin: 0 0 1em 0;
-    }
-
-    p:last-child {
-      margin: 0 0 0 0;
-    }
-
-    h1 {
-      font-size: 1.1em;
-      line-height: 1.2em;
-      margin: 1.4em 0;
-    }
-
-    h2 {
-      font-size: 1.1em;
-      margin: 1.0em 0;
-    }
-
-    h3 {
-      font-size: 1em;
-      margin: 1.2em 0;
-    }
-
-    h4 {
-      margin: 1.1em 0;
-    }
-
     &.single-line {
       white-space: nowrap;
       text-overflow: ellipsis;

From 1923ed84d451011df42e47a85060cc754a011e27 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 16:15:32 +0300
Subject: [PATCH 010/169] more tests

---
 .../mini_post_html_processor.spec.js          | 35 ++++++++++++-------
 1 file changed, 22 insertions(+), 13 deletions(-)

diff --git a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
index c4e3f688..b42f5f35 100644
--- a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
+++ b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
@@ -1,10 +1,10 @@
-import { convertHtml, processTextForEmoji } from 'src/services/mini_html_converter/mini_html_converter.service.js'
+import { convertHtml, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
 
 describe('MiniHtmlConverter', () => {
   describe('convertHtml', () => {
     it('converts html into a tree structure', () => {
-      const inputOutput = '1 <p>2</p> <b>3<img src="a">4</b>5'
-      expect(convertHtml(inputOutput)).to.eql([
+      const input = '1 <p>2</p> <b>3<img src="a">4</b>5'
+      expect(convertHtml(input)).to.eql([
         '1 ',
         [
           '<p>',
@@ -25,8 +25,8 @@ describe('MiniHtmlConverter', () => {
       ])
     })
     it('converts html to tree while preserving tag formatting', () => {
-      const inputOutput = '1 <p >2</p><b >3<img   src="a">4</b>5'
-      expect(convertHtml(inputOutput)).to.eql([
+      const input = '1 <p >2</p><b >3<img   src="a">4</b>5'
+      expect(convertHtml(input)).to.eql([
         '1 ',
         [
           '<p >',
@@ -46,8 +46,8 @@ describe('MiniHtmlConverter', () => {
       ])
     })
     it('converts semi-broken html', () => {
-      const inputOutput = '1 <br> 2 <p> 42'
-      expect(convertHtml(inputOutput)).to.eql([
+      const input = '1 <br> 2 <p> 42'
+      expect(convertHtml(input)).to.eql([
         '1 ',
         ['<br>'],
         ' 2 ',
@@ -58,8 +58,8 @@ describe('MiniHtmlConverter', () => {
       ])
     })
     it('realistic case 1', () => {
-      const inputOutput = '<p><span class="h-card"><a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">@<span>benis</span></a></span> <span class="h-card"><a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">@<span>hj</span></a></span> nice</p>'
-      expect(convertHtml(inputOutput)).to.eql([
+      const input = '<p><span class="h-card"><a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">@<span>benis</span></a></span> <span class="h-card"><a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">@<span>hj</span></a></span> nice</p>'
+      expect(convertHtml(input)).to.eql([
         [
           '<p>',
           [
@@ -129,15 +129,16 @@ describe('MiniHtmlConverter', () => {
       ])
     })
   })
+
   describe('processTextForEmoji', () => {
     it('processes all emoji in text', () => {
-      const inputOutput = 'Hello from finland! :lol: We have best water! :lmao:'
+      const input = 'Hello from finland! :lol: We have best water! :lmao:'
       const emojis = [
         { shortcode: 'lol', src: 'LOL' },
         { shortcode: 'lmao', src: 'LMAO' }
       ]
       const processor = ({ shortcode, src }) => ({ shortcode, src })
-      expect(processTextForEmoji(inputOutput, emojis, processor)).to.eql([
+      expect(processTextForEmoji(input, emojis, processor)).to.eql([
         'Hello from finland! ',
         { shortcode: 'lol', src: 'LOL' },
         ' We have best water! ',
@@ -145,13 +146,21 @@ describe('MiniHtmlConverter', () => {
       ])
     })
     it('leaves text as is', () => {
-      const inputOutput = 'Number one: that\'s terror'
+      const input = 'Number one: that\'s terror'
       const emojis = []
       const processor = ({ shortcode, src }) => ({ shortcode, src })
-      expect(processTextForEmoji(inputOutput, emojis, processor)).to.eql([
+      expect(processTextForEmoji(input, emojis, processor)).to.eql([
         'Number one: that\'s terror'
       ])
     })
   })
+
+  describe('getAttrs', () => {
+    it('extracts arguments from tag', () => {
+      const input = '<img src="boop" cool ebin="true">'
+      const output = { src: 'boop', cool: true, ebin: 'true' }
+
+      expect(getAttrs(input)).to.eql(output)
+    })
   })
 })

From 22c8f71945c6d114bf4db89c87eb1b166775f2d6 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 16:16:10 +0300
Subject: [PATCH 011/169] mention link

---
 src/components/mention_link/mention_link.js   | 71 +++++++++++++++++++
 src/components/mention_link/mention_link.scss | 49 +++++++++++++
 src/components/mention_link/mention_link.vue  | 17 +++++
 src/components/rich_content/rich_content.jsx  | 16 ++++-
 .../status_content/status_content.js          |  7 ++
 src/modules/users.js                          |  4 ++
 6 files changed, 161 insertions(+), 3 deletions(-)
 create mode 100644 src/components/mention_link/mention_link.js
 create mode 100644 src/components/mention_link/mention_link.scss
 create mode 100644 src/components/mention_link/mention_link.vue

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
new file mode 100644
index 00000000..ade598d8
--- /dev/null
+++ b/src/components/mention_link/mention_link.js
@@ -0,0 +1,71 @@
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { getTextColor, rgb2hex } from 'src/services/color_convert/color_convert.js'
+import { mapGetters, mapState } from 'vuex'
+import { convert } from 'chromatism'
+
+const MentionLink = {
+  name: 'MentionLink',
+  props: {
+    url: {
+      required: true,
+      type: String
+    },
+    content: {
+      required: true,
+      type: String
+    },
+    origattrs: {
+      required: true,
+      type: Object
+    }
+  },
+  methods: {
+    onClick () {
+      const link = generateProfileLink(this.user.id, this.user.screen_name)
+      this.$router.push(link)
+    }
+  },
+  computed: {
+    user () {
+      return this.$store.getters.findUserByUrl(this.url)
+    },
+    isYou () {
+      // FIXME why user !== currentUser???
+      return this.user.screen_name === this.currentUser.screen_name
+    },
+    userName () {
+      return this.userNameFullUi.split('@')[0]
+    },
+    userNameFull () {
+      return this.user.screen_name
+    },
+    userNameFullUi () {
+      return this.user.screen_name_ui
+    },
+    highlight () {
+      return this.mergedConfig.highlight[this.user.screen_name]
+    },
+    bg () {
+      if (this.highlight) return this.highlight.color
+    },
+    text () {
+      if (this.bg) {
+        const linkColor = this.mergedConfig.customTheme.colors.link
+        const color = getTextColor(convert(this.bg).rgb, convert(linkColor).rgb)
+        return rgb2hex(color)
+      }
+    },
+    style () {
+      return [
+        this.bg && `--mention-bg: ${this.bg}`,
+        this.text && `--mention-text: ${this.text}`
+      ].filter(_ => _).join('; ')
+    },
+    ...mapGetters(['mergedConfig']),
+    ...mapState({
+      currentUser: state => state.users.currentUser
+    })
+  }
+}
+
+export default MentionLink
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
new file mode 100644
index 00000000..237d9205
--- /dev/null
+++ b/src/components/mention_link/mention_link.scss
@@ -0,0 +1,49 @@
+.MentionLink {
+  position: relative;
+  white-space: normal;
+  display: inline-block;
+
+  & .new,
+  & .original,
+  & .full {
+    padding: 0 2px;
+    margin: 0 -2px;
+    display: inline-block;
+    border-radius: 2px;
+  }
+
+  .original {
+    opacity: 0.5;
+  }
+
+  .full {
+    pointer-events: none;
+    position: absolute;
+    opacity: 0;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    word-wrap: normal;
+    white-space: nowrap;
+    transition: opacity 0.2s ease;
+    background-color: var(--mention-bg, var(--popover));
+    color: var(--mention-text, var(--link));
+    z-index: 1;
+  }
+
+  .new {
+    background-color: var(--mention-bg);
+    color: var(--mention-text, var(--link));
+
+    &.-you {
+      & .shortName,
+      & .full {
+        font-weight: 600;
+      }
+    }
+  }
+
+  &:hover .new .full {
+    opacity: 1;
+  }
+}
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
new file mode 100644
index 00000000..84ed3205
--- /dev/null
+++ b/src/components/mention_link/mention_link.vue
@@ -0,0 +1,17 @@
+<template>
+  <span class="MentionLink">
+    <a v-if="!user" v-html="content" href="url" class="original"/>
+    <span v-if="user" class="new" :style="style" :class="{ '-you': isYou }" >
+      <button class="button-unstyled short" @click.prevent="onClick">
+        <span class="shortName">@<span v-html="userName" /></span> <span v-if="isYou">(You)</span>
+      </button>
+      <span class="full" v-if="userName !== userNameFull">
+        @<span v-html="userNameFull" />
+      </span>
+    </span>
+  </span>
+</template>
+
+<script src="./mention_link.js"/>
+
+<style lang="scss" src="./mention_link.scss"/>
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 7bff23cd..00cf6623 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,7 +1,8 @@
 import Vue from 'vue'
-import { unescape } from 'lodash'
+import { unescape, flattenDeep } from 'lodash'
 import { convertHtml, getTagName, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
 import StillImage from 'src/components/still-image/still-image.vue'
+import MentionLink from 'src/components/mention_link/mention_link.vue'
 
 import './rich_content.scss'
 
@@ -22,6 +23,9 @@ export default Vue.component('RichContent', {
       const attrs = getAttrs(tag)
       return <StillImage {...{ attrs }} class="img"/>
     }
+    const renderMention = (attrs, children) => {
+      return <MentionLink url={attrs.href} content={flattenDeep(children).join('')} origattrs={attrs}/>
+    }
     const structure = convertHtml(this.html)
     const processItem = (item) => {
       if (typeof item === 'string') {
@@ -45,8 +49,14 @@ export default Vue.component('RichContent', {
       if (Array.isArray(item)) {
         const [opener, children] = item
         const Tag = getTagName(opener)
-        if (Tag === 'img') {
-          return renderImage(opener)
+        switch (Tag) {
+          case 'img':
+            return renderImage(opener)
+          case 'a':
+            const attrs = getAttrs(opener)
+            if (attrs['class'] && attrs['class'].includes('mention')) {
+              return renderMention(attrs, children)
+            }
         }
         if (children !== undefined) {
           return <Tag {...{ attrs: getAttrs(opener) }}>
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 571f1a78..79408e38 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -168,6 +168,13 @@ const StatusContent = {
     LinkPreview,
     RichContent
   },
+  mounted () {
+    const { attentions } = this.status
+    attentions.forEach(attn => {
+      const { id } = attn
+      this.$store.state.api.backendInteractor.fetchUserIfMissing(this.$store, id)
+    })
+  },
   methods: {
     linkClicked (event) {
       const target = event.target.closest('.status-content a')
diff --git a/src/modules/users.js b/src/modules/users.js
index 2b416f94..9ed06897 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -246,6 +246,10 @@ export const getters = {
     }
     return result
   },
+  findUserByUrl: state => query => {
+    return state.users
+      .find(u => u.statusnet_profile_url.toLowerCase() === query.toLowerCase())
+  },
   relationship: state => id => {
     const rel = id && state.relationships[id]
     return rel || { id, loading: true }

From b0ae32e309134f0e91026c6712f2e9081f493c22 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 16:31:39 +0300
Subject: [PATCH 012/169] made getAttrs correctly handle both ' and "

---
 .../mini_html_converter/mini_html_converter.service.js        | 4 ++--
 .../tiny_post_html_processor/mini_post_html_processor.spec.js | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/services/mini_html_converter/mini_html_converter.service.js b/src/services/mini_html_converter/mini_html_converter.service.js
index 879ff544..01f8adf8 100644
--- a/src/services/mini_html_converter/mini_html_converter.service.js
+++ b/src/services/mini_html_converter/mini_html_converter.service.js
@@ -128,11 +128,11 @@ export const getAttrs = tag => {
     .replace(new RegExp('^' + getTagName(tag)), '')
     .replace(/\/?$/, '')
     .trim()
-  const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=(?:"([^"]+?)"|'([^']+?)'))?/gi))
+  const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi))
     .map(([trash, key, value]) => [key, value])
     .map(([k, v]) => {
       if (!v) return [k, true]
-      return [k, v]
+      return [k, v.substring(1, v.length - 1)]
     })
   return Object.fromEntries(attrs)
 }
diff --git a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
index b42f5f35..8df2fbc3 100644
--- a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
+++ b/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
@@ -157,7 +157,7 @@ describe('MiniHtmlConverter', () => {
 
   describe('getAttrs', () => {
     it('extracts arguments from tag', () => {
-      const input = '<img src="boop" cool ebin="true">'
+      const input = '<img src="boop" cool ebin=\'true\'>'
       const output = { src: 'boop', cool: true, ebin: 'true' }
 
       expect(getAttrs(input)).to.eql(output)

From aec05686d0fa300b8b8348d8d1ba2724d1dd0014 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 16:37:12 +0300
Subject: [PATCH 013/169] lint, fix warnings

---
 src/components/mention_link/mention_link.vue  | 30 ++++++++++++++++---
 .../status_content/status_content.js          |  5 ++--
 2 files changed, 28 insertions(+), 7 deletions(-)

diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 84ed3205..80aadd3f 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -1,12 +1,34 @@
 <template>
   <span class="MentionLink">
-    <a v-if="!user" v-html="content" href="url" class="original"/>
-    <span v-if="user" class="new" :style="style" :class="{ '-you': isYou }" >
-      <button class="button-unstyled short" @click.prevent="onClick">
+    <!-- eslint-disable vue/no-v-html -->
+    <a
+      v-if="!user"
+      href="url"
+      class="original"
+      v-html="content"
+    />
+    <!-- eslint-enable vue/no-v-html -->
+    <span
+      v-if="user"
+      class="new"
+      :style="style"
+      :class="{ '-you': isYou }"
+    >
+      <button
+        class="button-unstyled short"
+        @click.prevent="onClick"
+      >
+        <!-- eslint-disable vue/no-v-html -->
         <span class="shortName">@<span v-html="userName" /></span> <span v-if="isYou">(You)</span>
+        <!-- eslint-enable vue/no-v-html -->
       </button>
-      <span class="full" v-if="userName !== userNameFull">
+      <span
+        v-if="userName !== userNameFull"
+        class="full"
+      >
+        <!-- eslint-disable vue/no-v-html -->
         @<span v-html="userNameFull" />
+        <!-- eslint-enable vue/no-v-html -->
       </span>
     </span>
   </span>
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 79408e38..c21afc24 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -169,10 +169,9 @@ const StatusContent = {
     RichContent
   },
   mounted () {
-    const { attentions } = this.status
-    attentions.forEach(attn => {
+    this.status.attentions.forEach(attn => {
       const { id } = attn
-      this.$store.state.api.backendInteractor.fetchUserIfMissing(this.$store, id)
+      this.$store.dispatch('fetchUserIfMissing', id)
     })
   },
   methods: {

From 04fa1f0b2d1a92b1c653cd55f51ee7e1434b2bd7 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 18:39:51 +0300
Subject: [PATCH 014/169] some docs, added richcontent to usernames in status,
 updated stillImage to allow scale of "gif" label

---
 src/components/rich_content/rich_content.jsx  | 33 +++++++++++++++----
 src/components/rich_content/rich_content.scss |  4 +--
 src/components/status/status.js               |  7 ++--
 src/components/status/status.scss             |  8 ++---
 src/components/status/status.vue              | 12 +++----
 src/components/still-image/still-image.vue    |  3 +-
 .../entity_normalizer.service.js              |  1 +
 7 files changed, 44 insertions(+), 24 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 00cf6623..c15877c8 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -9,25 +9,41 @@ import './rich_content.scss'
 export default Vue.component('RichContent', {
   name: 'RichContent',
   props: {
+    // Original html content
     html: {
       required: true,
       type: String
     },
+    // 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
     }
   },
   render (h) {
     const renderImage = (tag) => {
-      const attrs = getAttrs(tag)
-      return <StillImage {...{ attrs }} class="img"/>
+      return <StillImage
+        {...{ attrs: getAttrs(tag) }}
+        class="img"
+      />
     }
     const renderMention = (attrs, children) => {
-      return <MentionLink url={attrs.href} content={flattenDeep(children).join('')} origattrs={attrs}/>
+      return <MentionLink
+        url={attrs.href}
+        content={flattenDeep(children).join('')}
+        origattrs={attrs}
+      />
     }
-    const structure = convertHtml(this.html)
+
+    // Processor to use with mini_html_converter
     const processItem = (item) => {
+      // Handle text noes - just add emoji
       if (typeof item === 'string') {
         if (item.includes(':')) {
           return processTextForEmoji(
@@ -46,18 +62,21 @@ export default Vue.component('RichContent', {
           return unescape(item)
         }
       }
+      // Handle tag nodes
       if (Array.isArray(item)) {
         const [opener, children] = item
         const Tag = getTagName(opener)
         switch (Tag) {
-          case 'img':
+          case 'img': // replace images with StillImage
             return renderImage(opener)
-          case 'a':
+          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)
             }
         }
+        // Render tag as is
         if (children !== undefined) {
           return <Tag {...{ attrs: getAttrs(opener) }}>
             { children.map(processItem) }
@@ -68,7 +87,7 @@ export default Vue.component('RichContent', {
       }
     }
     return <span class="RichContent">
-      { structure.map(processItem) }
+      { convertHtml(this.html).map(processItem) }
     </span>
   }
 })
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
index 2fcd3911..a486babf 100644
--- a/src/components/rich_content/rich_content.scss
+++ b/src/components/rich_content/rich_content.scss
@@ -56,8 +56,8 @@
   }
 
   .emoji {
-    width: 32px;
-    height: 32px;
+    width: var(--emoji-size, 32px);
+    height: var(--emoji-size, 32px);
   }
 
   .img,
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 470c01f1..b6414ad8 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -9,6 +9,7 @@ import UserAvatar from '../user_avatar/user_avatar.vue'
 import AvatarList from '../avatar_list/avatar_list.vue'
 import Timeago from '../timeago/timeago.vue'
 import StatusContent from '../status_content/status_content.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
 import StatusPopover from '../status_popover/status_popover.vue'
 import UserListPopover from '../user_list_popover/user_list_popover.vue'
 import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
@@ -68,7 +69,8 @@ const Status = {
     StatusPopover,
     UserListPopover,
     EmojiReactions,
-    StatusContent
+    StatusContent,
+    RichContent
   },
   props: [
     'statusoid',
@@ -136,8 +138,9 @@ const Status = {
       }
     },
     retweet () { return !!this.statusoid.retweeted_status },
+    retweeterUser () { return this.statusoid.user },
     retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
-    retweeterHtml () { return this.statusoid.user.name_html },
+    retweeterHtml () { return this.statusoid.user.name },
     retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
     status () {
       if (this.retweet) {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 58b55bc8..82088943 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -93,12 +93,8 @@ $status-margin: 0.75em;
     margin-right: 0.4em;
     text-overflow: ellipsis;
 
-    .emoji {
-      width: 14px;
-      height: 14px;
-      vertical-align: middle;
-      object-fit: contain;
-    }
+    --_still_image-label-scale: 0.25;
+    --emoji-size: 14px;
   }
 
   .status-favicon {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 00e962f3..cc5fb79f 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,5 +1,4 @@
 <template>
-  <!-- eslint-disable vue/no-v-html -->
   <div
     v-if="!hideStatus"
     class="Status"
@@ -89,8 +88,9 @@
             <router-link
               v-if="retweeterHtml"
               :to="retweeterProfileLink"
-              v-html="retweeterHtml"
-            />
+            >
+              <RichContent :html="retweeterHtml" :emoji="retweeterUser.emoji"/>
+            </router-link>
             <router-link
               v-else
               :to="retweeterProfileLink"
@@ -145,8 +145,9 @@
                   v-if="status.user.name_html"
                   class="status-username"
                   :title="status.user.name"
-                  v-html="status.user.name_html"
-                />
+                >
+                  <RichContent :html="status.user.name" :emoji="status.user.emoji" />
+                </h4>
                 <h4
                   v-else
                   class="status-username"
@@ -402,7 +403,6 @@
       </div>
     </template>
   </div>
-<!-- eslint-enable vue/no-v-html -->
 </template>
 
 <script src="./status.js" ></script>
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index d3eb5925..a3610b51 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -30,7 +30,7 @@
   position: relative;
   line-height: 0;
   overflow: hidden;
-  display: flex;
+  display: inline-flex;
   align-items: center;
 
   canvas {
@@ -53,6 +53,7 @@
 
   &.animated {
     &::before {
+      zoom: var(--_still_image-label-scale, 1);
       content: 'gif';
       position: absolute;
       line-height: 10px;
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 9f63feb6..8dc22823 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -54,6 +54,7 @@ export const parseUser = (data) => {
       return output
     }
 
+    output.emoji = data.emojis
     output.name = data.display_name
     output.name_html = addEmojis(escape(data.display_name), data.emojis)
 

From 50aa379038771da3725e6864c40007ae12896232 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 18:41:55 +0300
Subject: [PATCH 015/169] new component - StatusText, to separate post's text
 from its attachments

---
 .../status_content/status_content.js          | 127 +-------------
 .../status_content/status_content.vue         | 161 +++++-------------
 src/components/status_text/status_text.js     | 147 ++++++++++++++++
 src/components/status_text/status_text.vue    |  95 +++++++++++
 4 files changed, 282 insertions(+), 248 deletions(-)
 create mode 100644 src/components/status_text/status_text.js
 create mode 100644 src/components/status_text/status_text.vue

diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index c21afc24..67323b8a 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -1,12 +1,9 @@
 import Attachment from '../attachment/attachment.vue'
 import Poll from '../poll/poll.vue'
 import Gallery from '../gallery/gallery.vue'
-import RichContent from 'src/components/rich_content/rich_content.jsx'
+import StatusText from 'src/components/status_text/status_text.vue'
 import LinkPreview from '../link-preview/link-preview.vue'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import fileType from 'src/services/file_type/file_type.service'
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
-import { mentionMatchesUrl, extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
 import { mapGetters, mapState } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -36,52 +33,11 @@ const StatusContent = {
     'fullContent',
     'singleLine'
   ],
-  data () {
-    return {
-      showingTall: this.fullContent || (this.inConversation && this.focused),
-      showingLongSubject: false,
-      // not as computed because it sets the initial state which will be changed later
-      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
-    }
-  },
   computed: {
-    localCollapseSubjectDefault () {
-      return this.mergedConfig.collapseMessageWithSubject
-    },
     hideAttachments () {
       return (this.mergedConfig.hideAttachments && !this.inConversation) ||
         (this.mergedConfig.hideAttachmentsInConv && this.inConversation)
     },
-    // This is a bit hacky, but we want to approximate post height before rendering
-    // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
-    // as well as approximate line count by counting characters and approximating ~80
-    // per line.
-    //
-    // Using max-height + overflow: auto for status components resulted in false positives
-    // very often with japanese characters, and it was very annoying.
-    tallStatus () {
-      const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
-      return lengthScore > 20
-    },
-    longSubject () {
-      return this.status.summary.length > 240
-    },
-    // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
-    mightHideBecauseSubject () {
-      return !!this.status.summary && this.localCollapseSubjectDefault
-    },
-    mightHideBecauseTall () {
-      return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
-    },
-    hideSubjectStatus () {
-      return this.mightHideBecauseSubject && !this.expandingSubject
-    },
-    hideTallStatus () {
-      return this.mightHideBecauseTall && !this.showingTall
-    },
-    showingMore () {
-      return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
-    },
     nsfwClickthrough () {
       if (!this.status.nsfw) {
         return false
@@ -119,45 +75,11 @@ const StatusContent = {
         file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
       )
     },
-    attachmentTypes () {
-      return this.status.attachments.map(file => fileType.fileType(file.mimetype))
-    },
     maxThumbnails () {
       return this.mergedConfig.maxThumbnails
     },
-    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.err('Failed to process status html', e)
-          return html
-        }
-      } else {
-        return html
-      }
-    },
     ...mapGetters(['mergedConfig']),
     ...mapState({
-      betterShadow: state => state.interface.browserSupport.cssFilter,
       currentUser: state => state.users.currentUser
     })
   },
@@ -166,54 +88,9 @@ const StatusContent = {
     Poll,
     Gallery,
     LinkPreview,
-    RichContent
-  },
-  mounted () {
-    this.status.attentions.forEach(attn => {
-      const { id } = attn
-      this.$store.dispatch('fetchUserIfMissing', id)
-    })
+    StatusText
   },
   methods: {
-    linkClicked (event) {
-      const target = event.target.closest('.status-content a')
-      if (target) {
-        if (target.className.match(/mention/)) {
-          const href = target.href
-          const attn = this.status.attentions.find(attn => mentionMatchesUrl(attn, href))
-          if (attn) {
-            event.stopPropagation()
-            event.preventDefault()
-            const link = this.generateUserProfileLink(attn.id, attn.screen_name)
-            this.$router.push(link)
-            return
-          }
-        }
-        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
-      } else if (this.mightHideBecauseSubject) {
-        this.expandingSubject = !this.expandingSubject
-      }
-    },
-    generateUserProfileLink (id, name) {
-      return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
-    },
-    generateTagLink (tag) {
-      return `/tag/${tag}`
-    },
     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 31cd5a2e..7389244a 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,131 +1,46 @@
 <template>
   <div class="StatusContent">
     <slot name="header" />
-    <div
-      v-if="status.summary_html"
-      class="summary-wrapper"
-      :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
-    >
-      <RichContent
-        class="media-body summary"
-        :html="status.summary_raw_html"
-        :emoji="status.emojis"
-        @click.prevent="linkClicked"
-      />
-      <button
-        v-if="longSubject && showingLongSubject"
-        class="button-unstyled -link tall-subject-hider"
-        @click.prevent="showingLongSubject=false"
-      >
-        {{ $t("status.hide_full_subject") }}
-      </button>
-      <button
-        v-else-if="longSubject"
-        class="button-unstyled -link tall-subject-hider"
-        :class="{ 'tall-subject-hider_focused': focused }"
-        @click.prevent="showingLongSubject=true"
-      >
-        {{ $t("status.show_full_subject") }}
-      </button>
-    </div>
-    <div
-      :class="{'tall-status': hideTallStatus}"
-      class="status-content-wrapper"
-    >
-      <button
-        v-if="hideTallStatus"
-        class="button-unstyled -link tall-status-hider"
-        :class="{ 'tall-status-hider_focused': focused }"
-        @click.prevent="toggleShowMore"
-      >
-        {{ $t("general.show_more") }}
-      </button>
-      <RichContent
-        v-if="!hideSubjectStatus"
-        :class="{ 'single-line': singleLine }"
-        class="status-content media-body"
-        :html="postBodyHtml"
-        :emoji="status.emojis"
-        @click.prevent="linkClicked"
-      />
-      <button
-        v-if="hideSubjectStatus"
-        class="button-unstyled -link cw-status-hider"
-        @click.prevent="toggleShowMore"
-      >
-        {{ $t("status.show_content") }}
-        <FAIcon
-          v-if="attachmentTypes.includes('image')"
-          icon="image"
-        />
-        <FAIcon
-          v-if="attachmentTypes.includes('video')"
-          icon="video"
-        />
-        <FAIcon
-          v-if="attachmentTypes.includes('audio')"
-          icon="music"
-        />
-        <FAIcon
-          v-if="attachmentTypes.includes('unknown')"
-          icon="file"
-        />
-        <FAIcon
-          v-if="status.poll && status.poll.options"
-          icon="poll-h"
-        />
-        <FAIcon
-          v-if="status.card"
-          icon="link"
-        />
-      </button>
-      <button
-        v-if="showingMore && !fullContent"
-        class="button-unstyled -link status-unhider"
-        @click.prevent="toggleShowMore"
-      >
-        {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
-      </button>
-    </div>
+    <StatusText :status="status">
+      <div v-if="status.poll && status.poll.options">
+        <poll :base-poll="status.poll" />
+      </div>
 
-    <div v-if="status.poll && status.poll.options && !hideSubjectStatus">
-      <poll :base-poll="status.poll" />
-    </div>
+      <div
+        v-if="status.attachments.length !== 0"
+        class="attachments media-body"
+      >
+        <attachment
+          v-for="attachment in nonGalleryAttachments"
+          :key="attachment.id"
+          class="non-gallery"
+          :size="attachmentSize"
+          :nsfw="nsfwClickthrough"
+          :attachment="attachment"
+          :allow-play="true"
+          :set-media="setMedia()"
+          @play="$emit('mediaplay', attachment.id)"
+          @pause="$emit('mediapause', attachment.id)"
+        />
+        <gallery
+          v-if="galleryAttachments.length > 0"
+          :nsfw="nsfwClickthrough"
+          :attachments="galleryAttachments"
+          :set-media="setMedia()"
+        />
+      </div>
 
-    <div
-      v-if="status.attachments.length !== 0 && (!hideSubjectStatus || showingLongSubject)"
-      class="attachments media-body"
-    >
-      <attachment
-        v-for="attachment in nonGalleryAttachments"
-        :key="attachment.id"
-        class="non-gallery"
-        :size="attachmentSize"
-        :nsfw="nsfwClickthrough"
-        :attachment="attachment"
-        :allow-play="true"
-        :set-media="setMedia()"
-        @play="$emit('mediaplay', attachment.id)"
-        @pause="$emit('mediapause', attachment.id)"
-      />
-      <gallery
-        v-if="galleryAttachments.length > 0"
-        :nsfw="nsfwClickthrough"
-        :attachments="galleryAttachments"
-        :set-media="setMedia()"
-      />
-    </div>
-
-    <div
-      v-if="status.card && !hideSubjectStatus && !noHeading"
-      class="link-preview media-body"
-    >
-      <link-preview
-        :card="status.card"
-        :size="attachmentSize"
-        :nsfw="nsfwClickthrough"
-      />
-    </div>
+      <div
+        v-if="status.card && !noHeading"
+        class="link-preview media-body"
+      >
+        <link-preview
+          :card="status.card"
+          :size="attachmentSize"
+          :nsfw="nsfwClickthrough"
+        />
+      </div>
+    </StatusText>
     <slot name="footer" />
   </div>
 </template>
diff --git a/src/components/status_text/status_text.js b/src/components/status_text/status_text.js
new file mode 100644
index 00000000..e0b11edc
--- /dev/null
+++ b/src/components/status_text/status_text.js
@@ -0,0 +1,147 @@
+import fileType from 'src/services/file_type/file_type.service'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+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 {
+  faFile,
+  faMusic,
+  faImage,
+  faLink,
+  faPollH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faFile,
+  faMusic,
+  faImage,
+  faLink,
+  faPollH
+)
+
+const StatusContent = {
+  name: 'StatusContent',
+  props: [
+    'status',
+    'focused',
+    'noHeading',
+    'fullContent',
+    'singleLine'
+  ],
+  data () {
+    return {
+      showingTall: this.fullContent || (this.inConversation && this.focused),
+      showingLongSubject: false,
+      // not as computed because it sets the initial state which will be changed later
+      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
+    }
+  },
+  computed: {
+    localCollapseSubjectDefault () {
+      return this.mergedConfig.collapseMessageWithSubject
+    },
+    // This is a bit hacky, but we want to approximate post height before rendering
+    // so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
+    // as well as approximate line count by counting characters and approximating ~80
+    // per line.
+    //
+    // Using max-height + overflow: auto for status components resulted in false positives
+    // very often with japanese characters, and it was very annoying.
+    tallStatus () {
+      const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
+      return lengthScore > 20
+    },
+    longSubject () {
+      return this.status.summary.length > 240
+    },
+    // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
+    mightHideBecauseSubject () {
+      return !!this.status.summary && this.localCollapseSubjectDefault
+    },
+    mightHideBecauseTall () {
+      return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
+    },
+    hideSubjectStatus () {
+      return this.mightHideBecauseSubject && !this.expandingSubject
+    },
+    hideTallStatus () {
+      return this.mightHideBecauseTall && !this.showingTall
+    },
+    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.err('Failed to process status html', e)
+          return html
+        }
+      } else {
+        return html
+      }
+    },
+    attachmentTypes () {
+      return this.status.attachments.map(file => fileType.fileType(file.mimetype))
+    },
+    ...mapGetters(['mergedConfig']),
+  },
+  components: {
+    RichContent
+  },
+  mounted () {
+    this.status.attentions.forEach(attn => {
+      const { id } = attn
+      this.$store.dispatch('fetchUserIfMissing', id)
+    })
+  },
+  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
+      } else if (this.mightHideBecauseSubject) {
+        this.expandingSubject = !this.expandingSubject
+      }
+    },
+    generateTagLink (tag) {
+      return `/tag/${tag}`
+    }
+  }
+}
+
+export default StatusContent
diff --git a/src/components/status_text/status_text.vue b/src/components/status_text/status_text.vue
new file mode 100644
index 00000000..bf723e15
--- /dev/null
+++ b/src/components/status_text/status_text.vue
@@ -0,0 +1,95 @@
+<template>
+  <div class="StatusText">
+    <div
+      v-if="status.summary_html"
+      class="summary-wrapper"
+      :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
+    >
+      <RichContent
+        class="media-body summary"
+        :html="status.summary_raw_html"
+        :emoji="status.emojis"
+        @click.prevent="linkClicked"
+      />
+      <button
+        v-if="longSubject && showingLongSubject"
+        class="button-unstyled -link tall-subject-hider"
+        @click.prevent="showingLongSubject=false"
+        >
+        {{ $t("status.hide_full_subject") }}
+      </button>
+      <button
+        v-else-if="longSubject"
+        class="button-unstyled -link tall-subject-hider"
+        :class="{ 'tall-subject-hider_focused': focused }"
+        @click.prevent="showingLongSubject=true"
+      >
+        {{ $t("status.show_full_subject") }}
+      </button>
+    </div>
+    <div
+      :class="{'tall-status': hideTallStatus}"
+      class="status-content-wrapper"
+      >
+      <button
+        v-if="hideTallStatus"
+        class="button-unstyled -link tall-status-hider"
+        :class="{ 'tall-status-hider_focused': focused }"
+        @click.prevent="toggleShowMore"
+      >
+        {{ $t("general.show_more") }}
+      </button>
+      <RichContent
+        v-if="!hideSubjectStatus"
+        :class="{ 'single-line': singleLine }"
+        class="status-content media-body"
+        :html="postBodyHtml"
+        :emoji="status.emojis"
+        :handleLinks="true"
+        @click.prevent="linkClicked"
+        />
+      <button
+        v-if="hideSubjectStatus"
+        class="button-unstyled -link cw-status-hider"
+        @click.prevent="toggleShowMore"
+        >
+        {{ $t("status.show_content") }}
+        <FAIcon
+          v-if="attachmentTypes.includes('image')"
+          icon="image"
+          />
+        <FAIcon
+          v-if="attachmentTypes.includes('video')"
+          icon="video"
+          />
+        <FAIcon
+          v-if="attachmentTypes.includes('audio')"
+          icon="music"
+          />
+        <FAIcon
+          v-if="attachmentTypes.includes('unknown')"
+          icon="file"
+          />
+        <FAIcon
+          v-if="status.poll && status.poll.options"
+          icon="poll-h"
+          />
+        <FAIcon
+          v-if="status.card"
+          icon="link"
+          />
+      </button>
+      <button
+        v-if="showingMore && !fullContent"
+        class="button-unstyled -link status-unhider"
+        @click.prevent="toggleShowMore"
+        >
+        {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
+      </button>
+    </div>
+    <div v-if="!hideSubjectStatus">
+      <slot/>
+    </div>
+  </div>
+</template>
+<script src="./status_text.js" ></script>

From 8e9f5d7580d5a248e280b110218aa23052827789 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 19:50:26 +0300
Subject: [PATCH 016/169] renamed StatusText to StatusBody for clarity, fixed
 chats

---
 .../chat_list_item/chat_list_item.js          |   9 +-
 .../chat_list_item/chat_list_item.scss        |   9 +-
 .../chat_list_item/chat_list_item.vue         |   3 +-
 src/components/chat_message/chat_message.js   |   2 +
 src/components/chat_message/chat_message.scss |   6 +-
 src/components/chat_message/chat_message.vue  |   1 +
 .../tabs/theme_tab/theme_tab.js               |   2 +-
 src/components/status/status.vue              |  10 +-
 .../status_body.js}                           |   6 +-
 src/components/status_body/status_body.scss   | 112 ++++++++++++++++++
 src/components/status_body/status_body.vue    |  95 +++++++++++++++
 .../status_content/status_content.js          |   4 +-
 .../status_content/status_content.vue         | 102 +---------------
 src/components/status_text/status_text.vue    |  95 ---------------
 .../entity_normalizer.service.js              |   2 +
 .../mini_html_converter.service.js            |   2 +-
 yarn.lock                                     |  81 +++++++++++--
 17 files changed, 316 insertions(+), 225 deletions(-)
 rename src/components/{status_text/status_text.js => status_body/status_body.js} (96%)
 create mode 100644 src/components/status_body/status_body.scss
 create mode 100644 src/components/status_body/status_body.vue
 delete mode 100644 src/components/status_text/status_text.vue

diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
index bee1ad53..e01b9bd4 100644
--- a/src/components/chat_list_item/chat_list_item.js
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -1,5 +1,5 @@
 import { mapState } from 'vuex'
-import StatusContent from '../status_content/status_content.vue'
+import StatusBody from '../status_content/status_content.vue'
 import fileType from 'src/services/file_type/file_type.service'
 import UserAvatar from '../user_avatar/user_avatar.vue'
 import AvatarList from '../avatar_list/avatar_list.vue'
@@ -16,7 +16,7 @@ const ChatListItem = {
     AvatarList,
     Timeago,
     ChatTitle,
-    StatusContent
+    StatusBody
   },
   computed: {
     ...mapState({
@@ -38,12 +38,15 @@ const ChatListItem = {
     },
     messageForStatusContent () {
       const message = this.chat.lastMessage
+      const messageEmojis = message ? message.emojis : []
       const isYou = message && message.account_id === this.currentUser.id
-      const content = message ? (this.attachmentInfo || message.content) : ''
+      const content = message ? (this.attachmentInfo || message.content_raw) : ''
       const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
       return {
         summary: '',
+        emojis: messageEmojis,
         statusnet_html: messagePreview,
+        raw_html: messagePreview,
         text: messagePreview,
         attachments: []
       }
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
index 9e97b28e..57332bed 100644
--- a/src/components/chat_list_item/chat_list_item.scss
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -77,18 +77,15 @@
     border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
   }
 
-  .StatusContent {
-    img.emoji {
-      width: 1.4em;
-      height: 1.4em;
-    }
+  .chat-preview-body {
+    --emoji-size: 1.4em;
   }
 
   .time-wrapper {
     line-height: 1.4em;
   }
 
-  .single-line {
+  .chat-preview-body {
     padding-right: 1em;
   }
 }
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
index cd3f436e..c7c0e878 100644
--- a/src/components/chat_list_item/chat_list_item.vue
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -29,7 +29,8 @@
         </div>
       </div>
       <div class="chat-preview">
-        <StatusContent
+        <StatusBody
+          class="chat-preview-body"
           :status="messageForStatusContent"
           :single-line="true"
         />
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index bb380f87..d126d453 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -57,6 +57,8 @@ const ChatMessage = {
     messageForStatusContent () {
       return {
         summary: '',
+        emojis: this.message.emojis,
+        raw_html: this.message.content_raw,
         statusnet_html: this.message.content,
         text: this.message.content,
         attachments: this.message.attachments
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index e4351d3b..220355ea 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -89,8 +89,9 @@
   }
 
   .without-attachment {
-    .status-content {
-      &::after {
+    .message-content {
+      // TODO figure out how to do it properly
+      .text::after {
         margin-right: 5.4em;
         content: " ";
         display: inline-block;
@@ -162,6 +163,7 @@
   .visible {
     opacity: 1;
   }
+
 }
 
 .chat-message-date-separator {
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index 0f3fc97d..d62b831d 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -71,6 +71,7 @@
               </Popover>
             </div>
             <StatusContent
+              class="message-content"
               :status="messageForStatusContent"
               :full-content="true"
             >
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 1388f74b..63416e93 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -474,7 +474,7 @@ export default {
           this.loadThemeFromLocalStorage(false, true)
           break
         case 'file':
-          console.err('Forcing snapshout from file is not supported yet')
+          console.error('Forcing snapshout from file is not supported yet')
           break
       }
       this.dismissWarning()
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index cc5fb79f..a4247cd4 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -89,7 +89,10 @@
               v-if="retweeterHtml"
               :to="retweeterProfileLink"
             >
-              <RichContent :html="retweeterHtml" :emoji="retweeterUser.emoji"/>
+              <RichContent
+                :html="retweeterHtml"
+                :emoji="retweeterUser.emoji"
+              />
             </router-link>
             <router-link
               v-else
@@ -146,7 +149,10 @@
                   class="status-username"
                   :title="status.user.name"
                 >
-                  <RichContent :html="status.user.name" :emoji="status.user.emoji" />
+                  <RichContent
+                    :html="status.user.name"
+                    :emoji="status.user.emoji"
+                  />
                 </h4>
                 <h4
                   v-else
diff --git a/src/components/status_text/status_text.js b/src/components/status_body/status_body.js
similarity index 96%
rename from src/components/status_text/status_text.js
rename to src/components/status_body/status_body.js
index e0b11edc..232afccb 100644
--- a/src/components/status_text/status_text.js
+++ b/src/components/status_body/status_body.js
@@ -94,7 +94,7 @@ const StatusContent = {
             return html
           }
         } catch (e) {
-          console.err('Failed to process status html', e)
+          console.error('Failed to process status html', e)
           return html
         }
       } else {
@@ -104,13 +104,13 @@ const StatusContent = {
     attachmentTypes () {
       return this.status.attachments.map(file => fileType.fileType(file.mimetype))
     },
-    ...mapGetters(['mergedConfig']),
+    ...mapGetters(['mergedConfig'])
   },
   components: {
     RichContent
   },
   mounted () {
-    this.status.attentions.forEach(attn => {
+    this.status.attentions && this.status.attentions.forEach(attn => {
       const { id } = attn
       this.$store.dispatch('fetchUserIfMissing', id)
     })
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
new file mode 100644
index 00000000..6282919c
--- /dev/null
+++ b/src/components/status_body/status_body.scss
@@ -0,0 +1,112 @@
+@import '../../_variables.scss';
+
+.StatusBody {
+  .emoji {
+    --_still_image-label-scale: 0.5;
+  }
+
+  .summary {
+    display: block;
+    font-style: italic;
+    padding-bottom: 0.5em;
+  }
+
+  .text {
+    &.-single-line {
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      overflow: hidden;
+      height: 1.4em;
+    }
+  }
+
+  .summary-wrapper {
+    margin-bottom: 0.5em;
+    border-style: solid;
+    border-width: 0 0 1px 0;
+    border-color: var(--border, $fallback--border);
+    flex-grow: 0;
+
+    &.-tall {
+      position: relative;
+
+      .summary {
+        max-height: 2em;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+    }
+  }
+
+  .text-wrapper {
+    display: flex;
+    flex-direction: column;
+    flex-wrap: nowrap;
+
+    &.-tall-status {
+      position: relative;
+      height: 220px;
+      overflow-x: hidden;
+      overflow-y: hidden;
+      z-index: 1;
+
+      .text {
+        min-height: 0;
+        mask:
+          linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
+          linear-gradient(to top, white, white);
+
+        /* Autoprefixed seem to ignore this one, and also syntax is different */
+        -webkit-mask-composite: xor;
+        mask-composite: exclude;
+      }
+    }
+  }
+
+  & .tall-status-hider,
+  & .tall-subject-hider,
+  & .status-unhider,
+  & .cw-status-hider {
+    display: inline-block;
+    word-break: break-all;
+    width: 100%;
+    text-align: center;
+  }
+
+  .tall-status-hider {
+    position: absolute;
+    height: 70px;
+    margin-top: 150px;
+    line-height: 110px;
+    z-index: 2;
+  }
+
+  .tall-subject-hider {
+    // position: absolute;
+    padding-bottom: 0.5em;
+  }
+
+  & .status-unhider,
+  & .cw-status-hider {
+    word-break: break-all;
+
+    svg {
+      color: inherit;
+    }
+  }
+
+  .greentext {
+    color: $fallback--cGreen;
+    color: var(--postGreentext, $fallback--cGreen);
+  }
+
+  /* Not sure if this is necessary */
+  video {
+    max-width: 100%;
+    max-height: 400px;
+    vertical-align: middle;
+    object-fit: contain;
+  }
+
+}
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
new file mode 100644
index 00000000..6f982f2e
--- /dev/null
+++ b/src/components/status_body/status_body.vue
@@ -0,0 +1,95 @@
+<template>
+  <div class="StatusBody">
+    <div class="body">
+      <div
+        v-if="status.summary_html"
+        class="summary-wrapper"
+        :class="{ '-tall': (longSubject && !showingLongSubject) }"
+      >
+        <RichContent
+          class="media-body summary"
+          :html="status.summary_raw_html"
+          :emoji="status.emojis"
+          @click.prevent="linkClicked"
+        />
+        <button
+          v-if="longSubject && showingLongSubject"
+          class="button-unstyled -link tall-subject-hider"
+          @click.prevent="showingLongSubject=false"
+        >
+          {{ $t("status.hide_full_subject") }}
+        </button>
+        <button
+          v-else-if="longSubject"
+          class="button-unstyled -link tall-subject-hider"
+          @click.prevent="showingLongSubject=true"
+        >
+          {{ $t("status.show_full_subject") }}
+        </button>
+      </div>
+      <div
+        :class="{'-tall-status': hideTallStatus}"
+        class="text-wrapper"
+      >
+        <button
+          v-if="hideTallStatus"
+          class="button-unstyled -link tall-status-hider"
+          :class="{ '-focused': focused }"
+          @click.prevent="toggleShowMore"
+        >
+          {{ $t("general.show_more") }}
+        </button>
+        <RichContent
+          v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
+          :class="{ '-single-line': singleLine }"
+          class="text media-body"
+          :html="postBodyHtml"
+          :emoji="status.emojis"
+          :handle-links="true"
+          @click.prevent="linkClicked"
+        />
+        <button
+          v-if="hideSubjectStatus"
+          class="button-unstyled -link cw-status-hider"
+          @click.prevent="toggleShowMore"
+        >
+          {{ $t("status.show_content") }}
+          <FAIcon
+            v-if="attachmentTypes.includes('image')"
+            icon="image"
+          />
+          <FAIcon
+            v-if="attachmentTypes.includes('video')"
+            icon="video"
+          />
+          <FAIcon
+            v-if="attachmentTypes.includes('audio')"
+            icon="music"
+          />
+          <FAIcon
+            v-if="attachmentTypes.includes('unknown')"
+            icon="file"
+          />
+          <FAIcon
+            v-if="status.poll && status.poll.options"
+            icon="poll-h"
+          />
+          <FAIcon
+            v-if="status.card"
+            icon="link"
+          />
+        </button>
+        <button
+          v-if="showingMore && !fullContent"
+          class="button-unstyled -link status-unhider"
+          @click.prevent="toggleShowMore"
+        >
+          {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
+        </button>
+      </div>
+    </div>
+    <slot v-if="!hideSubjectStatus" />
+  </div>
+</template>
+<script src="./status_body.js" ></script>
+<style lang="scss" src="./status_body.scss" />
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 67323b8a..1b80ee09 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -1,7 +1,7 @@
 import Attachment from '../attachment/attachment.vue'
 import Poll from '../poll/poll.vue'
 import Gallery from '../gallery/gallery.vue'
-import StatusText from 'src/components/status_text/status_text.vue'
+import StatusBody from 'src/components/status_body/status_body.vue'
 import LinkPreview from '../link-preview/link-preview.vue'
 import fileType from 'src/services/file_type/file_type.service'
 import { mapGetters, mapState } from 'vuex'
@@ -88,7 +88,7 @@ const StatusContent = {
     Poll,
     Gallery,
     LinkPreview,
-    StatusText
+    StatusBody
   },
   methods: {
     setMedia () {
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 7389244a..0551f275 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="StatusContent">
     <slot name="header" />
-    <StatusText :status="status">
+    <StatusBody :status="status" :single-line="singleLine">
       <div v-if="status.poll && status.poll.options">
         <poll :base-poll="status.poll" />
       </div>
@@ -40,7 +40,7 @@
           :nsfw="nsfwClickthrough"
         />
       </div>
-    </StatusText>
+    </StatusBody>
     <slot name="footer" />
   </div>
 </template>
@@ -54,103 +54,5 @@ $status-margin: 0.75em;
 .StatusContent {
   flex: 1;
   min-width: 0;
-
-  .status-content-wrapper {
-    display: flex;
-    flex-direction: column;
-    flex-wrap: nowrap;
-  }
-
-  .tall-status {
-    position: relative;
-    height: 220px;
-    overflow-x: hidden;
-    overflow-y: hidden;
-    z-index: 1;
-    .status-content {
-      min-height: 0;
-      mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
-            linear-gradient(to top, white, white);
-      /* Autoprefixed seem to ignore this one, and also syntax is different */
-      -webkit-mask-composite: xor;
-      mask-composite: exclude;
-    }
-  }
-
-  .tall-status-hider {
-    display: inline-block;
-    word-break: break-all;
-    position: absolute;
-    height: 70px;
-    margin-top: 150px;
-    width: 100%;
-    text-align: center;
-    line-height: 110px;
-    z-index: 2;
-  }
-
-  .status-unhider, .cw-status-hider {
-    width: 100%;
-    text-align: center;
-    display: inline-block;
-    word-break: break-all;
-
-    svg {
-      color: inherit;
-    }
-  }
-
-  video {
-    max-width: 100%;
-    max-height: 400px;
-    vertical-align: middle;
-    object-fit: contain;
-  }
-
-  .summary-wrapper {
-    margin-bottom: 0.5em;
-    border-style: solid;
-    border-width: 0 0 1px 0;
-    border-color: var(--border, $fallback--border);
-    flex-grow: 0;
-  }
-
-  .summary {
-    font-style: italic;
-    padding-bottom: 0.5em;
-  }
-
-  .tall-subject {
-    position: relative;
-    .summary {
-      max-height: 2em;
-      overflow: hidden;
-      white-space: nowrap;
-      text-overflow: ellipsis;
-    }
-  }
-
-  .tall-subject-hider {
-    display: inline-block;
-    word-break: break-all;
-    // position: absolute;
-    width: 100%;
-    text-align: center;
-    padding-bottom: 0.5em;
-  }
-
-  .status-content {
-    &.single-line {
-      white-space: nowrap;
-      text-overflow: ellipsis;
-      overflow: hidden;
-      height: 1.4em;
-    }
-  }
-}
-
-.greentext {
-  color: $fallback--cGreen;
-  color: var(--postGreentext, $fallback--cGreen);
 }
 </style>
diff --git a/src/components/status_text/status_text.vue b/src/components/status_text/status_text.vue
deleted file mode 100644
index bf723e15..00000000
--- a/src/components/status_text/status_text.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<template>
-  <div class="StatusText">
-    <div
-      v-if="status.summary_html"
-      class="summary-wrapper"
-      :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
-    >
-      <RichContent
-        class="media-body summary"
-        :html="status.summary_raw_html"
-        :emoji="status.emojis"
-        @click.prevent="linkClicked"
-      />
-      <button
-        v-if="longSubject && showingLongSubject"
-        class="button-unstyled -link tall-subject-hider"
-        @click.prevent="showingLongSubject=false"
-        >
-        {{ $t("status.hide_full_subject") }}
-      </button>
-      <button
-        v-else-if="longSubject"
-        class="button-unstyled -link tall-subject-hider"
-        :class="{ 'tall-subject-hider_focused': focused }"
-        @click.prevent="showingLongSubject=true"
-      >
-        {{ $t("status.show_full_subject") }}
-      </button>
-    </div>
-    <div
-      :class="{'tall-status': hideTallStatus}"
-      class="status-content-wrapper"
-      >
-      <button
-        v-if="hideTallStatus"
-        class="button-unstyled -link tall-status-hider"
-        :class="{ 'tall-status-hider_focused': focused }"
-        @click.prevent="toggleShowMore"
-      >
-        {{ $t("general.show_more") }}
-      </button>
-      <RichContent
-        v-if="!hideSubjectStatus"
-        :class="{ 'single-line': singleLine }"
-        class="status-content media-body"
-        :html="postBodyHtml"
-        :emoji="status.emojis"
-        :handleLinks="true"
-        @click.prevent="linkClicked"
-        />
-      <button
-        v-if="hideSubjectStatus"
-        class="button-unstyled -link cw-status-hider"
-        @click.prevent="toggleShowMore"
-        >
-        {{ $t("status.show_content") }}
-        <FAIcon
-          v-if="attachmentTypes.includes('image')"
-          icon="image"
-          />
-        <FAIcon
-          v-if="attachmentTypes.includes('video')"
-          icon="video"
-          />
-        <FAIcon
-          v-if="attachmentTypes.includes('audio')"
-          icon="music"
-          />
-        <FAIcon
-          v-if="attachmentTypes.includes('unknown')"
-          icon="file"
-          />
-        <FAIcon
-          v-if="status.poll && status.poll.options"
-          icon="poll-h"
-          />
-        <FAIcon
-          v-if="status.card"
-          icon="link"
-          />
-      </button>
-      <button
-        v-if="showingMore && !fullContent"
-        class="button-unstyled -link status-unhider"
-        @click.prevent="toggleShowMore"
-        >
-        {{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
-      </button>
-    </div>
-    <div v-if="!hideSubjectStatus">
-      <slot/>
-    </div>
-  </div>
-</template>
-<script src="./status_text.js" ></script>
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 8dc22823..13162dcf 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -448,6 +448,8 @@ export const parseChatMessage = (message) => {
   output.id = message.id
   output.created_at = new Date(message.created_at)
   output.chat_id = message.chat_id
+  output.emojis = message.emojis
+  output.content_raw = message.content
   if (message.content) {
     output.content = addEmojis(message.content, message.emojis)
   } else {
diff --git a/src/services/mini_html_converter/mini_html_converter.service.js b/src/services/mini_html_converter/mini_html_converter.service.js
index 01f8adf8..900752cd 100644
--- a/src/services/mini_html_converter/mini_html_converter.service.js
+++ b/src/services/mini_html_converter/mini_html_converter.service.js
@@ -1,6 +1,6 @@
 /**
  * This is a not-so-tiny purpose-built HTML parser/processor. It was made for use
- * with StatusText component for purpose of replacing tags with vue components
+ * with StatusBody component for purpose of replacing tags with vue components
  *
  * known issue: doesn't handle CDATA so nested CDATA might not work well
  *
diff --git a/yarn.lock b/yarn.lock
index 23cc895b..9329cc3a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1011,23 +1011,86 @@
   resolved "https://registry.yarnpkg.com/@ungap/event-target/-/event-target-0.1.0.tgz#88d527d40de86c4b0c99a060ca241d755999915b"
   integrity sha512-W2oyj0Fe1w/XhPZjkI3oUcDUAmu5P4qsdT2/2S8aMhtAWM/CE/jYWtji0pKNPDfxLI75fa5gWSEmnynKMNP/oA==
 
-"@vue/babel-helper-vue-jsx-merge-props@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.0.0.tgz#048fe579958da408fb7a8b2a3ec050b50a661040"
-  integrity sha512-6tyf5Cqm4m6v7buITuwS+jHzPlIPxbFzEhXR5JGZpbrvOcp1hiQKckd305/3C7C36wFekNTQSxAtgeM0j0yoUw==
+"@vue/babel-helper-vue-jsx-merge-props@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz#31624a7a505fb14da1d58023725a4c5f270e6a81"
+  integrity sha512-QOi5OW45e2R20VygMSNhyQHvpdUwQZqGPc748JLGCYEy+yp8fNFNdbNIGAgZmi9e+2JHPd6i6idRuqivyicIkA==
 
-"@vue/babel-plugin-transform-vue-jsx@^1.1.2":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.1.2.tgz#c0a3e6efc022e75e4247b448a8fc6b86f03e91c0"
-  integrity sha512-YfdaoSMvD1nj7+DsrwfTvTnhDXI7bsuh+Y5qWwvQXlD24uLgnsoww3qbiZvWf/EoviZMrvqkqN4CBw0W3BWUTQ==
+"@vue/babel-plugin-transform-vue-jsx@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@vue/babel-plugin-transform-vue-jsx/-/babel-plugin-transform-vue-jsx-1.2.1.tgz#646046c652c2f0242727f34519d917b064041ed7"
+  integrity sha512-HJuqwACYehQwh1fNT8f4kyzqlNMpBuUK4rSiSES5D4QsYncv5fxFsLyrxFPG2ksO7t5WP+Vgix6tt6yKClwPzA==
   dependencies:
     "@babel/helper-module-imports" "^7.0.0"
     "@babel/plugin-syntax-jsx" "^7.2.0"
-    "@vue/babel-helper-vue-jsx-merge-props" "^1.0.0"
+    "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1"
     html-tags "^2.0.0"
     lodash.kebabcase "^4.1.1"
     svg-tags "^1.0.0"
 
+"@vue/babel-preset-jsx@^1.2.4":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@vue/babel-preset-jsx/-/babel-preset-jsx-1.2.4.tgz#92fea79db6f13b01e80d3a0099e2924bdcbe4e87"
+  integrity sha512-oRVnmN2a77bYDJzeGSt92AuHXbkIxbf/XXSE3klINnh9AXBmVS1DGa1f0d+dDYpLfsAKElMnqKTQfKn7obcL4w==
+  dependencies:
+    "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1"
+    "@vue/babel-plugin-transform-vue-jsx" "^1.2.1"
+    "@vue/babel-sugar-composition-api-inject-h" "^1.2.1"
+    "@vue/babel-sugar-composition-api-render-instance" "^1.2.4"
+    "@vue/babel-sugar-functional-vue" "^1.2.2"
+    "@vue/babel-sugar-inject-h" "^1.2.2"
+    "@vue/babel-sugar-v-model" "^1.2.3"
+    "@vue/babel-sugar-v-on" "^1.2.3"
+
+"@vue/babel-sugar-composition-api-inject-h@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-inject-h/-/babel-sugar-composition-api-inject-h-1.2.1.tgz#05d6e0c432710e37582b2be9a6049b689b6f03eb"
+  integrity sha512-4B3L5Z2G+7s+9Bwbf+zPIifkFNcKth7fQwekVbnOA3cr3Pq71q71goWr97sk4/yyzH8phfe5ODVzEjX7HU7ItQ==
+  dependencies:
+    "@babel/plugin-syntax-jsx" "^7.2.0"
+
+"@vue/babel-sugar-composition-api-render-instance@^1.2.4":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@vue/babel-sugar-composition-api-render-instance/-/babel-sugar-composition-api-render-instance-1.2.4.tgz#e4cbc6997c344fac271785ad7a29325c51d68d19"
+  integrity sha512-joha4PZznQMsxQYXtR3MnTgCASC9u3zt9KfBxIeuI5g2gscpTsSKRDzWQt4aqNIpx6cv8On7/m6zmmovlNsG7Q==
+  dependencies:
+    "@babel/plugin-syntax-jsx" "^7.2.0"
+
+"@vue/babel-sugar-functional-vue@^1.2.2":
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/@vue/babel-sugar-functional-vue/-/babel-sugar-functional-vue-1.2.2.tgz#267a9ac8d787c96edbf03ce3f392c49da9bd2658"
+  integrity sha512-JvbgGn1bjCLByIAU1VOoepHQ1vFsroSA/QkzdiSs657V79q6OwEWLCQtQnEXD/rLTA8rRit4rMOhFpbjRFm82w==
+  dependencies:
+    "@babel/plugin-syntax-jsx" "^7.2.0"
+
+"@vue/babel-sugar-inject-h@^1.2.2":
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/@vue/babel-sugar-inject-h/-/babel-sugar-inject-h-1.2.2.tgz#d738d3c893367ec8491dcbb669b000919293e3aa"
+  integrity sha512-y8vTo00oRkzQTgufeotjCLPAvlhnpSkcHFEp60+LJUwygGcd5Chrpn5480AQp/thrxVm8m2ifAk0LyFel9oCnw==
+  dependencies:
+    "@babel/plugin-syntax-jsx" "^7.2.0"
+
+"@vue/babel-sugar-v-model@^1.2.3":
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-model/-/babel-sugar-v-model-1.2.3.tgz#fa1f29ba51ebf0aa1a6c35fa66d539bc459a18f2"
+  integrity sha512-A2jxx87mySr/ulAsSSyYE8un6SIH0NWHiLaCWpodPCVOlQVODCaSpiR4+IMsmBr73haG+oeCuSvMOM+ttWUqRQ==
+  dependencies:
+    "@babel/plugin-syntax-jsx" "^7.2.0"
+    "@vue/babel-helper-vue-jsx-merge-props" "^1.2.1"
+    "@vue/babel-plugin-transform-vue-jsx" "^1.2.1"
+    camelcase "^5.0.0"
+    html-tags "^2.0.0"
+    svg-tags "^1.0.0"
+
+"@vue/babel-sugar-v-on@^1.2.3":
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/@vue/babel-sugar-v-on/-/babel-sugar-v-on-1.2.3.tgz#342367178586a69f392f04bfba32021d02913ada"
+  integrity sha512-kt12VJdz/37D3N3eglBywV8GStKNUhNrsxChXIV+o0MwVXORYuhDTHJRKPgLJRb/EY3vM2aRFQdxJBp9CLikjw==
+  dependencies:
+    "@babel/plugin-syntax-jsx" "^7.2.0"
+    "@vue/babel-plugin-transform-vue-jsx" "^1.2.1"
+    camelcase "^5.0.0"
+
 "@vue/test-utils@^1.0.0-beta.26":
   version "1.0.0-beta.28"
   resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.28.tgz#767c43413df8cde86128735e58923803e444b9a5"

From aa38223e87bbea917e978a3efe0df49206f33f8e Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 19:51:04 +0300
Subject: [PATCH 017/169] lint

---
 src/components/status_content/status_content.vue | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 0551f275..0283ce2d 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,7 +1,10 @@
 <template>
   <div class="StatusContent">
     <slot name="header" />
-    <StatusBody :status="status" :single-line="singleLine">
+    <StatusBody
+      :status="status"
+      :single-line="singleLine"
+    >
       <div v-if="status.poll && status.poll.options">
         <poll :base-poll="status.poll" />
       </div>

From 5e8367227424fc2ff1683a244b55809ac4a52d3d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 20:01:57 +0300
Subject: [PATCH 018/169] fixed some strange error

---
 src/modules/users.js | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/modules/users.js b/src/modules/users.js
index 9ed06897..fb92cc91 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -248,7 +248,8 @@ export const getters = {
   },
   findUserByUrl: state => query => {
     return state.users
-      .find(u => u.statusnet_profile_url.toLowerCase() === query.toLowerCase())
+      .find(u => u.statusnet_profile_url &&
+            u.statusnet_profile_url.toLowerCase() === query.toLowerCase())
   },
   relationship: state => id => {
     const rel = id && state.relationships[id]

From 60903272366e1f236041841af3dca86628901a35 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 20:02:09 +0300
Subject: [PATCH 019/169] moved some post styles into status body since they
 inferfere with usernames

---
 src/components/rich_content/rich_content.scss |  7 -------
 src/components/status_body/status_body.scss   | 11 +++++++++++
 2 files changed, 11 insertions(+), 7 deletions(-)

diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
index a486babf..12cb9776 100644
--- a/src/components/rich_content/rich_content.scss
+++ b/src/components/rich_content/rich_content.scss
@@ -1,11 +1,4 @@
 .RichContent {
-  font-family: var(--postFont, sans-serif);
-  line-height: 1.4em;
-  white-space: pre-wrap;
-  overflow-wrap: break-word;
-  word-wrap: break-word;
-  word-break: break-word;
-
   blockquote {
     margin: 0.2em 0 0.2em 2em;
     font-style: italic;
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
index 6282919c..b65e3b27 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -1,10 +1,21 @@
 @import '../../_variables.scss';
 
 .StatusBody {
+
   .emoji {
     --_still_image-label-scale: 0.5;
   }
 
+  & .text,
+  & .summary {
+    font-family: var(--postFont, sans-serif);
+    white-space: pre-wrap;
+    overflow-wrap: break-word;
+    word-wrap: break-word;
+    word-break: break-word;
+    line-height: 1.4em;
+  }
+
   .summary {
     display: block;
     font-style: italic;

From 8045d1866ecb08a606a2891a9f179c1e7e968e9b Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 20:25:09 +0300
Subject: [PATCH 020/169] localization

---
 src/components/mention_link/mention_link.vue | 2 +-
 src/i18n/en.json                             | 3 ++-
 src/i18n/fi.json                             | 3 ++-
 3 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 80aadd3f..ea356315 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -19,7 +19,7 @@
         @click.prevent="onClick"
       >
         <!-- eslint-disable vue/no-v-html -->
-        <span class="shortName">@<span v-html="userName" /></span> <span v-if="isYou">(You)</span>
+        <span class="shortName">@<span v-html="userName" /></span> <span v-if="isYou">{{ $t('status.you')}}</span>
         <!-- eslint-enable vue/no-v-html -->
       </button>
       <span
diff --git a/src/i18n/en.json b/src/i18n/en.json
index aa440ac1..d64adaff 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -711,7 +711,8 @@
     "hide_content": "Hide content",
     "status_deleted": "This post was deleted",
     "nsfw": "NSFW",
-    "expand": "Expand"
+    "expand": "Expand",
+    "you": "(You)"
   },
   "user_card": {
     "approve": "Approve",
diff --git a/src/i18n/fi.json b/src/i18n/fi.json
index 2524f278..ebcad804 100644
--- a/src/i18n/fi.json
+++ b/src/i18n/fi.json
@@ -579,7 +579,8 @@
     "hide_full_subject": "Piilota koko otsikko",
     "show_content": "Näytä sisältö",
     "hide_content": "Piilota sisältö",
-    "status_deleted": "Poistettu viesti"
+    "status_deleted": "Poistettu viesti",
+    "you": "(sinä)"
   },
   "user_card": {
     "approve": "Hyväksy",

From 6199788f28a2665388427f6e7737f15cebd5102d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 20:44:32 +0300
Subject: [PATCH 021/169] fix tall emojis being cropped

---
 src/components/still-image/still-image.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index a3610b51..0623b42e 100644
--- a/src/components/still-image/still-image.vue
+++ b/src/components/still-image/still-image.vue
@@ -47,7 +47,7 @@
 
   img {
     width: 100%;
-    min-height: 100%;
+    height: 100%;
     object-fit: contain;
   }
 

From 7d6fc044fbcf2c6f82f759605a0fd5c1aebae55a Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 23:42:04 +0300
Subject: [PATCH 022/169] new mentions look

---
 src/components/mention_link/mention_link.js   | 28 +++---
 src/components/mention_link/mention_link.scss | 94 ++++++++++++++++---
 src/components/mention_link/mention_link.vue  | 12 ++-
 .../user_highlighter/user_highlighter.js      | 14 ++-
 4 files changed, 114 insertions(+), 34 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index ade598d8..d26ae337 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -1,7 +1,6 @@
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
-import { getTextColor, rgb2hex } from 'src/services/color_convert/color_convert.js'
 import { mapGetters, mapState } from 'vuex'
-import { convert } from 'chromatism'
+import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 
 const MentionLink = {
   name: 'MentionLink',
@@ -45,21 +44,22 @@ const MentionLink = {
     highlight () {
       return this.mergedConfig.highlight[this.user.screen_name]
     },
-    bg () {
-      if (this.highlight) return this.highlight.color
+    highlightType () {
+      return this.highlight && ('-' + this.highlight.type)
     },
-    text () {
-      if (this.bg) {
-        const linkColor = this.mergedConfig.customTheme.colors.link
-        const color = getTextColor(convert(this.bg).rgb, convert(linkColor).rgb)
-        return rgb2hex(color)
-      }
+    highlightClass () {
+      if (this.highlight) return highlightClass(this.user)
     },
     style () {
-      return [
-        this.bg && `--mention-bg: ${this.bg}`,
-        this.text && `--mention-text: ${this.text}`
-      ].filter(_ => _).join('; ')
+      if (this.highlight) {
+        const {
+          backgroundColor,
+          backgroundPosition,
+          backgroundImage,
+          ...rest
+        } = highlightStyle(this.highlight)
+        return rest
+      }
     },
     ...mapGetters(['mergedConfig']),
     ...mapState({
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 237d9205..3a3e58d1 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -2,12 +2,10 @@
   position: relative;
   white-space: normal;
   display: inline-block;
+  color: var(--link);
 
   & .new,
-  & .original,
-  & .full {
-    padding: 0 2px;
-    margin: 0 -2px;
+  & .original {
     display: inline-block;
     border-radius: 2px;
   }
@@ -17,24 +15,95 @@
   }
 
   .full {
-    pointer-events: none;
     position: absolute;
+    display: inline-block;
+    pointer-events: none;
     opacity: 0;
-    top: 0;
-    bottom: 0;
+    top: 100%;
     left: 0;
+    height: 100%;
     word-wrap: normal;
     white-space: nowrap;
     transition: opacity 0.2s ease;
-    background-color: var(--mention-bg, var(--popover));
-    color: var(--mention-text, var(--link));
     z-index: 1;
+    margin-top: 0.25em;
+    padding: 0.5em;
+  }
+
+  & .short,
+  & .full {
+    &::before {
+      content: '@';
+    }
+  }
+
+  & .new {
+    &,
+    &.-striped,
+    &.-solid,
+    &.-side {
+      .full {
+      }
+      .short {
+        &::before {
+          display: inline-block;
+          height: 50%;
+          font-size: 90%;
+          line-height: 1;
+          vertical-align: 6%;
+        }
+      }
+
+      .you {
+        padding-right: 0.25em;
+      }
+      .short {
+        padding-left: 0.25em;
+        padding-right: 0;
+        padding-top: 0;
+        padding-bottom: 0;
+      }
+
+      .userName {
+        color: var(--link);
+        margin-left: 0.25em;
+        padding-left: 0.25em;
+        padding-right: 0.25em;
+        padding-top: 0;
+        padding-bottom: 0;
+      }
+    }
+
+    &.-striped {
+      & .userName,
+      & .full {
+        background-image:
+          repeating-linear-gradient(
+            135deg,
+            var(--____highlight-tintColor),
+            var(--____highlight-tintColor) 5px,
+            var(--____highlight-tintColor2) 5px,
+            var(--____highlight-tintColor2) 10px
+          );
+      }
+    }
+
+    &.-solid {
+      & .userName,
+      & .full {
+        background-image: linear-gradient(var(--____highlight-tintColor2), var(--____highlight-tintColor2));
+      }
+    }
+
+    &.-side {
+      & .userName,
+      & .userNameFull {
+        box-shadow: 0 -5px 3px -4px inset var(--____highlight-solidColor);
+      }
+    }
   }
 
   .new {
-    background-color: var(--mention-bg);
-    color: var(--mention-text, var(--link));
-
     &.-you {
       & .shortName,
       & .full {
@@ -45,5 +114,6 @@
 
   &:hover .new .full {
     opacity: 1;
+    pointer-events: initial;
   }
 }
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index ea356315..5a8506eb 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -12,22 +12,24 @@
       v-if="user"
       class="new"
       :style="style"
-      :class="{ '-you': isYou }"
+      :class="[{ '-you': isYou }, highlightType]"
     >
       <button
-        class="button-unstyled short"
+        class="short"
+        :class="highlight ? 'button-default' : 'button-default' "
         @click.prevent="onClick"
       >
         <!-- eslint-disable vue/no-v-html -->
-        <span class="shortName">@<span v-html="userName" /></span> <span v-if="isYou">{{ $t('status.you')}}</span>
+        <span class="shortName"><span class="userName" v-html="userName" /></span><span class="you" v-if="isYou">{{ $t('status.you')}}</span>
         <!-- eslint-enable vue/no-v-html -->
       </button>
       <span
         v-if="userName !== userNameFull"
-        class="full"
+        class="full popover-default"
+        :class="[highlightType]"
       >
         <!-- eslint-disable vue/no-v-html -->
-        @<span v-html="userNameFull" />
+        <span class="userNameFull" v-html="userNameFull" />
         <!-- eslint-enable vue/no-v-html -->
       </span>
     </span>
diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js
index b91c0f78..3b07592e 100644
--- a/src/services/user_highlighter/user_highlighter.js
+++ b/src/services/user_highlighter/user_highlighter.js
@@ -8,6 +8,11 @@ const highlightStyle = (prefs) => {
   const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})`
   const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)`
   const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)`
+  const customProps = {
+    '--____highlight-solidColor': solidColor,
+    '--____highlight-tintColor': tintColor,
+    '--____highlight-tintColor2': tintColor2
+  }
   if (type === 'striped') {
     return {
       backgroundImage: [
@@ -17,11 +22,13 @@ const highlightStyle = (prefs) => {
         `${tintColor2} 20px,`,
         `${tintColor2} 40px`
       ].join(' '),
-      backgroundPosition: '0 0'
+      backgroundPosition: '0 0',
+      ...customProps
     }
   } else if (type === 'solid') {
     return {
-      backgroundColor: tintColor2
+      backgroundColor: tintColor2,
+      ...customProps
     }
   } else if (type === 'side') {
     return {
@@ -31,7 +38,8 @@ const highlightStyle = (prefs) => {
         `${solidColor} 2px,`,
         `transparent 6px`
       ].join(' '),
-      backgroundPosition: '0 0'
+      backgroundPosition: '0 0',
+      ...customProps
     }
   }
 }

From c3e122ff6f011cb6750fb11030e601e37afcd586 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 23:48:01 +0300
Subject: [PATCH 023/169] smaller mentions

---
 src/components/mention_link/mention_link.scss | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 3a3e58d1..37717424 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -42,13 +42,14 @@
     &.-striped,
     &.-solid,
     &.-side {
-      .full {
-      }
       .short {
+        font-size: 90%;
+        line-height: 1.5;
+        vertical-align: middle;
+
         &::before {
           display: inline-block;
           height: 50%;
-          font-size: 90%;
           line-height: 1;
           vertical-align: 6%;
         }
@@ -65,12 +66,16 @@
       }
 
       .userName {
+        display: inline-block;
         color: var(--link);
+        line-height: inherit;
         margin-left: 0.25em;
         padding-left: 0.25em;
         padding-right: 0.25em;
         padding-top: 0;
         padding-bottom: 0;
+        border-top-right-radius: var(--btnRadius);
+        border-bottom-right-radius: var(--btnRadius);
       }
     }
 

From 59d046b16332e92b40b20c5dd19f074fb290dd17 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 7 Jun 2021 23:48:46 +0300
Subject: [PATCH 024/169] fix theme selection not working

---
 .../tabs/theme_tab/theme_tab.js               | 36 ++++++++++++-------
 .../tabs/theme_tab/theme_tab.vue              |  2 +-
 2 files changed, 25 insertions(+), 13 deletions(-)

diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 1388f74b..c76314f0 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -73,7 +73,8 @@ export default {
         getExportedObject: () => this.exportedTheme
       }),
       availableStyles: [],
-      selected: this.$store.getters.mergedConfig.theme,
+      selected: '',
+      selectedTheme: this.$store.getters.mergedConfig.theme,
       themeWarning: undefined,
       tempImportFile: undefined,
       engineVersion: 0,
@@ -207,7 +208,7 @@ export default {
       }
     },
     selectedVersion () {
-      return Array.isArray(this.selected) ? 1 : 2
+      return Array.isArray(this.selectedTheme) ? 1 : 2
     },
     currentColors () {
       return Object.keys(SLOT_INHERITANCE)
@@ -744,8 +745,19 @@ export default {
         console.warn(e)
       }
     },
-    selected () {
+    selected() {
+      this.selectedTheme = Object.entries(this.availableStyles).find(([k, s]) => {
+        if (Array.isArray(s)) {
+          console.log(s[0] === this.selected, this.selected)
+          return s[0] === this.selected
+        } else {
+          return s.name === this.selected
+        }
+      })[1]
+    },
+    selectedTheme () {
       this.dismissWarning()
+      console.log(this.selectedVersion)
       if (this.selectedVersion === 1) {
         if (!this.keepRoundness) {
           this.clearRoundness()
@@ -762,17 +774,17 @@ export default {
         if (!this.keepColor) {
           this.clearV1()
 
-          this.bgColorLocal = this.selected[1]
-          this.fgColorLocal = this.selected[2]
-          this.textColorLocal = this.selected[3]
-          this.linkColorLocal = this.selected[4]
-          this.cRedColorLocal = this.selected[5]
-          this.cGreenColorLocal = this.selected[6]
-          this.cBlueColorLocal = this.selected[7]
-          this.cOrangeColorLocal = this.selected[8]
+          this.bgColorLocal = this.selectedTheme[1]
+          this.fgColorLocal = this.selectedTheme[2]
+          this.textColorLocal = this.selectedTheme[3]
+          this.linkColorLocal = this.selectedTheme[4]
+          this.cRedColorLocal = this.selectedTheme[5]
+          this.cGreenColorLocal = this.selectedTheme[6]
+          this.cBlueColorLocal = this.selectedTheme[7]
+          this.cOrangeColorLocal = this.selectedTheme[8]
         }
       } else if (this.selectedVersion >= 2) {
-        this.normalizeLocalState(this.selected.theme, 2, this.selected.source)
+        this.normalizeLocalState(this.selectedTheme.theme, 2, this.selectedTheme.source)
       }
     }
   }
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index 548dc852..c02986ed 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -63,7 +63,7 @@
                 <option
                   v-for="style in availableStyles"
                   :key="style.name"
-                  :value="style"
+                  :value="style.name || style[0]"
                   :style="{
                     backgroundColor: style[1] || (style.theme || style.source).colors.bg,
                     color: style[3] || (style.theme || style.source).colors.text

From e6d5ddcbb69ff93e711227672caaf208cf872c21 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 00:03:59 +0300
Subject: [PATCH 025/169] better modifier, no background for unhighlighted
 mentions

---
 src/components/mention_link/mention_link.scss | 13 ++++++++-----
 src/components/mention_link/mention_link.vue  |  2 +-
 2 files changed, 9 insertions(+), 6 deletions(-)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 37717424..4e061be9 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -37,13 +37,15 @@
     }
   }
 
-  & .new {
+  .new:not(.-highlighted) {
+    .short {
+      background: none;
+    }
+  }
+  .new {
     &,
-    &.-striped,
-    &.-solid,
-    &.-side {
+    &.-highlighted {
       .short {
-        font-size: 90%;
         line-height: 1.5;
         vertical-align: middle;
 
@@ -58,6 +60,7 @@
       .you {
         padding-right: 0.25em;
       }
+
       .short {
         padding-left: 0.25em;
         padding-right: 0;
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 5a8506eb..a38faa7b 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -12,7 +12,7 @@
       v-if="user"
       class="new"
       :style="style"
-      :class="[{ '-you': isYou }, highlightType]"
+      :class="[{ '-you': isYou, '-highlighted': highlight }, highlightType]"
     >
       <button
         class="short"

From 5740a79dbd926c4a361cea90a4173dcdcc9bca17 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 00:06:26 +0300
Subject: [PATCH 026/169] faint @

---
 src/components/mention_link/mention_link.scss | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 4e061be9..67819d95 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -33,6 +33,7 @@
   & .short,
   & .full {
     &::before {
+      color: var(--faint);
       content: '@';
     }
   }

From 38d9ea8b1726a8c81ee18b15d5a6d2be52450ff4 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 00:09:16 +0300
Subject: [PATCH 027/169] lint

---
 src/components/emoji_input/emoji_input.vue                | 2 +-
 src/components/settings_modal/tabs/theme_tab/theme_tab.js | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index e6f9a9d3..aa2950ce 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -1,9 +1,9 @@
 <template>
   <div
+    ref="root"
     v-click-outside="onClickOutside"
     class="emoji-input"
     :class="{ 'with-picker': !hideEmojiButton }"
-    ref='root'
   >
     <slot />
     <template v-if="enableEmojiPicker">
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index c76314f0..49b548fb 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -745,7 +745,7 @@ export default {
         console.warn(e)
       }
     },
-    selected() {
+    selected () {
       this.selectedTheme = Object.entries(this.availableStyles).find(([k, s]) => {
         if (Array.isArray(s)) {
           console.log(s[0] === this.selected, this.selected)

From ccdf8924838fe26c8f28e31e624c9d46fe3d11e4 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 01:21:45 +0300
Subject: [PATCH 028/169] remove weird vertical align

---
 src/components/mention_link/mention_link.scss | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 67819d95..44b022bb 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -48,7 +48,6 @@
     &.-highlighted {
       .short {
         line-height: 1.5;
-        vertical-align: middle;
 
         &::before {
           display: inline-block;

From 6bc9886db47987fdd7b43b6319f635aa1824ae45 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 01:25:03 +0300
Subject: [PATCH 029/169] tweaking the spacings

---
 src/components/mention_link/mention_link.scss | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 44b022bb..4a9fab1c 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -72,8 +72,8 @@
         display: inline-block;
         color: var(--link);
         line-height: inherit;
-        margin-left: 0.25em;
-        padding-left: 0.25em;
+        margin-left: 0.125em;
+        padding-left: 0.125em;
         padding-right: 0.25em;
         padding-top: 0;
         padding-bottom: 0;

From 0583a6b8638afc6b3ea07adceecdb42ca9188251 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 10:13:40 +0300
Subject: [PATCH 030/169] moved transparent button styles into button itself

---
 src/App.scss                                  | 4 ++++
 src/components/mention_link/mention_link.scss | 5 -----
 src/components/mention_link/mention_link.vue  | 4 ++--
 3 files changed, 6 insertions(+), 7 deletions(-)

diff --git a/src/App.scss b/src/App.scss
index 45071ba2..bc027f4f 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -88,6 +88,10 @@ a {
   font-family: sans-serif;
   font-family: var(--interfaceFont, sans-serif);
 
+  &.-sublime {
+    background: transparent;
+  }
+
   i[class*=icon-],
   .svg-inline--fa {
     color: $fallback--text;
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 4a9fab1c..78a9816c 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -38,11 +38,6 @@
     }
   }
 
-  .new:not(.-highlighted) {
-    .short {
-      background: none;
-    }
-  }
   .new {
     &,
     &.-highlighted {
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index a38faa7b..d8d45a1b 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -15,8 +15,8 @@
       :class="[{ '-you': isYou, '-highlighted': highlight }, highlightType]"
     >
       <button
-        class="short"
-        :class="highlight ? 'button-default' : 'button-default' "
+        class="short button-default"
+        :class="{ '-sublime': !highlight }"
         @click.prevent="onClick"
       >
         <!-- eslint-disable vue/no-v-html -->

From 3abd357694c29c84d213d7d2a7a954ab2a591da0 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 11:38:44 +0300
Subject: [PATCH 031/169] moving mentions into separate row

---
 src/components/mention_link/mention_link.js   | 20 ++++++++-
 src/components/mention_link/mention_link.scss |  5 +++
 src/components/mention_link/mention_link.vue  |  2 +-
 src/components/rich_content/rich_content.jsx  | 19 ++++++--
 src/components/status/status.js               | 22 +++++++---
 src/components/status/status.scss             |  3 +-
 src/components/status/status.vue              | 43 ++++++++++++++++---
 src/i18n/en.json                              |  1 +
 8 files changed, 94 insertions(+), 21 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index d26ae337..465c7d00 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -14,8 +14,14 @@ const MentionLink = {
       type: String
     },
     origattrs: {
-      required: true,
-      type: Object
+      required: false,
+      type: Object,
+      default: {}
+    },
+    firstMention: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   methods: {
@@ -61,6 +67,16 @@ const MentionLink = {
         return rest
       }
     },
+    classnames () {
+      return [
+        {
+          '-you': this.isYou,
+          '-highlighted': this.highlight,
+          '-firstMention': this.firstMention
+        },
+        this.highlightType
+      ]
+    },
     ...mapGetters(['mergedConfig']),
     ...mapState({
       currentUser: state => state.users.currentUser
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 78a9816c..dec11014 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -39,10 +39,15 @@
   }
 
   .new {
+    &.-firstMention {
+      display: none;
+    }
+
     &,
     &.-highlighted {
       .short {
         line-height: 1.5;
+        font-size: inherit;
 
         &::before {
           display: inline-block;
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index d8d45a1b..05f6fd91 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -12,7 +12,7 @@
       v-if="user"
       class="new"
       :style="style"
-      :class="[{ '-you': isYou, '-highlighted': highlight }, highlightType]"
+      :class="classnames"
     >
       <button
         class="short button-default"
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index c15877c8..86a318bd 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -33,21 +33,32 @@ export default Vue.component('RichContent', {
         class="img"
       />
     }
-    const renderMention = (attrs, children) => {
+    const renderMention = (attrs, children, encounteredText) => {
       return <MentionLink
         url={attrs.href}
         content={flattenDeep(children).join('')}
+        firstMention={!encounteredText}
         origattrs={attrs}
       />
     }
 
+    let encounteredText = false
     // Processor to use with mini_html_converter
     const processItem = (item) => {
       // Handle text noes - just add emoji
       if (typeof item === 'string') {
+        const emptyText = item.trim()
+        if (!emptyText) {
+          return encounteredText ? item : item.trim()
+        }
+        let unescapedItem = unescape(item)
+        if (!encounteredText) {
+          unescapedItem = unescapedItem.trimStart()
+          encounteredText = true
+        }
         if (item.includes(':')) {
           return processTextForEmoji(
-            unescape(item),
+            unescapedItem,
             this.emoji,
             ({ shortcode, url }) => {
               return <StillImage
@@ -59,7 +70,7 @@ export default Vue.component('RichContent', {
             }
           )
         } else {
-          return unescape(item)
+          return unescapedItem
         }
       }
       // Handle tag nodes
@@ -73,7 +84,7 @@ export default Vue.component('RichContent', {
             if (!this.handleLinks) break
             const attrs = getAttrs(opener)
             if (attrs['class'] && attrs['class'].includes('mention')) {
-              return renderMention(attrs, children)
+              return renderMention(attrs, children, encounteredText)
             }
         }
         // Render tag as is
diff --git a/src/components/status/status.js b/src/components/status/status.js
index b6414ad8..54b54a90 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -13,6 +13,7 @@ import RichContent from 'src/components/rich_content/rich_content.jsx'
 import StatusPopover from '../status_popover/status_popover.vue'
 import UserListPopover from '../user_list_popover/user_list_popover.vue'
 import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
+import MentionLink from 'src/components/mention_link/mention_link.vue'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import { muteWordHits } from '../../services/status_parser/status_parser.js'
@@ -33,7 +34,8 @@ import {
   faStar,
   faEyeSlash,
   faEye,
-  faThumbtack
+  faThumbtack,
+  faAt,
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -50,7 +52,8 @@ library.add(
   faEllipsisH,
   faEyeSlash,
   faEye,
-  faThumbtack
+  faThumbtack,
+  faAt
 )
 
 const Status = {
@@ -70,7 +73,8 @@ const Status = {
     UserListPopover,
     EmojiReactions,
     StatusContent,
-    RichContent
+    RichContent,
+    MentionLink
   },
   props: [
     'statusoid',
@@ -133,9 +137,7 @@ const Status = {
       return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
     },
     replyProfileLink () {
-      if (this.isReply) {
-        return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName)
-      }
+      return this.$store.getters.findUser(this.status.in_reply_to_screen_name).statusnet_profile_url
     },
     retweet () { return !!this.statusoid.retweeted_status },
     retweeterUser () { return this.statusoid.user },
@@ -159,6 +161,14 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
+    mentions () {
+      return this.statusoid.attentions.filter(attn => {
+        return attn.screen_name !== this.replyToName
+      })
+    },
+    hasMentions () {
+      return this.mentions.length > 0
+    },
     muted () {
       if (this.statusoid.user.id === this.currentUser.id) return false
       const { status } = this
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 82088943..8744fa16 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -155,7 +155,8 @@ $status-margin: 0.75em;
     margin-right: 0.2em;
   }
 
-  .heading-reply-row {
+  & .heading-mentions-row,
+  & .heading-reply-row {
     position: relative;
     align-content: baseline;
     font-size: 12px;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index a4247cd4..092d3834 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -222,6 +222,35 @@
               </span>
             </div>
 
+            <div v-if="hasMentions" class="heading-mentions-row">
+              <div
+                class="mentions"
+              >
+                <span
+                  class="button-unstyled reply-to"
+                  :aria-label="$t('tool_tip.reply')"
+                  @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+                >
+                  <FAIcon
+                    class="fa-scale-110 fa-old-padding"
+                    icon="at"
+                  />
+                  <span
+                    class="faint-link reply-to-text"
+                  >
+                    {{ $t('status.mentions') }}
+                  </span>
+                </span>
+                <MentionLink
+                  v-for="mention in mentions"
+                  class="mention-link"
+                  :key="mention.statusnet_profile_url"
+                  :content="mention.statusnet_profile_url"
+                  :url="mention.statusnet_profile_url"
+                  :first-mention="false"
+                />
+              </div>
+            </div>
             <div class="heading-reply-row">
               <div
                 v-if="isReply"
@@ -258,13 +287,13 @@
                 >
                   <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
                 </span>
-                <router-link
-                  class="reply-to-link"
-                  :title="replyToName"
-                  :to="replyProfileLink"
-                >
-                  {{ replyToName }}
-                </router-link>
+
+                <MentionLink
+                  class="mention-link"
+                  :content="replyToName"
+                  :url="replyProfileLink"
+                  :first-mention="false"
+                />
                 <span
                   v-if="replies && replies.length"
                   class="faint replies-separator"
diff --git a/src/i18n/en.json b/src/i18n/en.json
index d64adaff..cd366132 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -697,6 +697,7 @@
     "unbookmark": "Unbookmark",
     "delete_confirm": "Do you really want to delete this status?",
     "reply_to": "Reply to",
+    "mentions": "Mentions",
     "replies_list": "Replies:",
     "mute_conversation": "Mute conversation",
     "unmute_conversation": "Unmute conversation",

From 9ea370033af450c0d5e4650a7c10f0e1ffec911c Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 12:58:28 +0300
Subject: [PATCH 032/169] configurable mentions placement

---
 src/components/mention_link/mention_link.js   | 14 +++--
 src/components/mention_link/mention_link.scss | 22 +++----
 src/components/mention_link/mention_link.vue  |  9 ++-
 src/components/rich_content/rich_content.jsx  |  1 -
 .../settings_modal/tabs/general_tab.vue       | 10 ++++
 src/components/status/status.js               |  8 ++-
 src/components/status/status.vue              | 60 +++++++++----------
 src/i18n/en.json                              |  2 +
 src/modules/config.js                         |  2 +
 9 files changed, 75 insertions(+), 53 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 465c7d00..8a93e8a3 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -13,11 +13,6 @@ const MentionLink = {
       required: true,
       type: String
     },
-    origattrs: {
-      required: false,
-      type: Object,
-      default: {}
-    },
     firstMention: {
       required: false,
       type: Boolean,
@@ -56,6 +51,12 @@ const MentionLink = {
     highlightClass () {
       if (this.highlight) return highlightClass(this.user)
     },
+    oldPlace () {
+      return this.mergedConfig.mentionsOldPlace
+    },
+    oldStyle () {
+      return this.mergedConfig.mentionsOldStyle
+    },
     style () {
       if (this.highlight) {
         const {
@@ -72,7 +73,8 @@ const MentionLink = {
         {
           '-you': this.isYou,
           '-highlighted': this.highlight,
-          '-firstMention': this.firstMention
+          '-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 dec11014..261aeaef 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -3,6 +3,7 @@
   white-space: normal;
   display: inline-block;
   color: var(--link);
+  margin-right: 0.25em;
 
   & .new,
   & .original {
@@ -33,23 +34,25 @@
   & .short,
   & .full {
     &::before {
-      color: var(--faint);
       content: '@';
     }
   }
 
   .new {
-    &.-firstMention {
-      display: none;
+    &.-you {
+      & .shortName,
+      & .full {
+        font-weight: 600;
+      }
     }
 
-    &,
-    &.-highlighted {
+    &:not(.-oldStyle) {
       .short {
         line-height: 1.5;
         font-size: inherit;
 
         &::before {
+          color: var(--faint);
           display: inline-block;
           height: 50%;
           line-height: 1;
@@ -111,12 +114,9 @@
     }
   }
 
-  .new {
-    &.-you {
-      & .shortName,
-      & .full {
-        font-weight: 600;
-      }
+  &:not(.-oldPlace) {
+    .new.-firstMention {
+      display: none;
     }
   }
 
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 05f6fd91..0dae1f53 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -1,5 +1,8 @@
 <template>
-  <span class="MentionLink">
+<span
+  class="MentionLink"
+  :class="{ '-oldPlace': oldPlace }"
+>
     <!-- eslint-disable vue/no-v-html -->
     <a
       v-if="!user"
@@ -15,8 +18,8 @@
       :class="classnames"
     >
       <button
-        class="short button-default"
-        :class="{ '-sublime': !highlight }"
+        class="short"
+        :class="[{ '-sublime': !highlight }, oldStyle ? 'button-unstyled' : 'button-default']"
         @click.prevent="onClick"
       >
         <!-- eslint-disable vue/no-v-html -->
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 86a318bd..26a64f28 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -38,7 +38,6 @@ export default Vue.component('RichContent', {
         url={attrs.href}
         content={flattenDeep(children).join('')}
         firstMention={!encounteredText}
-        origattrs={attrs}
       />
     }
 
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index bdb29edf..c8bab869 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -36,6 +36,16 @@
             {{ $t('settings.collapse_subject') }}
           </BooleanSetting>
         </li>
+        <li>
+          <BooleanSetting path="mentionsOldPlace">
+            {{ $t('settings.mentions_old_place') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting path="mentionsOldStyle">
+            {{ $t('settings.mentions_old_style') }}
+          </BooleanSetting>
+        </li>
         <li>
           <BooleanSetting path="streaming">
             {{ $t('settings.streaming') }}
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 54b54a90..7d2ec514 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -105,6 +105,9 @@ const Status = {
     muteWords () {
       return this.mergedConfig.muteWords
     },
+    mentionsOldPlace () {
+      return this.mergedConfig.mentionsOldPlace
+    },
     showReasonMutedThread () {
       return (
         this.status.thread_muted ||
@@ -137,7 +140,7 @@ const Status = {
       return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
     },
     replyProfileLink () {
-      return this.$store.getters.findUser(this.status.in_reply_to_screen_name).statusnet_profile_url
+      return this.$store.getters.findUser(this.status.in_reply_to_user_id).statusnet_profile_url
     },
     retweet () { return !!this.statusoid.retweeted_status },
     retweeterUser () { return this.statusoid.user },
@@ -163,7 +166,8 @@ const Status = {
     },
     mentions () {
       return this.statusoid.attentions.filter(attn => {
-        return attn.screen_name !== this.replyToName
+        return attn.screen_name !== this.replyToName &&
+          attn.screen_name !== this.statusoid.user.screen_name
       })
     },
     hasMentions () {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 092d3834..d0fb150d 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -221,36 +221,6 @@
                 </button>
               </span>
             </div>
-
-            <div v-if="hasMentions" class="heading-mentions-row">
-              <div
-                class="mentions"
-              >
-                <span
-                  class="button-unstyled reply-to"
-                  :aria-label="$t('tool_tip.reply')"
-                  @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
-                >
-                  <FAIcon
-                    class="fa-scale-110 fa-old-padding"
-                    icon="at"
-                  />
-                  <span
-                    class="faint-link reply-to-text"
-                  >
-                    {{ $t('status.mentions') }}
-                  </span>
-                </span>
-                <MentionLink
-                  v-for="mention in mentions"
-                  class="mention-link"
-                  :key="mention.statusnet_profile_url"
-                  :content="mention.statusnet_profile_url"
-                  :url="mention.statusnet_profile_url"
-                  :first-mention="false"
-                />
-              </div>
-            </div>
             <div class="heading-reply-row">
               <div
                 v-if="isReply"
@@ -320,6 +290,36 @@
                 </StatusPopover>
               </div>
             </div>
+
+            <div v-if="hasMentions && !mentionsOldPlace" class="heading-mentions-row">
+              <div
+                class="mentions"
+              >
+                <span
+                  class="button-unstyled reply-to"
+                  :aria-label="$t('tool_tip.reply')"
+                  @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+                >
+                  <FAIcon
+                    class="fa-scale-110 fa-old-padding"
+                    icon="at"
+                  />
+                  <span
+                    class="faint-link reply-to-text"
+                  >
+                    {{ $t('status.mentions') }}
+                  </span>
+                </span>
+                <MentionLink
+                  v-for="mention in mentions"
+                  class="mention-link"
+                  :key="mention.statusnet_profile_url"
+                  :content="mention.statusnet_profile_url"
+                  :url="mention.statusnet_profile_url"
+                  :first-mention="false"
+                />
+              </div>
+            </div>
           </div>
 
           <StatusContent
diff --git a/src/i18n/en.json b/src/i18n/en.json
index cd366132..da2f6ddf 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -259,6 +259,8 @@
     "security": "Security",
     "setting_changed": "Setting is different from default",
     "enter_current_password_to_confirm": "Enter your current password to confirm your identity",
+    "mentions_old_style": "Old style mentions",
+    "mentions_old_place": "Leave mentions inside post",
     "mfa": {
       "otp": "OTP",
       "setup_otp": "Setup OTP",
diff --git a/src/modules/config.js b/src/modules/config.js
index 0f596750..6e21e38b 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -54,6 +54,8 @@ export const defaultState = {
   interfaceLanguage: browserLocale,
   hideScopeNotice: false,
   useStreamingApi: false,
+  mentionsOldPlace: false,
+  mentionsOldStyle: false,
   sidebarRight: undefined, // instance default
   scopeCopy: undefined, // instance default
   subjectLineBehavior: undefined, // instance default

From 73127f0e2587ae2e06c3480451e9ea7fce3ce4c7 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 13:42:16 +0300
Subject: [PATCH 033/169] fix empty spaces again

---
 src/components/mention_link/mention_link.scss | 5 +++--
 src/components/rich_content/rich_content.jsx  | 4 ++--
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 261aeaef..5b5218f7 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -3,7 +3,6 @@
   white-space: normal;
   display: inline-block;
   color: var(--link);
-  margin-right: 0.25em;
 
   & .new,
   & .original {
@@ -12,7 +11,7 @@
   }
 
   .original {
-    opacity: 0.5;
+    margin-right: 0.25em;
   }
 
   .full {
@@ -39,6 +38,8 @@
   }
 
   .new {
+    margin-right: 0.25em;
+
     &.-you {
       & .shortName,
       & .full {
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 26a64f28..bb7ae739 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -46,8 +46,8 @@ export default Vue.component('RichContent', {
     const processItem = (item) => {
       // Handle text noes - just add emoji
       if (typeof item === 'string') {
-        const emptyText = item.trim()
-        if (!emptyText) {
+        const emptyText = item.trim() === ''
+        if (emptyText) {
           return encounteredText ? item : item.trim()
         }
         let unescapedItem = unescape(item)

From 2f383c2c0197b94b30fdc4c5e0c742c7e104be20 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 14:34:47 +0300
Subject: [PATCH 034/169] moved mentions into a separate component -
 MentionLine, added collapsing of mentions when there's too many of 'em

---
 src/components/mention_link/mention_link.scss | 10 ++--
 src/components/mention_link/mention_link.vue  |  3 +-
 src/components/mentions_line/mentions_line.js | 51 +++++++++++++++++++
 .../mentions_line/mentions_line.scss          | 15 ++++++
 .../mentions_line/mentions_line.vue           | 42 +++++++++++++++
 src/components/status/status.js               | 10 ++--
 src/components/status/status.vue              | 10 ++--
 src/components/status_body/status_body.js     | 10 +++-
 src/components/status_body/status_body.vue    | 25 ++++++---
 src/i18n/en.json                              |  3 +-
 10 files changed, 151 insertions(+), 28 deletions(-)
 create mode 100644 src/components/mentions_line/mentions_line.js
 create mode 100644 src/components/mentions_line/mentions_line.scss
 create mode 100644 src/components/mentions_line/mentions_line.vue

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 5b5218f7..eed4d5be 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -40,6 +40,10 @@
   .new {
     margin-right: 0.25em;
 
+    &.-firstMention {
+      display: none;
+    }
+
     &.-you {
       & .shortName,
       & .full {
@@ -115,12 +119,6 @@
     }
   }
 
-  &:not(.-oldPlace) {
-    .new.-firstMention {
-      display: none;
-    }
-  }
-
   &:hover .new .full {
     opacity: 1;
     pointer-events: initial;
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 0dae1f53..d2f4129d 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -23,7 +23,8 @@
         @click.prevent="onClick"
       >
         <!-- eslint-disable vue/no-v-html -->
-        <span class="shortName"><span class="userName" v-html="userName" /></span><span class="you" v-if="isYou">{{ $t('status.you')}}</span>
+        <span class="shortName"><span class="userName" v-html="userName" /></span>
+        <span class="you" v-if="isYou">{{ $t('status.you') }}</span>
         <!-- eslint-enable vue/no-v-html -->
       </button>
       <span
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
new file mode 100644
index 00000000..837935ec
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.js
@@ -0,0 +1,51 @@
+import MentionLink from 'src/components/mention_link/mention_link.vue'
+import { mapGetters } from 'vuex'
+
+const MentionsLine = {
+  name: 'MentionsLine',
+  props: {
+    attentions: {
+      required: true,
+      type: Object
+    }
+  },
+  data: () => ({ expanded: false }),
+  components: {
+    MentionLink
+  },
+  computed: {
+    oldStyle () {
+      return this.mergedConfig.mentionsOldStyle
+    },
+    limit () {
+      return 1
+    },
+    mentions () {
+      return this.attentions.slice(0, this.limit)
+    },
+    extraMentions () {
+      return this.attentions.slice(this.limit)
+    },
+    manyMentions () {
+      return this.extraMentions.length > 0
+    },
+    buttonClasses () {
+      return [
+        this.oldStyle
+          ? 'button-unstyled'
+          : 'button-default -sublime',
+        this.oldStyle
+          ? '-oldStyle'
+          : '-newStyle'
+      ]
+    },
+    ...mapGetters(['mergedConfig']),
+  },
+  methods: {
+    toggleShowMore () {
+      this.expanded = !this.expanded
+    }
+  }
+}
+
+export default MentionsLine
diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
new file mode 100644
index 00000000..735502de
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.scss
@@ -0,0 +1,15 @@
+.MentionsLine {
+  .showMoreLess {
+    &.-newStyle {
+      line-height: 1.5;
+      font-size: inherit;
+      display: inline-block;
+      padding-top: 0;
+      padding-bottom: 0;
+    }
+
+    &.-oldStyle {
+      color: var(--link);
+    }
+  }
+}
diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue
new file mode 100644
index 00000000..6d114f2d
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.vue
@@ -0,0 +1,42 @@
+<template>
+<span class="MentionsLine">
+  <MentionLink
+    v-for="mention in mentions"
+    class="mention-link"
+    :key="mention.statusnet_profile_url"
+    :content="mention.statusnet_profile_url"
+    :url="mention.statusnet_profile_url"
+    :first-mention="false"
+  /><span v-if="manyMentions" class="extraMentions">
+    <span
+      v-if="expanded"
+      class="fullExtraMentions"
+    >
+      <MentionLink
+        v-for="mention in extraMentions"
+        class="mention-link"
+        :key="mention.statusnet_profile_url"
+        :content="mention.statusnet_profile_url"
+        :url="mention.statusnet_profile_url"
+        :first-mention="false"
+      />
+    </span><button
+      v-if="!expanded"
+      class="showMoreLess"
+      :class="buttonClasses"
+      @click="toggleShowMore"
+    >
+      {{ $t('status.plus_more', { number: extraMentions.length })}}
+    </button><button
+      v-if="expanded"
+      class="showMoreLess"
+      :class="buttonClasses"
+      @click="toggleShowMore"
+    >
+      {{ $t('general.show_less')}}
+    </button>
+  </span>
+</span>
+</template>
+<script src="./mentions_line.js" ></script>
+<style lang="scss" src="./mentions_line.scss" />
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 7d2ec514..d921f625 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -13,6 +13,7 @@ import RichContent from 'src/components/rich_content/rich_content.jsx'
 import StatusPopover from '../status_popover/status_popover.vue'
 import UserListPopover from '../user_list_popover/user_list_popover.vue'
 import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
+import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
 import MentionLink from 'src/components/mention_link/mention_link.vue'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
@@ -74,7 +75,8 @@ const Status = {
     EmojiReactions,
     StatusContent,
     RichContent,
-    MentionLink
+    MentionLink,
+    MentionsLine
   },
   props: [
     'statusoid',
@@ -105,9 +107,6 @@ const Status = {
     muteWords () {
       return this.mergedConfig.muteWords
     },
-    mentionsOldPlace () {
-      return this.mergedConfig.mentionsOldPlace
-    },
     showReasonMutedThread () {
       return (
         this.status.thread_muted ||
@@ -164,6 +163,9 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
+    mentionsOldPlace () {
+      return this.mergedConfig.mentionsOldPlace
+    },
     mentions () {
       return this.statusoid.attentions.filter(attn => {
         return attn.screen_name !== this.replyToName &&
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index d0fb150d..faf67328 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -310,13 +310,9 @@
                     {{ $t('status.mentions') }}
                   </span>
                 </span>
-                <MentionLink
-                  v-for="mention in mentions"
-                  class="mention-link"
-                  :key="mention.statusnet_profile_url"
-                  :content="mention.statusnet_profile_url"
-                  :url="mention.statusnet_profile_url"
-                  :first-mention="false"
+                <MentionsLine
+                  :attentions="mentions"
+                  class="mentions-line"
                 />
               </div>
             </div>
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 232afccb..0c1aa88e 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -1,5 +1,6 @@
 import fileType from 'src/services/file_type/file_type.service'
 import RichContent 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'
@@ -104,10 +105,17 @@ const StatusContent = {
     attachmentTypes () {
       return this.status.attachments.map(file => fileType.fileType(file.mimetype))
     },
+    mentionsOldPlace () {
+      return this.mergedConfig.mentionsOldPlace
+    },
+    mentions () {
+      return this.status.attentions
+    },
     ...mapGetters(['mergedConfig'])
   },
   components: {
-    RichContent
+    RichContent,
+    MentionsLine
   },
   mounted () {
     this.status.attentions && this.status.attentions.forEach(attn => {
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 6f982f2e..92e47118 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -39,15 +39,24 @@
         >
           {{ $t("general.show_more") }}
         </button>
-        <RichContent
+        <span
           v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
-          :class="{ '-single-line': singleLine }"
-          class="text media-body"
-          :html="postBodyHtml"
-          :emoji="status.emojis"
-          :handle-links="true"
-          @click.prevent="linkClicked"
-        />
+        >
+          <MentionsLine
+            v-if="mentionsOldPlace"
+            :attentions="status.attentions"
+            class="mentions-line"
+            />
+          <RichContent
+            :class="{ '-single-line': singleLine }"
+            class="text media-body"
+            :html="postBodyHtml"
+            :emoji="status.emojis"
+            :handle-links="true"
+            @click.prevent="linkClicked"
+          />
+        </span>
+
         <button
           v-if="hideSubjectStatus"
           class="button-unstyled -link cw-status-hider"
diff --git a/src/i18n/en.json b/src/i18n/en.json
index da2f6ddf..de1f23b4 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -715,7 +715,8 @@
     "status_deleted": "This post was deleted",
     "nsfw": "NSFW",
     "expand": "Expand",
-    "you": "(You)"
+    "you": "(You)",
+    "plus_more": "+{number} more"
   },
   "user_card": {
     "approve": "Approve",

From 0ae3985a522d1ad367d02b86b8b5eca770d1b292 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 14:36:41 +0300
Subject: [PATCH 035/169] bump limit to a saner one

---
 src/components/mentions_line/mentions_line.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
index 837935ec..2e84d4c5 100644
--- a/src/components/mentions_line/mentions_line.js
+++ b/src/components/mentions_line/mentions_line.js
@@ -18,7 +18,7 @@ const MentionsLine = {
       return this.mergedConfig.mentionsOldStyle
     },
     limit () {
-      return 1
+      return 6
     },
     mentions () {
       return this.attentions.slice(0, this.limit)

From 7ae85c8318add44af1c8f91c9738a2d715da2f3e Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 14:51:42 +0300
Subject: [PATCH 036/169] change defaults

---
 src/components/mention_link/mention_link.js   |  4 +-
 src/components/mention_link/mention_link.vue  | 23 ++++--
 src/components/mentions_line/mentions_line.js |  4 +-
 .../mentions_line/mentions_line.vue           | 75 ++++++++++---------
 .../settings_modal/tabs/general_tab.vue       |  8 +-
 src/components/status/status.js               |  4 +-
 src/components/status/status.vue              |  5 +-
 src/components/status_body/status_body.js     |  4 +-
 src/components/status_body/status_body.vue    |  2 +-
 src/modules/config.js                         |  4 +-
 10 files changed, 74 insertions(+), 59 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 8a93e8a3..8ad84a51 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -52,10 +52,10 @@ const MentionLink = {
       if (this.highlight) return highlightClass(this.user)
     },
     oldPlace () {
-      return this.mergedConfig.mentionsOldPlace
+      return !this.mergedConfig.mentionsOwnLine
     },
     oldStyle () {
-      return this.mergedConfig.mentionsOldStyle
+      return !this.mergedConfig.mentionsNewStyle
     },
     style () {
       if (this.highlight) {
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index d2f4129d..55f49507 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -1,8 +1,8 @@
 <template>
-<span
-  class="MentionLink"
-  :class="{ '-oldPlace': oldPlace }"
->
+  <span
+    class="MentionLink"
+    :class="{ '-oldPlace': oldPlace }"
+  >
     <!-- eslint-disable vue/no-v-html -->
     <a
       v-if="!user"
@@ -23,8 +23,14 @@
         @click.prevent="onClick"
       >
         <!-- eslint-disable vue/no-v-html -->
-        <span class="shortName"><span class="userName" v-html="userName" /></span>
-        <span class="you" v-if="isYou">{{ $t('status.you') }}</span>
+        <span class="shortName"><span
+          class="userName"
+          v-html="userName"
+        /></span>
+        <span
+          v-if="isYou"
+          class="you"
+        >{{ $t('status.you') }}</span>
         <!-- eslint-enable vue/no-v-html -->
       </button>
       <span
@@ -33,7 +39,10 @@
         :class="[highlightType]"
       >
         <!-- eslint-disable vue/no-v-html -->
-        <span class="userNameFull" v-html="userNameFull" />
+        <span
+          class="userNameFull"
+          v-html="userNameFull"
+        />
         <!-- eslint-enable vue/no-v-html -->
       </span>
     </span>
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
index 2e84d4c5..6f347058 100644
--- a/src/components/mentions_line/mentions_line.js
+++ b/src/components/mentions_line/mentions_line.js
@@ -15,7 +15,7 @@ const MentionsLine = {
   },
   computed: {
     oldStyle () {
-      return this.mergedConfig.mentionsOldStyle
+      return !this.mergedConfig.mentionsNewStyle
     },
     limit () {
       return 6
@@ -39,7 +39,7 @@ const MentionsLine = {
           : '-newStyle'
       ]
     },
-    ...mapGetters(['mergedConfig']),
+    ...mapGetters(['mergedConfig'])
   },
   methods: {
     toggleShowMore () {
diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue
index 6d114f2d..58f3de6f 100644
--- a/src/components/mentions_line/mentions_line.vue
+++ b/src/components/mentions_line/mentions_line.vue
@@ -1,42 +1,45 @@
 <template>
-<span class="MentionsLine">
-  <MentionLink
-    v-for="mention in mentions"
-    class="mention-link"
-    :key="mention.statusnet_profile_url"
-    :content="mention.statusnet_profile_url"
-    :url="mention.statusnet_profile_url"
-    :first-mention="false"
-  /><span v-if="manyMentions" class="extraMentions">
-    <span
-      v-if="expanded"
-      class="fullExtraMentions"
+  <span class="MentionsLine">
+    <MentionLink
+      v-for="mention in mentions"
+      :key="mention.statusnet_profile_url"
+      class="mention-link"
+      :content="mention.statusnet_profile_url"
+      :url="mention.statusnet_profile_url"
+      :first-mention="false"
+    /><span
+      v-if="manyMentions"
+      class="extraMentions"
     >
-      <MentionLink
-        v-for="mention in extraMentions"
-        class="mention-link"
-        :key="mention.statusnet_profile_url"
-        :content="mention.statusnet_profile_url"
-        :url="mention.statusnet_profile_url"
-        :first-mention="false"
-      />
-    </span><button
-      v-if="!expanded"
-      class="showMoreLess"
-      :class="buttonClasses"
-      @click="toggleShowMore"
-    >
-      {{ $t('status.plus_more', { number: extraMentions.length })}}
-    </button><button
-      v-if="expanded"
-      class="showMoreLess"
-      :class="buttonClasses"
-      @click="toggleShowMore"
-    >
-      {{ $t('general.show_less')}}
-    </button>
+      <span
+        v-if="expanded"
+        class="fullExtraMentions"
+      >
+        <MentionLink
+          v-for="mention in extraMentions"
+          :key="mention.statusnet_profile_url"
+          class="mention-link"
+          :content="mention.statusnet_profile_url"
+          :url="mention.statusnet_profile_url"
+          :first-mention="false"
+        />
+      </span><button
+        v-if="!expanded"
+        class="showMoreLess"
+        :class="buttonClasses"
+        @click="toggleShowMore"
+      >
+        {{ $t('status.plus_more', { number: extraMentions.length }) }}
+      </button><button
+        v-if="expanded"
+        class="showMoreLess"
+        :class="buttonClasses"
+        @click="toggleShowMore"
+      >
+        {{ $t('general.show_less') }}
+      </button>
+    </span>
   </span>
-</span>
 </template>
 <script src="./mentions_line.js" ></script>
 <style lang="scss" src="./mentions_line.scss" />
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index c8bab869..e62df290 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -37,13 +37,13 @@
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="mentionsOldPlace">
-            {{ $t('settings.mentions_old_place') }}
+          <BooleanSetting path="mentionsOwnLine">
+            {{ $t('settings.mentions_new_place') }}
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="mentionsOldStyle">
-            {{ $t('settings.mentions_old_style') }}
+          <BooleanSetting path="mentionsNewStyle">
+            {{ $t('settings.mentions_new_style') }}
           </BooleanSetting>
         </li>
         <li>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index d921f625..3954df00 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -163,8 +163,8 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
-    mentionsOldPlace () {
-      return this.mergedConfig.mentionsOldPlace
+    mentionsOwnLine () {
+      return this.mergedConfig.mentionsOwnLine
     },
     mentions () {
       return this.statusoid.attentions.filter(attn => {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index faf67328..969e2bc8 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -291,7 +291,10 @@
               </div>
             </div>
 
-            <div v-if="hasMentions && !mentionsOldPlace" class="heading-mentions-row">
+            <div
+              v-if="hasMentions && mentionsOwnLine"
+              class="heading-mentions-row"
+            >
               <div
                 class="mentions"
               >
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 0c1aa88e..3c092ac7 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -105,8 +105,8 @@ const StatusContent = {
     attachmentTypes () {
       return this.status.attachments.map(file => fileType.fileType(file.mimetype))
     },
-    mentionsOldPlace () {
-      return this.mergedConfig.mentionsOldPlace
+    mentionsOwnLine () {
+      return this.mergedConfig.mentionsOwnLine
     },
     mentions () {
       return this.status.attentions
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 92e47118..fadbeaf3 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -43,7 +43,7 @@
           v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
         >
           <MentionsLine
-            v-if="mentionsOldPlace"
+            v-if="!mentionsOwnLine"
             :attentions="status.attentions"
             class="mentions-line"
             />
diff --git a/src/modules/config.js b/src/modules/config.js
index 6e21e38b..ad9c8be5 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -54,8 +54,8 @@ export const defaultState = {
   interfaceLanguage: browserLocale,
   hideScopeNotice: false,
   useStreamingApi: false,
-  mentionsOldPlace: false,
-  mentionsOldStyle: false,
+  mentionsOwnLine: false,
+  mentionsNewStyle: false,
   sidebarRight: undefined, // instance default
   scopeCopy: undefined, // instance default
   subjectLineBehavior: undefined, // instance default

From a3b8e7ad9936ec0353e1ac54402a6e7ae2ef62d8 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 16:37:13 +0300
Subject: [PATCH 037/169] missing localization

---
 src/i18n/en.json | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/i18n/en.json b/src/i18n/en.json
index de1f23b4..2b253efb 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -259,8 +259,8 @@
     "security": "Security",
     "setting_changed": "Setting is different from default",
     "enter_current_password_to_confirm": "Enter your current password to confirm your identity",
-    "mentions_old_style": "Old style mentions",
-    "mentions_old_place": "Leave mentions inside post",
+    "mentions_new_style": "Fancier mention links",
+    "mentions_new_place": "Put mentions on a separate line",
     "mfa": {
       "otp": "OTP",
       "setup_otp": "Setup OTP",

From 963f1679e0b24b36c77f15bb7c8e832583f93d79 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 17:04:57 +0300
Subject: [PATCH 038/169] fix console errors

---
 src/components/mention_link/mention_link.js   | 26 +++++++++++++------
 src/components/mentions_line/mentions_line.js |  2 +-
 src/components/status/status.js               |  5 +++-
 src/components/status/status.vue              |  2 ++
 4 files changed, 25 insertions(+), 10 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 8ad84a51..acd0f584 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -6,13 +6,20 @@ const MentionLink = {
   name: 'MentionLink',
   props: {
     url: {
-      required: true,
       type: String
     },
     content: {
       required: true,
       type: String
     },
+    userId: {
+      required: false,
+      type: String
+    },
+    userScreenName: {
+      required: false,
+      type: String
+    },
     firstMention: {
       required: false,
       type: Boolean,
@@ -21,29 +28,32 @@ const MentionLink = {
   },
   methods: {
     onClick () {
-      const link = generateProfileLink(this.user.id, this.user.screen_name)
+      const link = generateProfileLink(
+        this.userId || this.user.id,
+        this.userScreenName || this.user.screen_name
+      )
       this.$router.push(link)
     }
   },
   computed: {
     user () {
-      return this.$store.getters.findUserByUrl(this.url)
+      return this.url && this.$store.getters.findUserByUrl(this.url)
     },
     isYou () {
       // FIXME why user !== currentUser???
-      return this.user.screen_name === this.currentUser.screen_name
+      return this.user && this.user.screen_name === this.currentUser.screen_name
     },
     userName () {
-      return this.userNameFullUi.split('@')[0]
+      return this.user && this.userNameFullUi.split('@')[0]
     },
     userNameFull () {
-      return this.user.screen_name
+      return this.user && this.user.screen_name
     },
     userNameFullUi () {
-      return this.user.screen_name_ui
+      return this.user && this.user.screen_name_ui
     },
     highlight () {
-      return this.mergedConfig.highlight[this.user.screen_name]
+      return this.user && this.mergedConfig.highlight[this.user.screen_name]
     },
     highlightType () {
       return this.highlight && ('-' + this.highlight.type)
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
index 6f347058..4b20d861 100644
--- a/src/components/mentions_line/mentions_line.js
+++ b/src/components/mentions_line/mentions_line.js
@@ -6,7 +6,7 @@ const MentionsLine = {
   props: {
     attentions: {
       required: true,
-      type: Object
+      type: Array
     }
   },
   data: () => ({ expanded: false }),
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 3954df00..eb0af098 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -139,7 +139,10 @@ const Status = {
       return this.generateUserProfileLink(this.status.user.id, this.status.user.screen_name)
     },
     replyProfileLink () {
-      return this.$store.getters.findUser(this.status.in_reply_to_user_id).statusnet_profile_url
+      if (this.isReply) {
+        const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
+        return user && user.statusnet_profile_url
+      }
     },
     retweet () { return !!this.statusoid.retweeted_status },
     retweeterUser () { return this.statusoid.user },
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 969e2bc8..d62c6817 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -262,6 +262,8 @@
                   class="mention-link"
                   :content="replyToName"
                   :url="replyProfileLink"
+                  :userId="status.in_reply_to_user_id"
+                  :userScreenName="status.in_reply_to_screen_name"
                   :first-mention="false"
                 />
                 <span

From 8c8237418cf36e97c76dfe339ab49df4a12be7a4 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 17:05:20 +0300
Subject: [PATCH 039/169] fix repeats having wrong mentions

---
 src/components/status/status.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/status/status.js b/src/components/status/status.js
index eb0af098..9b1c1b9c 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -170,9 +170,9 @@ const Status = {
       return this.mergedConfig.mentionsOwnLine
     },
     mentions () {
-      return this.statusoid.attentions.filter(attn => {
+      return this.status.attentions.filter(attn => {
         return attn.screen_name !== this.replyToName &&
-          attn.screen_name !== this.statusoid.user.screen_name
+          attn.screen_name !== this.status.user.screen_name
       })
     },
     hasMentions () {

From 5c655b6675718e1617a2bf94c4ec75167f0a0e23 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 17:19:38 +0300
Subject: [PATCH 040/169] lint

---
 src/components/status/status.js            | 2 +-
 src/components/status/status.vue           | 4 ++--
 src/components/status_body/status_body.vue | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/components/status/status.js b/src/components/status/status.js
index 9b1c1b9c..54472525 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -36,7 +36,7 @@ import {
   faEyeSlash,
   faEye,
   faThumbtack,
-  faAt,
+  faAt
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index d62c6817..f27fbaa5 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -262,8 +262,8 @@
                   class="mention-link"
                   :content="replyToName"
                   :url="replyProfileLink"
-                  :userId="status.in_reply_to_user_id"
-                  :userScreenName="status.in_reply_to_screen_name"
+                  :user-id="status.in_reply_to_user_id"
+                  :user-screen-name="status.in_reply_to_screen_name"
                   :first-mention="false"
                 />
                 <span
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index fadbeaf3..421a3820 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -46,7 +46,7 @@
             v-if="!mentionsOwnLine"
             :attentions="status.attentions"
             class="mentions-line"
-            />
+          />
           <RichContent
             :class="{ '-single-line': singleLine }"
             class="text media-body"

From 566964992a394f1d0462557f70e284f2493d82bf Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 8 Jun 2021 19:37:18 +0300
Subject: [PATCH 041/169] fix long posts having weird gradient

---
 src/components/status_body/status_body.scss | 2 +-
 src/components/status_body/status_body.vue  | 1 +
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
index b65e3b27..10b4c186 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -62,7 +62,7 @@
       overflow-y: hidden;
       z-index: 1;
 
-      .text {
+      .text-wrapper {
         min-height: 0;
         mask:
           linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 421a3820..fae2d594 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -40,6 +40,7 @@
           {{ $t("general.show_more") }}
         </button>
         <span
+          class="text-wrapper"
           v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
         >
           <MentionsLine

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 042/169] 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

From c6c478f4cf123d9510e1e67a852cad0398d08052 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 12:15:14 +0300
Subject: [PATCH 043/169] moved mentions onto reply line, replies moved below
 post body

---
 src/components/status/status.vue | 79 +++++++++++++++-----------------
 1 file changed, 37 insertions(+), 42 deletions(-)

diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 05d22232..62ad0eac 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -267,58 +267,34 @@
                   :first-mention="false"
                 />
                 <span
-                  v-if="replies && replies.length"
+                  v-if="isReply && hasMentions"
                   class="faint replies-separator"
                 >
                   -
                 </span>
-              </div>
-              <div
-                v-if="inConversation && !isPreview && replies && replies.length"
-                class="replies"
-              >
-                <span class="faint">{{ $t('status.replies_list') }}</span>
-                <StatusPopover
-                  v-for="reply in replies"
-                  :key="reply.id"
-                  :status-id="reply.id"
-                >
-                  <button
-                    class="button-unstyled -link reply-link"
-                    @click.prevent="gotoOriginal(reply.id)"
-                  >
-                    {{ reply.name }}
-                  </button>
-                </StatusPopover>
-              </div>
-            </div>
-
-            <div
-              v-if="hasMentionsLine"
-              class="heading-mentions-row"
-            >
-              <div
-                class="mentions"
-              >
                 <span
-                  class="button-unstyled reply-to"
-                  :aria-label="$t('tool_tip.reply')"
-                  @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+                  v-if="hasMentionsLine"
                 >
-                  <FAIcon
-                    class="fa-scale-110 fa-old-padding"
-                    icon="at"
-                  />
                   <span
-                    class="faint-link reply-to-text"
+                    class="button-unstyled reply-to"
+                    :aria-label="$t('tool_tip.reply')"
+                    @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
                   >
-                    {{ $t('status.mentions') }}
+                    <FAIcon
+                      class="fa-scale-110 fa-old-padding"
+                      icon="at"
+                      />
+                    <span
+                      class="faint-link reply-to-text"
+                      >
+                      {{ $t('status.mentions') }}
+                    </span>
                   </span>
+                  <MentionsLine
+                    :mentions="mentionsLine"
+                    class="mentions-line"
+                  />
                 </span>
-                <MentionsLine
-                  :mentions="mentionsLine"
-                  class="mentions-line"
-                />
               </div>
             </div>
           </div>
@@ -332,6 +308,25 @@
             @mediapause="removeMediaPlaying($event)"
           />
 
+          <div
+            v-if="inConversation && !isPreview && replies && replies.length"
+            class="replies"
+            >
+            <span class="faint">{{ $t('status.replies_list') }}</span>
+            <StatusPopover
+              v-for="reply in replies"
+              :key="reply.id"
+              :status-id="reply.id"
+              >
+              <button
+                class="button-unstyled -link reply-link"
+                @click.prevent="gotoOriginal(reply.id)"
+                >
+                {{ reply.name }}
+              </button>
+            </StatusPopover>
+          </div>
+
           <transition name="fade">
             <div
               v-if="!hidePostStats && isFocused && combinedFavsAndRepeatsUsers.length > 0"

From 394fd462dc48654ba7743bb9c2ac378f98f75bd4 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 13:01:00 +0300
Subject: [PATCH 044/169] proper cachin of headTailLinks, show mentions in
 notificaitons always

---
 src/components/status/status.js                  |  3 ++-
 src/components/status/status.scss                |  8 +++++---
 src/components/status/status.vue                 | 10 ++++++----
 src/components/status_body/status_body.js        | 15 ++++++---------
 src/components/status_body/status_body.vue       |  2 +-
 src/components/status_content/status_content.js  |  4 +++-
 src/components/status_content/status_content.vue |  2 ++
 7 files changed, 25 insertions(+), 19 deletions(-)

diff --git a/src/components/status/status.js b/src/components/status/status.js
index 0498f28a..e9a5ec0d 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -142,7 +142,8 @@ const Status = {
     replyProfileLink () {
       if (this.isReply) {
         const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
-        return user && user.statusnet_profile_url
+        // FIXME Why user not found sometimes???
+        return user ? user.statusnet_profile_url : 'NOT_FOUND'
       }
     },
     retweet () { return !!this.statusoid.retweeted_status },
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 8744fa16..8f7fb71a 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -217,21 +217,23 @@ $status-margin: 0.75em;
     }
   }
 
-  .reply-to {
+  & .mentions,
+  & .reply-to {
     position: relative;
   }
 
-  .reply-to-text {
+  & .reply-to-text {
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
   }
 
-  .replies-separator {
+  .mentions-separator {
     margin-left: 0.4em;
   }
 
   .replies {
+    margin-top: 0.25em;
     line-height: 18px;
     font-size: 12px;
     display: flex;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 62ad0eac..e4c3a755 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -267,8 +267,8 @@
                   :first-mention="false"
                 />
                 <span
-                  v-if="isReply && hasMentions"
-                  class="faint replies-separator"
+                  v-if="isReply && hasMentionsLine"
+                  class="faint mentions-separator"
                 >
                   -
                 </span>
@@ -276,7 +276,7 @@
                   v-if="hasMentionsLine"
                 >
                   <span
-                    class="button-unstyled reply-to"
+                    class="button-unstyled mentions"
                     :aria-label="$t('tool_tip.reply')"
                     @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
                   >
@@ -285,7 +285,7 @@
                       icon="at"
                       />
                     <span
-                      class="faint-link reply-to-text"
+                      class="faint-link mentions-text"
                       >
                       {{ $t('status.mentions') }}
                     </span>
@@ -304,6 +304,8 @@
             :no-heading="noHeading"
             :highlight="highlight"
             :focused="isFocused"
+            :hide-first-mentions="mentionsOwnLine"
+            :head-tail-links="headTailLinks"
             @mediaplay="addMediaPlaying($event)"
             @mediapause="removeMediaPlaying($event)"
           />
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index dbabd208..c2edb601 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -29,14 +29,18 @@ const StatusContent = {
     'singleLine',
     // if this was computed at upper level it can be passed here, otherwise
     // it will be in this component
-    'headTailLinks'
+    'headTailLinks',
+    'hideFirstMentions'
   ],
   data () {
     return {
       showingTall: this.fullContent || (this.inConversation && this.focused),
       showingLongSubject: false,
       // not as computed because it sets the initial state which will be changed later
-      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
+      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
+      headTailLinksComputed: this.headTailLinks
+        ? this.headTailLinks
+        : getHeadTailLinks(this.status.raw_html)
     }
   },
   computed: {
@@ -76,13 +80,6 @@ const StatusContent = {
     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.headTailLinksComputed.firstMentions
     },
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index ff919211..3c291cf6 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -52,7 +52,7 @@
           >
             <template v-slot:prefix>
               <MentionsLine
-                v-if="!mentionsOwnLine"
+                v-if="!hideFirstMentions"
                 :mentions="mentions"
                 class="mentions-line"
               />
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 1b80ee09..363a9cb0 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -31,7 +31,9 @@ const StatusContent = {
     'focused',
     'noHeading',
     'fullContent',
-    'singleLine'
+    'singleLine',
+    'hideFirstMentions',
+    'headTailLinks'
   ],
   computed: {
     hideAttachments () {
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 0283ce2d..18f6e7be 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -4,6 +4,8 @@
     <StatusBody
       :status="status"
       :single-line="singleLine"
+      :hide-first-mentions="hideFirstMentions"
+      :headTailLinks="headTailLinks"
     >
       <div v-if="status.poll && status.poll.options">
         <poll :base-poll="status.poll" />

From 0260693f5162ea8a73d2b359940781049a1b3e76 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 13:22:36 +0300
Subject: [PATCH 045/169] stylistic improvements for single-line mentions

---
 src/components/mention_link/mention_link.scss |   2 +
 src/components/status/status.scss             |  22 +---
 src/components/status/status.vue              | 122 ++++++++----------
 src/components/status_body/status_body.vue    |   1 -
 4 files changed, 62 insertions(+), 85 deletions(-)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index eed4d5be..a18475a1 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -32,6 +32,8 @@
 
   & .short,
   & .full {
+    white-space: nowrap;
+
     &::before {
       content: '@';
     }
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 8f7fb71a..60dd8237 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -155,32 +155,17 @@ $status-margin: 0.75em;
     margin-right: 0.2em;
   }
 
-  & .heading-mentions-row,
   & .heading-reply-row {
     position: relative;
     align-content: baseline;
     font-size: 12px;
-    line-height: 18px;
+    line-height: 160%;
     max-width: 100%;
     display: flex;
     flex-wrap: wrap;
     align-items: stretch;
   }
 
-  .reply-to-and-accountname {
-    display: flex;
-    height: 18px;
-    margin-right: 0.5em;
-    max-width: 100%;
-
-    .reply-to-link {
-      white-space: nowrap;
-      word-break: break-word;
-      text-overflow: ellipsis;
-      overflow-x: hidden;
-    }
-  }
-
   & .reply-to-popover,
   & .reply-to-no-popover {
     min-width: 0;
@@ -219,6 +204,7 @@ $status-margin: 0.75em;
 
   & .mentions,
   & .reply-to {
+    white-space: nowrap;
     position: relative;
   }
 
@@ -228,8 +214,8 @@ $status-margin: 0.75em;
     white-space: nowrap;
   }
 
-  .mentions-separator {
-    margin-left: 0.4em;
+  .mentions-line {
+    display: inline-block;
   }
 
   .replies {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index e4c3a755..9e08ce85 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -221,81 +221,71 @@
                 </button>
               </span>
             </div>
-            <div class="heading-reply-row">
-              <div
-                v-if="isReply"
-                class="reply-to-and-accountname"
+            <div
+              class="heading-reply-row"
+              v-if="isReply || hasMentionsLine"
               >
-                <StatusPopover
-                  v-if="!isPreview"
-                  :status-id="status.parent_visible && status.in_reply_to_status_id"
-                  class="reply-to-popover"
-                  style="min-width: 0"
-                  :class="{ '-strikethrough': !status.parent_visible }"
+              <StatusPopover
+                v-if="isReply && !isPreview"
+                :status-id="status.parent_visible && status.in_reply_to_status_id"
+                class="reply-to-popover"
+                style="min-width: 0"
+                :class="{ '-strikethrough': !status.parent_visible }"
+              >
+                <button
+                  class="button-unstyled reply-to"
+                  :aria-label="$t('tool_tip.reply')"
+                  @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
                 >
-                  <button
-                    class="button-unstyled reply-to"
-                    :aria-label="$t('tool_tip.reply')"
-                    @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+                  <FAIcon
+                    class="fa-scale-110 fa-old-padding"
+                    icon="reply"
+                    flip="horizontal"
+                  />
+                  <span
+                    class="faint-link reply-to-text"
                   >
-                    <FAIcon
-                      class="fa-scale-110 fa-old-padding"
-                      icon="reply"
-                      flip="horizontal"
-                    />
-                    <span
-                      class="faint-link reply-to-text"
-                    >
-                      {{ $t('status.reply_to') }}
-                    </span>
-                  </button>
-                </StatusPopover>
+                    {{ $t('status.reply_to') }}
+                  </span>
+                </button>
+              </StatusPopover>
 
-                <span
-                  v-else
-                  class="reply-to-no-popover"
-                >
-                  <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
-                </span>
+              <span
+                v-else-if="isReply"
+                class="reply-to-no-popover"
+              >
+                <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
+              </span>
 
-                <MentionLink
-                  class="mention-link"
-                  :content="replyToName"
-                  :url="replyProfileLink"
-                  :user-id="status.in_reply_to_user_id"
-                  :user-screen-name="status.in_reply_to_screen_name"
-                  :first-mention="false"
+              <MentionLink
+                :content="replyToName"
+                :url="replyProfileLink"
+                :user-id="status.in_reply_to_user_id"
+                :user-screen-name="status.in_reply_to_screen_name"
+                :first-mention="false"
+              />
+
+              <span
+                v-if="hasMentionsLine"
+                class="mentions"
+                :aria-label="$t('tool_tip.mentions')"
+                @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+              >
+                <FAIcon
+                  class="fa-scale-110 fa-old-padding"
+                  icon="at"
                 />
                 <span
-                  v-if="isReply && hasMentionsLine"
-                  class="faint mentions-separator"
+                  class="faint-link mentions-text"
                 >
-                  -
+                  {{ $t('status.mentions') }}
                 </span>
-                <span
-                  v-if="hasMentionsLine"
-                >
-                  <span
-                    class="button-unstyled mentions"
-                    :aria-label="$t('tool_tip.reply')"
-                    @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
-                  >
-                    <FAIcon
-                      class="fa-scale-110 fa-old-padding"
-                      icon="at"
-                      />
-                    <span
-                      class="faint-link mentions-text"
-                      >
-                      {{ $t('status.mentions') }}
-                    </span>
-                  </span>
-                  <MentionsLine
-                    :mentions="mentionsLine"
-                    class="mentions-line"
-                  />
-                </span>
-              </div>
+              </span>
+              <MentionsLine
+                v-if="hasMentionsLine"
+                :mentions="mentionsLine"
+                class="mentions-line"
+              />
             </div>
           </div>
 
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 3c291cf6..4df29934 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -54,7 +54,6 @@
               <MentionsLine
                 v-if="!hideFirstMentions"
                 :mentions="mentions"
-                class="mentions-line"
               />
             </template>
           </RichContent>

From 6bff7cc6efd1a7def75f4eef316fdcd84f0d3209 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 13:29:59 +0300
Subject: [PATCH 046/169] use icon instead of symbol for @ in mentions links

---
 src/components/mention_link/mention_link.js   |  8 +++++++
 src/components/mention_link/mention_link.scss | 21 +++++++------------
 src/components/mention_link/mention_link.vue  |  5 +++++
 3 files changed, 20 insertions(+), 14 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 559250c5..711c87d6 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -1,6 +1,14 @@
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import { mapGetters, mapState } from 'vuex'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faAt
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faAt
+)
 
 const MentionLink = {
   name: 'MentionLink',
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index a18475a1..9560e205 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -33,10 +33,6 @@
   & .short,
   & .full {
     white-space: nowrap;
-
-    &::before {
-      content: '@';
-    }
   }
 
   .new {
@@ -55,15 +51,19 @@
 
     &:not(.-oldStyle) {
       .short {
+        padding-left: 0.25em;
+        padding-right: 0;
+        padding-top: 0;
+        padding-bottom: 0;
         line-height: 1.5;
         font-size: inherit;
 
-        &::before {
+        .at {
           color: var(--faint);
+          opacity: 0.8;
           display: inline-block;
           height: 50%;
           line-height: 1;
-          vertical-align: 6%;
         }
       }
 
@@ -71,18 +71,11 @@
         padding-right: 0.25em;
       }
 
-      .short {
-        padding-left: 0.25em;
-        padding-right: 0;
-        padding-top: 0;
-        padding-bottom: 0;
-      }
-
       .userName {
         display: inline-block;
         color: var(--link);
         line-height: inherit;
-        margin-left: 0.125em;
+        margin-left: 0;
         padding-left: 0.125em;
         padding-right: 0.25em;
         padding-top: 0;
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 55f49507..281fab25 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -22,6 +22,11 @@
         :class="[{ '-sublime': !highlight }, oldStyle ? 'button-unstyled' : 'button-default']"
         @click.prevent="onClick"
       >
+        <FAIcon
+          size="s"
+          icon="at"
+          class="at"
+        />
         <!-- eslint-disable vue/no-v-html -->
         <span class="shortName"><span
           class="userName"

From 0263834faa59229e0290828798a9b8f61836c72c Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 14:01:26 +0300
Subject: [PATCH 047/169] mentions on same line as replies

---
 src/components/status/status.scss |  11 ++--
 src/components/status/status.vue  | 103 +++++++++++++++++-------------
 2 files changed, 64 insertions(+), 50 deletions(-)

diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 60dd8237..e68bc62c 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,4 +1,3 @@
-
 @import '../../_variables.scss';
 
 $status-margin: 0.75em;
@@ -151,6 +150,11 @@ $status-margin: 0.75em;
     }
   }
 
+  .glued-label {
+    display: inline-flex;
+    white-space: nowrap;
+  }
+
   .timeago {
     margin-right: 0.2em;
   }
@@ -161,8 +165,6 @@ $status-margin: 0.75em;
     font-size: 12px;
     line-height: 160%;
     max-width: 100%;
-    display: flex;
-    flex-wrap: wrap;
     align-items: stretch;
   }
 
@@ -206,6 +208,7 @@ $status-margin: 0.75em;
   & .reply-to {
     white-space: nowrap;
     position: relative;
+    padding-right: 0.25em;
   }
 
   & .reply-to-text {
@@ -215,7 +218,7 @@ $status-margin: 0.75em;
   }
 
   .mentions-line {
-    display: inline-block;
+    display: inline;
   }
 
   .replies {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 9e08ce85..98965ae0 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -225,65 +225,76 @@
               class="heading-reply-row"
               v-if="isReply || hasMentionsLine"
               >
-              <StatusPopover
-                v-if="isReply && !isPreview"
-                :status-id="status.parent_visible && status.in_reply_to_status_id"
-                class="reply-to-popover"
-                style="min-width: 0"
-                :class="{ '-strikethrough': !status.parent_visible }"
-              >
-                <button
-                  class="button-unstyled reply-to"
-                  :aria-label="$t('tool_tip.reply')"
-                  @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
-                >
-                  <FAIcon
-                    class="fa-scale-110 fa-old-padding"
-                    icon="reply"
-                    flip="horizontal"
-                  />
-                  <span
-                    class="faint-link reply-to-text"
-                  >
-                    {{ $t('status.reply_to') }}
-                  </span>
-                </button>
-              </StatusPopover>
-
               <span
-                v-else-if="isReply"
-                class="reply-to-no-popover"
+                class="glued-label"
+                v-if="isReply"
               >
-                <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
+                <StatusPopover
+                   v-if="!isPreview"
+                  :status-id="status.parent_visible && status.in_reply_to_status_id"
+                  class="reply-to-popover"
+                  style="min-width: 0"
+                  :class="{ '-strikethrough': !status.parent_visible }"
+                >
+                  <button
+                    class="button-unstyled reply-to"
+                    :aria-label="$t('tool_tip.reply')"
+                    @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+                  >
+                    <FAIcon
+                      class="fa-scale-110 fa-old-padding"
+                      icon="reply"
+                      flip="horizontal"
+                    />
+                    <span
+                      class="faint-link reply-to-text"
+                    >
+                      {{ $t('status.reply_to') }}
+                    </span>
+                  </button>
+                </StatusPopover>
+
+                <span
+                  v-else
+                  class="reply-to-no-popover"
+                >
+                  <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
+                </span>
+                <MentionLink
+                  :content="replyToName"
+                  :url="replyProfileLink"
+                  :user-id="status.in_reply_to_user_id"
+                  :user-screen-name="status.in_reply_to_screen_name"
+                  :first-mention="false"
+                />
               </span>
 
-              <MentionLink
-                :content="replyToName"
-                :url="replyProfileLink"
-                :user-id="status.in_reply_to_user_id"
-                :user-screen-name="status.in_reply_to_screen_name"
-                :first-mention="false"
-              />
-
+              <!-- This little wrapper is made for sole purpose of "gluing" -->
+              <!-- "Mentions" label to the first mention -->
               <span
                 v-if="hasMentionsLine"
-                class="mentions"
-                :aria-label="$t('tool_tip.mentions')"
-                @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
+                class="glued-label"
               >
-                <FAIcon
-                  class="fa-scale-110 fa-old-padding"
-                  icon="at"
-                />
                 <span
-                  class="faint-link mentions-text"
+                  class="mentions"
+                  :aria-label="$t('tool_tip.mentions')"
+                  @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
                 >
-                  {{ $t('status.mentions') }}
+                  <span
+                    class="faint-link mentions-text"
+                  >
+                    {{ $t('status.mentions') }}
+                  </span>
                 </span>
+                <MentionsLine
+                  v-if="hasMentionsLine"
+                  :mentions="mentionsLine.slice(0, 1)"
+                  class="mentions-line-first"
+                />
               </span>
               <MentionsLine
                 v-if="hasMentionsLine"
-                :mentions="mentionsLine"
+                :mentions="mentionsLine.slice(1)"
                 class="mentions-line"
               />
             </div>

From 0f73e96194fb13e70be0222a7ab718d7894b62c2 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 15:11:57 +0300
Subject: [PATCH 048/169] don't hide mentions for OPs

---
 src/components/status/status.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 98965ae0..7cc25be9 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -305,7 +305,7 @@
             :no-heading="noHeading"
             :highlight="highlight"
             :focused="isFocused"
-            :hide-first-mentions="mentionsOwnLine"
+            :hide-first-mentions="mentionsOwnLine && isReply"
             :head-tail-links="headTailLinks"
             @mediaplay="addMediaPlaying($event)"
             @mediapause="removeMediaPlaying($event)"

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: [PATCH 049/169] Hellthread(tm) Certified

---
 src/components/mention_link/mention_link.js   |   3 -
 src/components/mention_link/mention_link.vue  |   1 -
 src/components/rich_content/rich_content.jsx  | 182 ++++++++++++++----
 src/components/status/status.js               |   3 +
 src/components/status/status.vue              |   1 +
 src/components/status_body/status_body.js     |   8 +-
 src/components/status_body/status_body.vue    |  12 +-
 .../status_content/status_content.js          |   1 +
 .../status_content/status_content.vue         |   3 +-
 .../html_line_converter.service.js}           |  30 +--
 .../html_tree_converter.service.js}           |  20 +-
 .../html_line_converter.spec.js}              |  68 +++++--
 .../html_tree_converter.spec.js}              |  14 +-
 13 files changed, 257 insertions(+), 89 deletions(-)
 rename src/services/{tiny_post_html_processor/tiny_post_html_processor.service.js => html_converter/html_line_converter.service.js} (72%)
 rename src/services/{mini_html_converter/mini_html_converter.service.js => html_converter/html_tree_converter.service.js} (83%)
 rename test/unit/specs/services/{tiny_post_html_processor/tiny_post_html_processor.spec.js => html_converter/html_line_converter.spec.js} (50%)
 rename test/unit/specs/services/{tiny_post_html_processor/mini_post_html_processor.spec.js => html_converter/html_tree_converter.spec.js} (91%)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 711c87d6..00b9e388 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -70,9 +70,6 @@ const MentionLink = {
     highlightClass () {
       if (this.highlight) return highlightClass(this.user)
     },
-    oldPlace () {
-      return !this.mergedConfig.mentionsOwnLine
-    },
     oldStyle () {
       return !this.mergedConfig.mentionsNewStyle
     },
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 281fab25..a65dbad3 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -1,7 +1,6 @@
 <template>
   <span
     class="MentionLink"
-    :class="{ '-oldPlace': oldPlace }"
   >
     <!-- eslint-disable vue/no-v-html -->
     <a
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>`
-        } else {
-          return string
-        }
-      })
-    } else {
-      return html
+/** 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>`
+      }
     }
-  } catch (e) {
-    console.error('Failed to process status html', e)
-    return html
-  }
+
+    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 {
+          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 flattenDeep(result).join('')
+    }
+  }).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 }
 }
diff --git a/src/components/status/status.js b/src/components/status/status.js
index e9a5ec0d..bab818fc 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -196,6 +196,9 @@ 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
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 7cc25be9..0190d864 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -306,6 +306,7 @@
             :highlight="highlight"
             :focused="isFocused"
             :hide-first-mentions="mentionsOwnLine && isReply"
+            :hide-last-mentions="hideLastMentions"
             :head-tail-links="headTailLinks"
             @mediaplay="addMediaPlaying($event)"
             @mediapause="removeMediaPlaying($event)"
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index c2edb601..2fc9abbf 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -30,7 +30,8 @@ const StatusContent = {
     // if this was computed at upper level it can be passed here, otherwise
     // it will be in this component
     'headTailLinks',
-    'hideFirstMentions'
+    'hideFirstMentions',
+    'hideLastMentions'
   ],
   data () {
     return {
@@ -80,9 +81,12 @@ const StatusContent = {
     attachmentTypes () {
       return this.status.attachments.map(file => fileType.fileType(file.mimetype))
     },
-    mentions () {
+    mentionsFirst () {
       return this.headTailLinksComputed.firstMentions
     },
+    mentionsLast () {
+      return this.headTailLinksComputed.lastMentions
+    },
     ...mapGetters(['mergedConfig'])
   },
   components: {
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 4df29934..bd599a8c 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -49,11 +49,19 @@
             :emoji="status.emojis"
             :handle-links="true"
             :greentext="mergedConfig.greentext"
+            :hide-first-mentions="hideFirstMentions"
+            :hide-last-mentions="hideLastMentions"
           >
             <template v-slot:prefix>
               <MentionsLine
-                v-if="!hideFirstMentions"
-                :mentions="mentions"
+                v-if="!hideFirstMentions && mentionsFirst"
+                :mentions="mentionsFirst"
+              />
+            </template>
+            <template v-slot:suffix>
+              <MentionsLine
+                v-if="!hideFirstMentions && mentionsLast"
+                :mentions="mentionsLast"
               />
             </template>
           </RichContent>
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 363a9cb0..64cc6d44 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -33,6 +33,7 @@ const StatusContent = {
     'fullContent',
     'singleLine',
     'hideFirstMentions',
+    'hideLastMentions',
     'headTailLinks'
   ],
   computed: {
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 18f6e7be..c32bbbfb 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -5,7 +5,8 @@
       :status="status"
       :single-line="singleLine"
       :hide-first-mentions="hideFirstMentions"
-      :headTailLinks="headTailLinks"
+      :hide-last-mentions="hideLastMentions"
+      :head-tail-links="headTailLinks"
     >
       <div v-if="status.poll && status.poll.options">
         <poll :base-poll="status.poll" />
diff --git a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js b/src/services/html_converter/html_line_converter.service.js
similarity index 72%
rename from src/services/tiny_post_html_processor/tiny_post_html_processor.service.js
rename to src/services/html_converter/html_line_converter.service.js
index de6f20ef..80482c9a 100644
--- a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js
+++ b/src/services/html_converter/html_line_converter.service.js
@@ -1,18 +1,26 @@
 /**
- * This is a tiny purpose-built HTML parser/processor. This basically detects any type of visual newline and
- * allows it to be processed, useful for greentexting, mostly
+ * This is a tiny purpose-built HTML parser/processor. This basically detects
+ * any type of visual newline and converts entire HTML into a array structure.
+ *
+ * Text nodes are represented as object with single property - text - containing
+ * the visual line. Intended usage is to process the array with .map() in which
+ * map function returns a string and resulting array can be converted back to html
+ * with a .join('').
+ *
+ * Generally this isn't very useful except for when you really need to either
+ * modify visual lines (greentext i.e. simple quoting) or do something with
+ * first/last line.
  *
  * known issue: doesn't handle CDATA so nested CDATA might not work well
  *
  * @param {Object} input - input data
- * @param {(string) => string} processor - function that will be called on every line
- * @return {string} processed html
+ * @return {(string|{ text: string })[]} processed html in form of a list.
  */
-export const processHtml = (html, processor) => {
+export const convertHtmlToLines = (html) => {
   const handledTags = new Set(['p', 'br', 'div'])
   const openCloseTags = new Set(['p', 'div'])
 
-  let buffer = '' // Current output buffer
+  let buffer = [] // Current output buffer
   const level = [] // How deep we are in tags and which tags were there
   let textBuffer = '' // Current line content
   let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag
@@ -25,27 +33,27 @@ export const processHtml = (html, processor) => {
 
   const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
     if (textBuffer.trim().length > 0) {
-      buffer += processor(textBuffer)
+      buffer.push({ text: textBuffer })
     } else {
-      buffer += textBuffer
+      buffer.push(textBuffer)
     }
     textBuffer = ''
   }
 
   const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing
     flush()
-    buffer += tag
+    buffer.push(tag)
   }
 
   const handleOpen = (tag) => { // handles opening tags
     flush()
-    buffer += tag
+    buffer.push(tag)
     level.push(tag)
   }
 
   const handleClose = (tag) => { // handles closing tags
     flush()
-    buffer += tag
+    buffer.push(tag)
     if (level[level.length - 1] === tag) {
       level.pop()
     }
diff --git a/src/services/mini_html_converter/mini_html_converter.service.js b/src/services/html_converter/html_tree_converter.service.js
similarity index 83%
rename from src/services/mini_html_converter/mini_html_converter.service.js
rename to src/services/html_converter/html_tree_converter.service.js
index 900752cd..badd473a 100644
--- a/src/services/mini_html_converter/mini_html_converter.service.js
+++ b/src/services/html_converter/html_tree_converter.service.js
@@ -1,15 +1,23 @@
 /**
- * This is a not-so-tiny purpose-built HTML parser/processor. It was made for use
- * with StatusBody component for purpose of replacing tags with vue components
+ * This is a not-so-tiny purpose-built HTML parser/processor. This parses html
+ * and converts it into a tree structure representing tag openers/closers and
+ * children.
  *
- * known issue: doesn't handle CDATA so nested CDATA might not work well
+ * Structure follows this pattern: [opener, [...children], closer] except root
+ * node which is just [...children]. Text nodes can only be within children and
+ * are represented as strings.
+ *
+ * Intended use is to convert HTML structure and then recursively iterate over it
+ * most likely using a map. Very useful for dynamically rendering html replacing
+ * tags with JSX elements in a render function.
+ *
+ * known issue: doesn't handle CDATA so CDATA might not work well
+ * known issue: doesn't handle HTML comments
  *
  * @param {Object} input - input data
- * @param {(string) => string} lineProcessor - function that will be called on every line
- * @param {{ key[string]: (string) => string}} tagProcessor - map of processors for tags
  * @return {string} processed html
  */
-export const convertHtml = (html) => {
+export const convertHtmlToTree = (html) => {
   // Elements that are implicitly self-closing
   // https://developer.mozilla.org/en-US/docs/Glossary/empty_element
   const emptyElements = new Set([
diff --git a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js
similarity index 50%
rename from test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js
rename to test/unit/specs/services/html_converter/html_line_converter.spec.js
index f301429d..82cb4170 100644
--- a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js
+++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js
@@ -1,46 +1,64 @@
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
+import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
+
+const mapOnlyText = (processor) => (input) => input.text ? processor(input.text) : input
 
 describe('TinyPostHTMLProcessor', () => {
   describe('with processor that keeps original line should not make any changes to HTML when', () => {
     const processorKeep = (line) => line
     it('fed with regular HTML with newlines', () => {
       const inputOutput = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
     })
 
     it('fed with possibly broken HTML with invalid tags/composition', () => {
       const inputOutput = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
     })
 
     it('fed with very broken HTML with broken composition', () => {
       const inputOutput = '</p> lmao what </div> whats going on <div> wha <p>'
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
     })
 
     it('fed with sorta valid HTML but tags aren\'t closed', () => {
       const inputOutput = 'just leaving a <div> hanging'
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
     })
 
     it('fed with not really HTML at this point... tags that aren\'t finished', () => {
       const inputOutput = 'do you expect me to finish this <div class='
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
     })
 
     it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
       const inputOutput = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
     })
 
     it('fed with maybe valid HTML? self-closing divs and ps', () => {
       const inputOutput = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
     })
 
     it('fed with valid XHTML containing a CDATA', () => {
       const inputOutput = 'Yes, it is me, <![CDATA[DIO]]>'
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
     })
   })
   describe('with processor that replaces lines with word "_" should match expected line when', () => {
@@ -48,49 +66,65 @@ describe('TinyPostHTMLProcessor', () => {
     it('fed with regular HTML with newlines', () => {
       const input = '1<br/>2<p class="lol">3 4</p> 5 \n 6 <p > 7 <br> 8 </p> <br>\n<br/>'
       const output = '_<br/>_<p class="lol">_</p>_\n_<p >_<br>_</p> <br>\n<br/>'
-      expect(processHtml(input, processorReplace)).to.eql(output)
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
     })
 
     it('fed with possibly broken HTML with invalid tags/composition', () => {
       const input = '<feeee dwdwddddddw> <i>ayy<b>lm</i>ao</b> </section>'
       const output = '_'
-      expect(processHtml(input, processorReplace)).to.eql(output)
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
     })
 
     it('fed with very broken HTML with broken composition', () => {
       const input = '</p> lmao what </div> whats going on <div> wha <p>'
       const output = '</p>_</div>_<div>_<p>'
-      expect(processHtml(input, processorReplace)).to.eql(output)
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
     })
 
     it('fed with sorta valid HTML but tags aren\'t closed', () => {
       const input = 'just leaving a <div> hanging'
       const output = '_<div>_'
-      expect(processHtml(input, processorReplace)).to.eql(output)
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
     })
 
     it('fed with not really HTML at this point... tags that aren\'t finished', () => {
       const input = 'do you expect me to finish this <div class='
       const output = '_'
-      expect(processHtml(input, processorReplace)).to.eql(output)
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
     })
 
     it('fed with dubiously valid HTML (p within p and also div inside p)', () => {
       const input = 'look ma <p> p \nwithin <p> p! </p> and a <br/><div>div!</div></p>'
       const output = '_<p>_\n_<p>_</p>_<br/><div>_</div></p>'
-      expect(processHtml(input, processorReplace)).to.eql(output)
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
     })
 
     it('fed with maybe valid HTML? self-closing divs and ps', () => {
       const input = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
       const output = '_<div class="what"/>_<p aria-label="wtf"/>_'
-      expect(processHtml(input, processorReplace)).to.eql(output)
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
     })
 
     it('fed with valid XHTML containing a CDATA', () => {
       const input = 'Yes, it is me, <![CDATA[DIO]]>'
       const output = '_'
-      expect(processHtml(input, processorReplace)).to.eql(output)
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
     })
   })
 })
diff --git a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js b/test/unit/specs/services/html_converter/html_tree_converter.spec.js
similarity index 91%
rename from test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
rename to test/unit/specs/services/html_converter/html_tree_converter.spec.js
index 8df2fbc3..a54745c3 100644
--- a/test/unit/specs/services/tiny_post_html_processor/mini_post_html_processor.spec.js
+++ b/test/unit/specs/services/html_converter/html_tree_converter.spec.js
@@ -1,10 +1,10 @@
-import { convertHtml, processTextForEmoji, getAttrs } from 'src/services/mini_html_converter/mini_html_converter.service.js'
+import { convertHtmlToTree, processTextForEmoji, getAttrs } from 'src/services/html_converter/html_tree_converter.service.js'
 
 describe('MiniHtmlConverter', () => {
-  describe('convertHtml', () => {
+  describe('convertHtmlToTree', () => {
     it('converts html into a tree structure', () => {
       const input = '1 <p>2</p> <b>3<img src="a">4</b>5'
-      expect(convertHtml(input)).to.eql([
+      expect(convertHtmlToTree(input)).to.eql([
         '1 ',
         [
           '<p>',
@@ -26,7 +26,7 @@ describe('MiniHtmlConverter', () => {
     })
     it('converts html to tree while preserving tag formatting', () => {
       const input = '1 <p >2</p><b >3<img   src="a">4</b>5'
-      expect(convertHtml(input)).to.eql([
+      expect(convertHtmlToTree(input)).to.eql([
         '1 ',
         [
           '<p >',
@@ -47,7 +47,7 @@ describe('MiniHtmlConverter', () => {
     })
     it('converts semi-broken html', () => {
       const input = '1 <br> 2 <p> 42'
-      expect(convertHtml(input)).to.eql([
+      expect(convertHtmlToTree(input)).to.eql([
         '1 ',
         ['<br>'],
         ' 2 ',
@@ -59,7 +59,7 @@ describe('MiniHtmlConverter', () => {
     })
     it('realistic case 1', () => {
       const input = '<p><span class="h-card"><a class="u-url mention" data-user="9wRC6T2ZZiKWJ0vUi8" href="https://cawfee.club/users/benis" rel="ugc">@<span>benis</span></a></span> <span class="h-card"><a class="u-url mention" data-user="194" href="https://shigusegubu.club/users/hj" rel="ugc">@<span>hj</span></a></span> nice</p>'
-      expect(convertHtml(input)).to.eql([
+      expect(convertHtmlToTree(input)).to.eql([
         [
           '<p>',
           [
@@ -112,7 +112,7 @@ describe('MiniHtmlConverter', () => {
     })
     it('realistic case 2', () => {
       const inputOutput = 'Country improv: give me a city<br/>Audience: Memphis<br/>Improv troupe: come on, a better one<br/>Audience: el paso'
-      expect(convertHtml(inputOutput)).to.eql([
+      expect(convertHtmlToTree(inputOutput)).to.eql([
         'Country improv: give me a city',
         [
           '<br/>'

From b84aeff6bf288b6e5855c2be0fd78577a0f7c0e5 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 10 Jun 2021 18:52:23 +0300
Subject: [PATCH 050/169] stylistic changes

---
 src/components/mention_link/mention_link.scss | 16 +++++++++++++---
 src/components/mention_link/mention_link.vue  |  5 ++---
 2 files changed, 15 insertions(+), 6 deletions(-)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 9560e205..9df1ccfe 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -49,6 +49,17 @@
       }
     }
 
+    .at {
+      color: var(--link);
+      opacity: 0.8;
+      display: inline-block;
+      height: 50%;
+      line-height: 1;
+      padding: 0 0.1em;
+      vertical-align: -25%;
+      margin: 0;
+    }
+
     &:not(.-oldStyle) {
       .short {
         padding-left: 0.25em;
@@ -61,9 +72,8 @@
         .at {
           color: var(--faint);
           opacity: 0.8;
-          display: inline-block;
-          height: 50%;
-          line-height: 1;
+          padding-right: 0.25em;
+          vertical-align: -20%;
         }
       }
 
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index a65dbad3..062ad4bc 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -21,13 +21,12 @@
         :class="[{ '-sublime': !highlight }, oldStyle ? 'button-unstyled' : 'button-default']"
         @click.prevent="onClick"
       >
+        <!-- eslint-disable vue/no-v-html -->
         <FAIcon
           size="s"
           icon="at"
           class="at"
-        />
-        <!-- eslint-disable vue/no-v-html -->
-        <span class="shortName"><span
+        /><span class="shortName"><span
           class="userName"
           v-html="userName"
         /></span>

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 051/169] 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" />

From 255f47fe56a1f9db2ca669aff1d9bd9cdde5dd8b Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 11 Jun 2021 11:05:28 +0300
Subject: [PATCH 052/169] fix infinite loop

---
 src/components/rich_content/rich_content.jsx | 15 ++++++------
 src/components/status_body/status_body.vue   | 25 +++++++-------------
 2 files changed, 17 insertions(+), 23 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 8972c494..f7fa4dce 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -42,7 +42,7 @@ export default Vue.component('RichContent', {
   // NEVER EVER TOUCH DATA INSIDE RENDER
   render (h) {
     // Pre-process HTML
-    const { newHtml: html, lastMentions } = preProcessPerLine(this.html, this.greentext, this.hideLastMentions)
+    const { newHtml: html, lastMentions } = preProcessPerLine(this.html, this.greentext, this.hideMentions)
     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
@@ -187,6 +187,13 @@ export default Vue.component('RichContent', {
       return item
     }
 
+    // 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">
+      { convertHtmlToTree(html).map(processItem).reverse().map(processItemReverse).reverse() }
+    </span>
+
     const event = {
       firstMentions,
       lastMentions,
@@ -195,12 +202,6 @@ export default Vue.component('RichContent', {
       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)
 
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 68f6701f..799e812d 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -38,10 +38,11 @@
         >
           {{ $t("general.show_more") }}
         </button>
-        <span
-          class="text-wrapper"
-          v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
-        >
+        <span v-if="!hideSubjectStatus && !(singleLine && status.summary_html)">
+          <MentionsLine
+            v-if="!hideMentions && firstMentions && firstMentions.length > 0"
+            :mentions="firstMentions"
+          />
           <RichContent
             :class="{ '-single-line': singleLine }"
             class="text media-body"
@@ -53,19 +54,11 @@
             @parseReady="setHeadTailLinks"
             ref="text"
           >
-            <template v-slot:prefix>
-              <MentionsLine
-                v-if="!hideMentions && firstMentions && firstMentions.length > 0"
-                :mentions="firstMentions"
-              />
-            </template>
-            <template v-slot:suffix>
-              <MentionsLine
-                v-if="!hideMentions && lastMentions.length > 0 && firstMentions.length === 0"
-                :mentions="lastMentions"
-              />
-            </template>
           </RichContent>
+          <MentionsLine
+            v-if="!hideMentions && lastMentions.length > 0 && firstMentions.length === 0"
+            :mentions="lastMentions"
+          />
         </span>
 
         <button

From f819227bed8ab34e775ce5c0501457bf94c45fed Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 11 Jun 2021 11:49:32 +0300
Subject: [PATCH 053/169] fixed console errors, improved user-selecting, added
 cyantexting

---
 src/components/mention_link/mention_link.scss | 5 +++++
 src/components/mention_link/mention_link.vue  | 4 ++--
 src/components/status_body/status_body.scss   | 4 ++++
 src/services/theme_data/pleromafe.js          | 6 ++++++
 4 files changed, 17 insertions(+), 2 deletions(-)

diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 1be3e7c5..5f5da98f 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -28,6 +28,11 @@
     z-index: 1;
     margin-top: 0.25em;
     padding: 0.5em;
+    user-select: all;
+  }
+
+  .short {
+    user-select: none;
   }
 
   & .short,
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 062ad4bc..3449f4bd 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -23,7 +23,7 @@
       >
         <!-- eslint-disable vue/no-v-html -->
         <FAIcon
-          size="s"
+          size="sm"
           icon="at"
           class="at"
         /><span class="shortName"><span
@@ -44,7 +44,7 @@
         <!-- eslint-disable vue/no-v-html -->
         <span
           class="userNameFull"
-          v-html="userNameFull"
+          v-text="'@' + userNameFull"
         />
         <!-- eslint-enable vue/no-v-html -->
       </span>
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
index 10b4c186..310185ae 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -112,6 +112,10 @@
     color: var(--postGreentext, $fallback--cGreen);
   }
 
+  .cyantext {
+    color: var(--postCyantext, $fallback--cBlue);
+  }
+
   /* Not sure if this is necessary */
   video {
     max-width: 100%;
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
index 14aac975..c2983be7 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -369,6 +369,12 @@ export const SLOT_INHERITANCE = {
     textColor: 'preserve'
   },
 
+  postCyantext: {
+    depends: ['cBlue'],
+    layer: 'bg',
+    textColor: 'preserve'
+  },
+
   border: {
     depends: ['fg'],
     opacity: 'border',

From 5834790d0b38d487e314e8419509d162abbd6a80 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 11 Jun 2021 11:50:05 +0300
Subject: [PATCH 054/169] fix #935

---
 .../html_line_converter.service.js            | 13 +++++-----
 .../html_line_converter.spec.js               | 25 +++++++++++++++++++
 2 files changed, 32 insertions(+), 6 deletions(-)

diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js
index 80482c9a..d8f5ecb8 100644
--- a/src/services/html_converter/html_line_converter.service.js
+++ b/src/services/html_converter/html_line_converter.service.js
@@ -17,8 +17,9 @@
  * @return {(string|{ text: string })[]} processed html in form of a list.
  */
 export const convertHtmlToLines = (html) => {
-  const handledTags = new Set(['p', 'br', 'div'])
-  const openCloseTags = new Set(['p', 'div'])
+  const ignoredTags = new Set(['code', 'blockquote'])
+  const handledTags = new Set(['p', 'br', 'div', 'pre', 'code', 'blockquote'])
+  const openCloseTags = new Set(['p', 'div', 'pre', 'code', 'blockquote'])
 
   let buffer = [] // Current output buffer
   const level = [] // How deep we are in tags and which tags were there
@@ -32,7 +33,7 @@ export const convertHtmlToLines = (html) => {
   }
 
   const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
-    if (textBuffer.trim().length > 0) {
+    if (textBuffer.trim().length > 0 && !level.some(l => ignoredTags.has(l))) {
       buffer.push({ text: textBuffer })
     } else {
       buffer.push(textBuffer)
@@ -48,14 +49,14 @@ export const convertHtmlToLines = (html) => {
   const handleOpen = (tag) => { // handles opening tags
     flush()
     buffer.push(tag)
-    level.push(tag)
+    level.unshift(getTagName(tag))
   }
 
   const handleClose = (tag) => { // handles closing tags
     flush()
     buffer.push(tag)
-    if (level[level.length - 1] === tag) {
-      level.pop()
+    if (level[0] === getTagName(tag)) {
+      level.shift()
     }
   }
 
diff --git a/test/unit/specs/services/html_converter/html_line_converter.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js
index 82cb4170..532ea187 100644
--- a/test/unit/specs/services/html_converter/html_line_converter.spec.js
+++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js
@@ -126,5 +126,30 @@ describe('TinyPostHTMLProcessor', () => {
       const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
       expect(comparableResult).to.eql(output)
     })
+
+    it('Testing handling ignored blocks', () => {
+      const input = `
+      <pre><code>&gt; rei = &quot;0&quot;
+      &#39;0&#39;
+      &gt; rei == 0
+      true
+      &gt; rei == null
+      false</code></pre><blockquote>That, christian-like JS diagram but it’s evangelion instead.</blockquote>
+      `
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(input)
+    })
+    it('Testing handling ignored blocks 2', () => {
+      const input = `
+      <blockquote>An SSL error has happened.</blockquote><p>Shakespeare</p>
+      `
+      const output = `
+      <blockquote>An SSL error has happened.</blockquote><p>_</p>
+      `
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
+    })
   })
 })

From 9421501c1e871129b11c42a672b12b70b0db4c4a Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 11 Jun 2021 11:52:50 +0300
Subject: [PATCH 055/169] lint & cleanup

---
 src/components/rich_content/rich_content.jsx |  4 ----
 src/components/status/status.vue             | 16 ++++++++--------
 src/components/status_body/status_body.vue   |  4 +---
 3 files changed, 9 insertions(+), 15 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index f7fa4dce..78af0d9e 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -320,7 +320,3 @@ export const preProcessPerLine = (html, greentext) => {
 
   return { newHtml, lastMentions }
 }
-
-export const getHeadTailLinks = (html) => {
-  // Exported object properties
-}
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 507e4192..be6458ae 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -222,15 +222,15 @@
               </span>
             </div>
             <div
-              class="heading-reply-row"
               v-if="isReply || hasMentionsLine"
-              >
+              class="heading-reply-row"
+            >
               <span
-                class="glued-label"
                 v-if="isReply"
+                class="glued-label"
               >
                 <StatusPopover
-                   v-if="!isPreview"
+                  v-if="!isPreview"
                   :status-id="status.parent_visible && status.in_reply_to_status_id"
                   class="reply-to-popover"
                   style="min-width: 0"
@@ -301,6 +301,7 @@
           </div>
 
           <StatusContent
+            ref="content"
             :status="status"
             :no-heading="noHeading"
             :highlight="highlight"
@@ -309,23 +310,22 @@
             @mediaplay="addMediaPlaying($event)"
             @mediapause="removeMediaPlaying($event)"
             @parseReady="setHeadTailLinks"
-            ref="content"
           />
 
           <div
             v-if="inConversation && !isPreview && replies && replies.length"
             class="replies"
-            >
+          >
             <span class="faint">{{ $t('status.replies_list') }}</span>
             <StatusPopover
               v-for="reply in replies"
               :key="reply.id"
               :status-id="reply.id"
-              >
+            >
               <button
                 class="button-unstyled -link reply-link"
                 @click.prevent="gotoOriginal(reply.id)"
-                >
+              >
                 {{ reply.name }}
               </button>
             </StatusPopover>
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 799e812d..45d899fb 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -52,9 +52,7 @@
             :hide-mentions="hideMentions"
             :greentext="mergedConfig.greentext"
             @parseReady="setHeadTailLinks"
-            ref="text"
-          >
-          </RichContent>
+          />
           <MentionsLine
             v-if="!hideMentions && lastMentions.length > 0 && firstMentions.length === 0"
             :mentions="lastMentions"

From ffc501eb236a7ec088c058d24396a2dccc8f3a8b Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 11 Jun 2021 13:38:08 +0300
Subject: [PATCH 056/169] cleanup

---
 src/components/status_body/status_body.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 7433619b..14558aca 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -1,5 +1,5 @@
 import fileType from 'src/services/file_type/file_type.service'
-import RichContent, { getHeadTailLinks } from 'src/components/rich_content/rich_content.jsx'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
 import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'

From c1bd36dc6f5700eb13c4b86fcd353a07a7c2fc08 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 16:15:22 +0300
Subject: [PATCH 057/169] change how "first" line is determined. Allow one
 mention in the beginning for hellthread style

---
 src/components/rich_content/rich_content.jsx | 5 +++--
 src/components/status_body/status_body.vue   | 2 +-
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 78af0d9e..a489689b 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -126,7 +126,7 @@ export default Vue.component('RichContent', {
         switch (Tag) {
           case 'span': // replace images with StillImage
             if (attrs['class'] && attrs['class'].includes('lastMentions')) {
-              if (firstMentions.length > 0) {
+              if (firstMentions.length > 1) {
                 break
               } else {
                 return ''
@@ -231,6 +231,7 @@ const getLinkData = (attrs, children, index) => {
 export const preProcessPerLine = (html, greentext) => {
   const lastMentions = []
 
+  let nonEmptyIndex = 0
   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
@@ -295,7 +296,7 @@ export const preProcessPerLine = (html, greentext) => {
     const result = [...tree].map(process)
 
     // Only check last (first since list is reversed) line
-    if (hasMentions && !hasLooseText && index === 0) {
+    if (hasMentions && !hasLooseText && nonEmptyIndex++ === 0) {
       let mentionIndex = 0
       const process = (item) => {
         if (Array.isArray(item)) {
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 45d899fb..7e699a45 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -54,7 +54,7 @@
             @parseReady="setHeadTailLinks"
           />
           <MentionsLine
-            v-if="!hideMentions && lastMentions.length > 0 && firstMentions.length === 0"
+            v-if="!hideMentions && lastMentions.length > 0 && firstMentions.length <= 1"
             :mentions="lastMentions"
           />
         </span>

From 647e4476f90be087dda099c588f2f8acc089c1ee Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 16:25:37 +0300
Subject: [PATCH 058/169] fix long post fader

---
 src/components/status_body/status_body.scss | 3 +--
 src/components/status_body/status_body.vue  | 5 ++++-
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
index 310185ae..da5d4dd3 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -62,7 +62,7 @@
       overflow-y: hidden;
       z-index: 1;
 
-      .text-wrapper {
+      .rich-content-wrapper {
         min-height: 0;
         mask:
           linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
@@ -123,5 +123,4 @@
     vertical-align: middle;
     object-fit: contain;
   }
-
 }
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 7e699a45..b84541d7 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -38,7 +38,10 @@
         >
           {{ $t("general.show_more") }}
         </button>
-        <span v-if="!hideSubjectStatus && !(singleLine && status.summary_html)">
+        <span
+          v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
+          class="rich-content-wrapper"
+        >
           <MentionsLine
             v-if="!hideMentions && firstMentions && firstMentions.length > 0"
             :mentions="firstMentions"

From 24f3681ac157594c94e3b933aa85bc4707758746 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 17:11:49 +0300
Subject: [PATCH 059/169] fix color of reply row, fix overflow in
 status-popover

---
 src/components/status/status.scss | 4 ++++
 src/components/status/status.vue  | 4 ++--
 2 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index e68bc62c..3805aa30 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -4,6 +4,7 @@ $status-margin: 0.75em;
 
 .Status {
   min-width: 0;
+  white-space: normal;
 
   &:hover {
     --_still-image-img-visibility: visible;
@@ -166,6 +167,7 @@ $status-margin: 0.75em;
     line-height: 160%;
     max-width: 100%;
     align-items: stretch;
+    z-index: 2;
   }
 
   & .reply-to-popover,
@@ -211,7 +213,9 @@ $status-margin: 0.75em;
     padding-right: 0.25em;
   }
 
+  & .mentions-text,
   & .reply-to-text {
+    color: var(--faint);
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index be6458ae..a5f347a6 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -247,7 +247,7 @@
                       flip="horizontal"
                     />
                     <span
-                      class="faint-link reply-to-text"
+                      class="reply-to-text"
                     >
                       {{ $t('status.reply_to') }}
                     </span>
@@ -281,7 +281,7 @@
                   @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
                 >
                   <span
-                    class="faint-link mentions-text"
+                    class="mentions-text"
                   >
                     {{ $t('status.mentions') }}
                   </span>

From ca6c7d5b10e48299dcb0ee65248de14f27ed78c8 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 17:20:21 +0300
Subject: [PATCH 060/169] fix tags gluing

---
 src/components/rich_content/rich_content.jsx | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index a489689b..ad77d615 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -141,6 +141,7 @@ export default Vue.component('RichContent', {
             if (attrs['class'] && attrs['class'].includes('mention')) {
               return renderMention(attrs, children, encounteredText)
             } else if (attrs['class'] && attrs['class'].includes('hashtag')) {
+              encounteredText = true
               return item // We'll handle it later
             } else {
               attrs.target = '_blank'
@@ -167,7 +168,7 @@ export default Vue.component('RichContent', {
       // Handle text nodes - just add emoji
       if (typeof item === 'string') {
         const emptyText = item.trim() === ''
-        if (emptyText) return encounteredTextReverse ? item : item.trim()
+        if (emptyText) return item
         if (!encounteredTextReverse) encounteredTextReverse = true
         return item
       } else if (Array.isArray(item)) {

From cd4455675024a3dfc8930184114d5f92438d0466 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 19:47:23 +0300
Subject: [PATCH 061/169] restructure and tests

squash! restructure and tests
---
 src/components/rich_content/rich_content.jsx  |  17 +-
 src/components/status_body/status_body.vue    |   1 -
 .../html_line_converter.service.js            |   8 +-
 .../html_tree_converter.service.js            |  53 +--
 .../html_converter/utility.service.js         |  73 ++++
 .../specs/components/rich_content.spec.js     | 357 ++++++++++++++++++
 .../html_line_converter.spec.js               |   2 +-
 .../html_tree_converter.spec.js               |  38 +-
 .../services/html_converter/utility.spec.js   |  37 ++
 9 files changed, 481 insertions(+), 105 deletions(-)
 create mode 100644 src/services/html_converter/utility.service.js
 create mode 100644 test/unit/specs/components/rich_content.spec.js
 create mode 100644 test/unit/specs/services/html_converter/utility.spec.js

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index ad77d615..ef15aaeb 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 { convertHtmlToTree, getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/html_tree_converter.service.js'
+import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
+import { convertHtmlToTree } 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,18 +32,12 @@ export default Vue.component('RichContent', {
       required: false,
       type: Boolean,
       default: false
-    },
-    // Whether to hide last mentions (hellthreads)
-    hideMentions: {
-      required: false,
-      type: Boolean,
-      default: false
     }
   },
   // NEVER EVER TOUCH DATA INSIDE RENDER
   render (h) {
     // Pre-process HTML
-    const { newHtml: html, lastMentions } = preProcessPerLine(this.html, this.greentext, this.hideMentions)
+    const { newHtml: html, lastMentions } = preProcessPerLine(this.html, this.greentext, this.handleLinks)
     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
@@ -228,8 +223,9 @@ const getLinkData = (attrs, children, index) => {
  *
  * @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
  */
-export const preProcessPerLine = (html, greentext) => {
+export const preProcessPerLine = (html, greentext, handleLinks) => {
   const lastMentions = []
 
   let nonEmptyIndex = 0
@@ -264,6 +260,7 @@ export const preProcessPerLine = (html, greentext) => {
         const tag = getTagName(opener)
         // If we have a link we probably have mentions
         if (tag === 'a') {
+          if (!handleLinks) return [opener, children, closer]
           const attrs = getAttrs(opener)
           if (attrs['class'] && attrs['class'].includes('mention')) {
             // Got mentions
@@ -297,7 +294,7 @@ export const preProcessPerLine = (html, greentext) => {
     const result = [...tree].map(process)
 
     // Only check last (first since list is reversed) line
-    if (hasMentions && !hasLooseText && nonEmptyIndex++ === 0) {
+    if (handleLinks && hasMentions && !hasLooseText && nonEmptyIndex++ === 0) {
       let mentionIndex = 0
       const process = (item) => {
         if (Array.isArray(item)) {
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index b84541d7..aac44e42 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -52,7 +52,6 @@
             :html="status.raw_html"
             :emoji="status.emojis"
             :handle-links="true"
-            :hide-mentions="hideMentions"
             :greentext="mergedConfig.greentext"
             @parseReady="setHeadTailLinks"
           />
diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js
index d8f5ecb8..e448d5cd 100644
--- a/src/services/html_converter/html_line_converter.service.js
+++ b/src/services/html_converter/html_line_converter.service.js
@@ -1,3 +1,5 @@
+import { getTagName } from './utility.service.js'
+
 /**
  * This is a tiny purpose-built HTML parser/processor. This basically detects
  * any type of visual newline and converts entire HTML into a array structure.
@@ -26,12 +28,6 @@ export const convertHtmlToLines = (html) => {
   let textBuffer = '' // Current line content
   let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag
 
-  // Extracts tag name from tag, i.e. <span a="b"> => span
-  const getTagName = (tag) => {
-    const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag)
-    return result && (result[1] || result[2])
-  }
-
   const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
     if (textBuffer.trim().length > 0 && !level.some(l => ignoredTags.has(l))) {
       buffer.push({ text: textBuffer })
diff --git a/src/services/html_converter/html_tree_converter.service.js b/src/services/html_converter/html_tree_converter.service.js
index badd473a..804d35d7 100644
--- a/src/services/html_converter/html_tree_converter.service.js
+++ b/src/services/html_converter/html_tree_converter.service.js
@@ -1,3 +1,5 @@
+import { getTagName } from './utility.service.js'
+
 /**
  * This is a not-so-tiny purpose-built HTML parser/processor. This parses html
  * and converts it into a tree structure representing tag openers/closers and
@@ -93,54 +95,3 @@ export const convertHtmlToTree = (html) => {
   flushText()
   return buffer
 }
-
-// Extracts tag name from tag, i.e. <span a="b"> => span
-export const getTagName = (tag) => {
-  const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag)
-  return result && (result[1] || result[2])
-}
-
-export const processTextForEmoji = (text, emojis, processor) => {
-  const buffer = []
-  let textBuffer = ''
-  for (let i = 0; i < text.length; i++) {
-    const char = text[i]
-    if (char === ':') {
-      const next = text.slice(i + 1)
-      let found = false
-      for (let emoji of emojis) {
-        if (next.slice(0, emoji.shortcode.length + 1) === (emoji.shortcode + ':')) {
-          found = emoji
-          break
-        }
-      }
-      if (found) {
-        buffer.push(textBuffer)
-        textBuffer = ''
-        buffer.push(processor(found))
-        i += found.shortcode.length + 1
-      } else {
-        textBuffer += char
-      }
-    } else {
-      textBuffer += char
-    }
-  }
-  if (textBuffer) buffer.push(textBuffer)
-  return buffer
-}
-
-export const getAttrs = tag => {
-  const innertag = tag
-    .substring(1, tag.length - 1)
-    .replace(new RegExp('^' + getTagName(tag)), '')
-    .replace(/\/?$/, '')
-    .trim()
-  const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi))
-    .map(([trash, key, value]) => [key, value])
-    .map(([k, v]) => {
-      if (!v) return [k, true]
-      return [k, v.substring(1, v.length - 1)]
-    })
-  return Object.fromEntries(attrs)
-}
diff --git a/src/services/html_converter/utility.service.js b/src/services/html_converter/utility.service.js
new file mode 100644
index 00000000..4d0c36c2
--- /dev/null
+++ b/src/services/html_converter/utility.service.js
@@ -0,0 +1,73 @@
+/**
+ * Extract tag name from tag opener/closer.
+ *
+ * @param {String} tag - tag string, i.e. '<a href="...">'
+ * @return {String} - tagname, i.e. "div"
+ */
+export const getTagName = (tag) => {
+  const result = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/gi.exec(tag)
+  return result && (result[1] || result[2])
+}
+
+/**
+ * Extract attributes from tag opener.
+ *
+ * @param {String} tag - tag string, i.e. '<a href="...">'
+ * @return {Object} - map of attributes key = attribute name, value = attribute value
+ *   attributes without values represented as boolean true
+ */
+export const getAttrs = tag => {
+  const innertag = tag
+    .substring(1, tag.length - 1)
+    .replace(new RegExp('^' + getTagName(tag)), '')
+    .replace(/\/?$/, '')
+    .trim()
+  const attrs = Array.from(innertag.matchAll(/([a-z0-9-]+)(?:=("[^"]+?"|'[^']+?'))?/gi))
+    .map(([trash, key, value]) => [key, value])
+    .map(([k, v]) => {
+      if (!v) return [k, true]
+      return [k, v.substring(1, v.length - 1)]
+    })
+  return Object.fromEntries(attrs)
+}
+
+/**
+ * Finds shortcodes in text
+ *
+ * @param {String} text - original text to find emojis in
+ * @param {{ url: String, shortcode: Sring }[]} emoji - list of shortcodes to find
+ * @param {Function} processor - function to call on each encountered emoji,
+ *   function is passed single object containing matching emoji ({ url, shortcode })
+ *   return value will be inserted into resulting array instead of :shortcode:
+ * @return {Array} resulting array with non-emoji parts of text and whatever {processor}
+ *   returned for emoji
+ */
+export const processTextForEmoji = (text, emojis, processor) => {
+  const buffer = []
+  let textBuffer = ''
+  for (let i = 0; i < text.length; i++) {
+    const char = text[i]
+    if (char === ':') {
+      const next = text.slice(i + 1)
+      let found = false
+      for (let emoji of emojis) {
+        if (next.slice(0, emoji.shortcode.length + 1) === (emoji.shortcode + ':')) {
+          found = emoji
+          break
+        }
+      }
+      if (found) {
+        buffer.push(textBuffer)
+        textBuffer = ''
+        buffer.push(processor(found))
+        i += found.shortcode.length + 1
+      } else {
+        textBuffer += char
+      }
+    } else {
+      textBuffer += char
+    }
+  }
+  if (textBuffer) buffer.push(textBuffer)
+  return buffer
+}
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
new file mode 100644
index 00000000..05c0b259
--- /dev/null
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -0,0 +1,357 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+
+const localVue = createLocalVue()
+
+const makeMention = (who) => `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
+const stubMention = (who) => `<span class="h-card"><mentionlink-stub url="https://fake.tld/@${who}" content="@<span>${who}</span>"></mentionlink-stub></span>`
+const lastMentions = (...data) => `<span class="lastMentions">${data.join('')}</span>`
+const p = (...data) => `<p>${data.join('')}</p>`
+const compwrap = (...data) => `<span class="RichContent">${data.join('')}</span>`
+const removedMentionSpan = '<span class="h-card"></span>'
+
+describe('RichContent', () => {
+  it('renders simple post without exploding', () => {
+    const html = p('Hello world!')
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(html))
+  })
+
+  it('removes mentions from the beginning of post', () => {
+    const html = p(
+      makeMention('John'),
+      ' how are you doing thoday?'
+    )
+    const expected = p(
+      removedMentionSpan,
+      'how are you doing thoday?'
+    )
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('removes mentions from the end of the hellpost (<p>)', () => {
+    const html = [
+      p('How are you doing today, fine gentlemen?'),
+      p(
+        makeMention('John'),
+        makeMention('Josh'),
+        makeMention('Jeremy')
+      )
+    ].join('')
+    const expected = [
+      p(
+        'How are you doing today, fine gentlemen?'
+      ),
+      // TODO fix this extra line somehow?
+      p()
+    ].join('')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('removes mentions from the end of the hellpost (<br>)', () => {
+    const html = [
+      'How are you doing today, fine gentlemen?',
+      [
+        makeMention('John'),
+        makeMention('Josh'),
+        makeMention('Jeremy')
+      ].join('')
+    ].join('<br>')
+    const expected = [
+      'How are you doing today, fine gentlemen?',
+      // TODO fix this extra line somehow?
+      '<br>'
+    ].join('')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('removes mentions from the end of the hellpost (\\n)', () => {
+    const html = [
+      'How are you doing today, fine gentlemen?',
+      [
+        makeMention('John'),
+        makeMention('Josh'),
+        makeMention('Jeremy')
+      ].join('')
+    ].join('\n')
+    const expected = [
+      'How are you doing today, fine gentlemen?',
+      // TODO fix this extra line somehow?
+      ''
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('Does not remove mentions in the middle or at the end of text string', () => {
+    const html = [
+      [
+        makeMention('Jack'),
+        'let\'s meet up with ',
+        makeMention('Janet')
+      ].join(''),
+      [
+        'cc: ',
+        makeMention('John'),
+        makeMention('Josh'),
+        makeMention('Jeremy')
+      ].join('')
+    ].join('\n')
+    const expected = [
+      [
+        removedMentionSpan,
+        'let\'s meet up with ',
+        stubMention('Janet')
+      ].join(''),
+      [
+        'cc: ',
+        stubMention('John'),
+        stubMention('Josh'),
+        stubMention('Jeremy')
+      ].join('')
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('removes mentions from the end if there\'s only one first mention', () => {
+    const html = [
+      p(
+        makeMention('Todd'),
+        'so anyway you are wrong'
+      ),
+      p(
+        makeMention('Tom'),
+        makeMention('Trace'),
+        makeMention('Theodor')
+      )
+    ].join('')
+    const expected = [
+      p(
+        removedMentionSpan,
+        'so anyway you are wrong'
+      ),
+      // TODO fix this extra line somehow?
+      p()
+    ].join('')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('does not remove mentions from the end if there\'s more than one first mention', () => {
+    const html = [
+      p(
+        makeMention('Zacharie'),
+        makeMention('Zinaide'),
+        'you guys have cool names, and so do these guys: '
+      ),
+      p(
+        makeMention('Watson'),
+        makeMention('Wallace'),
+        makeMention('Wakamoto')
+      )
+    ].join('')
+    const expected = [
+      p(
+        removedMentionSpan,
+        removedMentionSpan,
+        'you guys have cool names, and so do these guys: '
+      ),
+      p(
+        lastMentions(
+          stubMention('Watson'),
+          stubMention('Wallace'),
+          stubMention('Wakamoto')
+        )
+      )
+    ].join('')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('Does not touch links if link handling is disabled', () => {
+    const html = [
+      [
+        makeMention('Jack'),
+        'let\'s meet up with ',
+        makeMention('Janet')
+      ].join(''),
+      [
+        makeMention('John'),
+        makeMention('Josh'),
+        makeMention('Jeremy')
+      ].join('')
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: false,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(html))
+  })
+
+  it('Adds greentext and cyantext to the post', () => {
+    const html = [
+      '&gt;preordering videogames',
+      '&gt;any year'
+    ].join('\n')
+    const expected = [
+      '<span class="greentext">&gt;preordering videogames</span>',
+      '<span class="greentext">&gt;any year</span>'
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: false,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('Does not add greentext and cyantext if setting is set to false', () => {
+    const html = [
+      '&gt;preordering videogames',
+      '&gt;any year'
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: false,
+        greentext: false,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(html))
+  })
+
+  it('Adds emoji to post', () => {
+    const html = p('Ebin :DDDD :spurdo:')
+    const expected = p(
+      'Ebin :DDDD ',
+      '<anonymous-stub alt=":spurdo:" src="about:blank" title=":spurdo:" class="emoji img"></anonymous-stub>'
+    )
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: false,
+        greentext: false,
+        emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('Doesn\'t add nonexistent emoji to post', () => {
+    const html = p('Lol :lol:')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: false,
+        greentext: false,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(html))
+  })
+})
diff --git a/test/unit/specs/services/html_converter/html_line_converter.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js
index 532ea187..9485233f 100644
--- a/test/unit/specs/services/html_converter/html_line_converter.spec.js
+++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js
@@ -2,7 +2,7 @@ import { convertHtmlToLines } from 'src/services/html_converter/html_line_conver
 
 const mapOnlyText = (processor) => (input) => input.text ? processor(input.text) : input
 
-describe('TinyPostHTMLProcessor', () => {
+describe('html_line_converter', () => {
   describe('with processor that keeps original line should not make any changes to HTML when', () => {
     const processorKeep = (line) => line
     it('fed with regular HTML with newlines', () => {
diff --git a/test/unit/specs/services/html_converter/html_tree_converter.spec.js b/test/unit/specs/services/html_converter/html_tree_converter.spec.js
index a54745c3..7283021b 100644
--- a/test/unit/specs/services/html_converter/html_tree_converter.spec.js
+++ b/test/unit/specs/services/html_converter/html_tree_converter.spec.js
@@ -1,6 +1,6 @@
-import { convertHtmlToTree, processTextForEmoji, getAttrs } from 'src/services/html_converter/html_tree_converter.service.js'
+import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
 
-describe('MiniHtmlConverter', () => {
+describe('html_tree_converter', () => {
   describe('convertHtmlToTree', () => {
     it('converts html into a tree structure', () => {
       const input = '1 <p>2</p> <b>3<img src="a">4</b>5'
@@ -129,38 +129,4 @@ describe('MiniHtmlConverter', () => {
       ])
     })
   })
-
-  describe('processTextForEmoji', () => {
-    it('processes all emoji in text', () => {
-      const input = 'Hello from finland! :lol: We have best water! :lmao:'
-      const emojis = [
-        { shortcode: 'lol', src: 'LOL' },
-        { shortcode: 'lmao', src: 'LMAO' }
-      ]
-      const processor = ({ shortcode, src }) => ({ shortcode, src })
-      expect(processTextForEmoji(input, emojis, processor)).to.eql([
-        'Hello from finland! ',
-        { shortcode: 'lol', src: 'LOL' },
-        ' We have best water! ',
-        { shortcode: 'lmao', src: 'LMAO' }
-      ])
-    })
-    it('leaves text as is', () => {
-      const input = 'Number one: that\'s terror'
-      const emojis = []
-      const processor = ({ shortcode, src }) => ({ shortcode, src })
-      expect(processTextForEmoji(input, emojis, processor)).to.eql([
-        'Number one: that\'s terror'
-      ])
-    })
-  })
-
-  describe('getAttrs', () => {
-    it('extracts arguments from tag', () => {
-      const input = '<img src="boop" cool ebin=\'true\'>'
-      const output = { src: 'boop', cool: true, ebin: 'true' }
-
-      expect(getAttrs(input)).to.eql(output)
-    })
-  })
 })
diff --git a/test/unit/specs/services/html_converter/utility.spec.js b/test/unit/specs/services/html_converter/utility.spec.js
new file mode 100644
index 00000000..cf6fd99b
--- /dev/null
+++ b/test/unit/specs/services/html_converter/utility.spec.js
@@ -0,0 +1,37 @@
+import { processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
+
+describe('html_converter utility', () => {
+  describe('processTextForEmoji', () => {
+    it('processes all emoji in text', () => {
+      const input = 'Hello from finland! :lol: We have best water! :lmao:'
+      const emojis = [
+        { shortcode: 'lol', src: 'LOL' },
+        { shortcode: 'lmao', src: 'LMAO' }
+      ]
+      const processor = ({ shortcode, src }) => ({ shortcode, src })
+      expect(processTextForEmoji(input, emojis, processor)).to.eql([
+        'Hello from finland! ',
+        { shortcode: 'lol', src: 'LOL' },
+        ' We have best water! ',
+        { shortcode: 'lmao', src: 'LMAO' }
+      ])
+    })
+    it('leaves text as is', () => {
+      const input = 'Number one: that\'s terror'
+      const emojis = []
+      const processor = ({ shortcode, src }) => ({ shortcode, src })
+      expect(processTextForEmoji(input, emojis, processor)).to.eql([
+        'Number one: that\'s terror'
+      ])
+    })
+  })
+
+  describe('getAttrs', () => {
+    it('extracts arguments from tag', () => {
+      const input = '<img src="boop" cool ebin=\'true\'>'
+      const output = { src: 'boop', cool: true, ebin: 'true' }
+
+      expect(getAttrs(input)).to.eql(output)
+    })
+  })
+})

From 90a188f2c3c16b926c75bf4aa749633e6967e5a0 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 19:54:03 +0300
Subject: [PATCH 062/169] cleanup

---
 src/components/chat_list_item/chat_list_item.js          | 3 +--
 src/components/chat_message/chat_message.js              | 3 +--
 src/components/status_body/status_body.js                | 2 +-
 .../entity_normalizer/entity_normalizer.service.js       | 5 ++---
 .../services/entity_normalizer/entity_normalizer.spec.js | 9 ---------
 5 files changed, 5 insertions(+), 17 deletions(-)

diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
index e01b9bd4..e5032176 100644
--- a/src/components/chat_list_item/chat_list_item.js
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -40,12 +40,11 @@ const ChatListItem = {
       const message = this.chat.lastMessage
       const messageEmojis = message ? message.emojis : []
       const isYou = message && message.account_id === this.currentUser.id
-      const content = message ? (this.attachmentInfo || message.content_raw) : ''
+      const content = message ? (this.attachmentInfo || message.content) : ''
       const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
       return {
         summary: '',
         emojis: messageEmojis,
-        statusnet_html: messagePreview,
         raw_html: messagePreview,
         text: messagePreview,
         attachments: []
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index d126d453..9a2d1e7d 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -58,8 +58,7 @@ const ChatMessage = {
       return {
         summary: '',
         emojis: this.message.emojis,
-        raw_html: this.message.content_raw,
-        statusnet_html: this.message.content,
+        raw_html: this.message.content,
         text: this.message.content,
         attachments: this.message.attachments
       }
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 14558aca..26491e1b 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -53,7 +53,7 @@ const StatusContent = {
     // Using max-height + overflow: auto for status components resulted in false positives
     // very often with japanese characters, and it was very annoying.
     tallStatus () {
-      const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
+      const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.status.text.length / 80
       return lengthScore > 20
     },
     longSubject () {
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 13162dcf..613ed566 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -267,7 +267,6 @@ export const parseStatus = (data) => {
     output.type = data.reblog ? 'retweet' : 'status'
     output.nsfw = data.sensitive
 
-    output.statusnet_html = addEmojis(data.content, data.emojis)
     output.raw_html = data.content
     output.emojis = data.emojis
 
@@ -329,7 +328,7 @@ export const parseStatus = (data) => {
       output.nsfw = data.nsfw
     }
 
-    output.statusnet_html = data.statusnet_html
+    output.raw_html = data.statusnet_html
     output.text = data.text
 
     output.in_reply_to_status_id = data.in_reply_to_status_id
@@ -449,7 +448,7 @@ export const parseChatMessage = (message) => {
   output.created_at = new Date(message.created_at)
   output.chat_id = message.chat_id
   output.emojis = message.emojis
-  output.content_raw = message.content
+  output.content = message.content
   if (message.content) {
     output.content = addEmojis(message.content, message.emojis)
   } else {
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index 759539e0..c8965785 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -23,7 +23,6 @@ const makeMockStatusQvitter = (overrides = {}) => {
     repeat_num: 0,
     repeated: false,
     statusnet_conversation_id: '16300488',
-    statusnet_html: '<p>haha benis</p>',
     summary: null,
     tags: [],
     text: 'haha benis',
@@ -233,14 +232,6 @@ describe('API Entities normalizer', () => {
         expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef')
       })
 
-      it('adds emojis to post content', () => {
-        const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), content: 'Makes you think :thinking:' })
-
-        const parsedPost = parseStatus(post)
-
-        expect(parsedPost).to.have.property('statusnet_html').that.contains('<img')
-      })
-
       it('adds emojis to subject line', () => {
         const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), spoiler_text: 'CW: 300 IQ :thinking:' })
 

From 418f029789f5e1cc22fd7db4f269088633d90050 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 20:42:17 +0300
Subject: [PATCH 063/169] review + fixes

---
 src/components/mention_link/mention_link.vue  |  2 --
 .../mentions_line/mentions_line.scss          |  1 +
 src/components/rich_content/rich_content.jsx  | 21 ++++++++++++++
 .../tabs/theme_tab/theme_tab.js               |  2 +-
 src/components/status/status.js               |  6 ++--
 src/components/status_body/status_body.scss   |  8 ------
 src/components/status_body/status_body.vue    |  4 +--
 .../entity_normalizer.service.js              |  6 ----
 .../specs/components/rich_content.spec.js     | 28 +++++++++++++++++++
 .../entity_normalizer.spec.js                 |  8 ------
 10 files changed, 55 insertions(+), 31 deletions(-)

diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 3449f4bd..e4d395fa 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -41,12 +41,10 @@
         class="full popover-default"
         :class="[highlightType]"
       >
-        <!-- eslint-disable vue/no-v-html -->
         <span
           class="userNameFull"
           v-text="'@' + userNameFull"
         />
-        <!-- eslint-enable vue/no-v-html -->
       </span>
     </span>
   </span>
diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index 59f75fbb..90d1e0a4 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -1,6 +1,7 @@
 .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 ef15aaeb..0aae7a55 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -8,6 +8,25 @@ import MentionLink from 'src/components/mention_link/mention_link.vue'
 
 import './rich_content.scss'
 
+/**
+ * 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: {
@@ -241,8 +260,10 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
         .replace(/@\w+/gi, '') // remove mentions (even failed ones)
         .trim()
       if (cleanedString.startsWith('&gt;')) {
+        nonEmptyIndex += 1
         return `<span class='greentext'>${string}</span>`
       } else if (cleanedString.startsWith('&lt;')) {
+        nonEmptyIndex += 1
         return `<span class='cyantext'>${string}</span>`
       }
     }
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 63416e93..85749045 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -474,7 +474,7 @@ export default {
           this.loadThemeFromLocalStorage(false, true)
           break
         case 'file':
-          console.error('Forcing snapshout from file is not supported yet')
+          console.error('Forcing snapshot from file is not supported yet')
           break
       }
       this.dismissWarning()
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 5b178c2e..ae734493 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -35,8 +35,7 @@ import {
   faStar,
   faEyeSlash,
   faEye,
-  faThumbtack,
-  faAt
+  faThumbtack
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -53,8 +52,7 @@ library.add(
   faEllipsisH,
   faEyeSlash,
   faEye,
-  faThumbtack,
-  faAt
+  faThumbtack
 )
 
 const Status = {
diff --git a/src/components/status_body/status_body.scss b/src/components/status_body/status_body.scss
index da5d4dd3..81a687f1 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -115,12 +115,4 @@
   .cyantext {
     color: var(--postCyantext, $fallback--cBlue);
   }
-
-  /* Not sure if this is necessary */
-  video {
-    max-width: 100%;
-    max-height: 400px;
-    vertical-align: middle;
-    object-fit: contain;
-  }
 }
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index aac44e42..0eb11ad0 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -2,7 +2,7 @@
   <div class="StatusBody">
     <div class="body">
       <div
-        v-if="status.summary_html"
+        v-if="status.summary_raw_html"
         class="summary-wrapper"
         :class="{ '-tall': (longSubject && !showingLongSubject) }"
       >
@@ -39,7 +39,7 @@
           {{ $t("general.show_more") }}
         </button>
         <span
-          v-if="!hideSubjectStatus && !(singleLine && status.summary_html)"
+          v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)"
           class="rich-content-wrapper"
         >
           <MentionsLine
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 613ed566..477b861f 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -296,7 +296,6 @@ export const parseStatus = (data) => {
     }
 
     output.summary_raw_html = escape(data.spoiler_text)
-    output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
     output.external_url = data.url
     output.poll = data.poll
     if (output.poll) {
@@ -449,11 +448,6 @@ export const parseChatMessage = (message) => {
   output.chat_id = message.chat_id
   output.emojis = message.emojis
   output.content = message.content
-  if (message.content) {
-    output.content = addEmojis(message.content, message.emojis)
-  } else {
-    output.content = ''
-  }
   if (message.attachment) {
     output.attachments = [parseAttachment(message.attachment)]
   } else {
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index 05c0b259..f2c3f04e 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -354,4 +354,32 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(html))
   })
+
+  it('Greentext + last mentions', () => {
+    const html = [
+      '&gt;quote',
+      makeMention('lol'),
+      '&gt;quote',
+      '&gt;quote'
+    ].join('\n')
+    const expected = [
+      '<span class="greentext">&gt;quote</span>',
+      stubMention('lol'),
+      '<span class="greentext">&gt;quote</span>',
+      '<span class="greentext">&gt;quote</span>'
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
 })
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index c8965785..8a5a6ef9 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -231,14 +231,6 @@ describe('API Entities normalizer', () => {
         expect(parsedRepeat).to.have.property('retweeted_status')
         expect(parsedRepeat).to.have.deep.property('retweeted_status.id', 'deadbeef')
       })
-
-      it('adds emojis to subject line', () => {
-        const post = makeMockStatusMasto({ emojis: makeMockEmojiMasto(), spoiler_text: 'CW: 300 IQ :thinking:' })
-
-        const parsedPost = parseStatus(post)
-
-        expect(parsedPost).to.have.property('summary_html').that.contains('<img')
-      })
     })
   })
 

From 18fb7516cc17b36f271dad325d37e546d47dde08 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 20:42:59 +0300
Subject: [PATCH 064/169] lint

---
 test/unit/specs/components/rich_content.spec.js | 1 -
 1 file changed, 1 deletion(-)

diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index f2c3f04e..20322019 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -381,5 +381,4 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
-
 })

From 2c60a9b638a00db33e6c47e8642aff2ffd0ce7a0 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 20:51:36 +0300
Subject: [PATCH 065/169] fix next relply-row bleeding through popover

---
 src/components/status/status.scss | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 3805aa30..71305dd7 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -167,7 +167,6 @@ $status-margin: 0.75em;
     line-height: 160%;
     max-width: 100%;
     align-items: stretch;
-    z-index: 2;
   }
 
   & .reply-to-popover,

From 9c70f3e4df2e28863b51156fdbd25e253a3a1b98 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 12 Jun 2021 21:49:40 +0300
Subject: [PATCH 066/169] fixed a bug + made a testcase out of it

---
 src/components/rich_content/rich_content.jsx  |  5 ++-
 .../specs/components/rich_content.spec.js     | 35 +++++++++++++++++++
 2 files changed, 37 insertions(+), 3 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 0aae7a55..e188763f 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -247,12 +247,13 @@ const getLinkData = (attrs, children, index) => {
 export const preProcessPerLine = (html, greentext, handleLinks) => {
   const lastMentions = []
 
-  let nonEmptyIndex = 0
+  let nonEmptyIndex = -1
   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
+    nonEmptyIndex += 1
 
     // Greentext stuff
     if (greentext && (string.includes('&gt;') || string.includes('&lt;'))) {
@@ -260,10 +261,8 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
         .replace(/@\w+/gi, '') // remove mentions (even failed ones)
         .trim()
       if (cleanedString.startsWith('&gt;')) {
-        nonEmptyIndex += 1
         return `<span class='greentext'>${string}</span>`
       } else if (cleanedString.startsWith('&lt;')) {
-        nonEmptyIndex += 1
         return `<span class='cyantext'>${string}</span>`
       }
     }
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index 20322019..ff491a3a 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -381,4 +381,39 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
+
+  it('One buggy example', () => {
+    const html = [
+      'Bruh',
+      'Bruh',
+      [
+        makeMention('foo'),
+        makeMention('bar'),
+        makeMention('baz')
+      ].join(''),
+      'Bruh'
+    ].join('<br>')
+    const expected = [
+      'Bruh',
+      'Bruh',
+      [
+        stubMention('foo'),
+        stubMention('bar'),
+        stubMention('baz')
+      ].join(''),
+      'Bruh'
+    ].join('<br>')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
 })

From e825021ef1ae7a672b275227a6a1ff44d5f522bc Mon Sep 17 00:00:00 2001
From: HJ <30-hj@users.noreply.git.pleroma.social>
Date: Sat, 12 Jun 2021 18:55:18 +0000
Subject: [PATCH 067/169] Apply 1 suggestion(s) to 1 file(s)

---
 src/components/status/status.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/status/status.js b/src/components/status/status.js
index ae734493..3c21cb76 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -352,7 +352,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 wahttp://localhost:8080/users/hj/dmsnt to see is taller than screen so match its top to screen top
+          // Post we want 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

From bebafa1a2c38972245d37de70f4aec4bfb2083fd Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 13 Jun 2021 13:29:26 +0300
Subject: [PATCH 068/169] refactored line converter, untied its logic from
 greentexting, better handling of broken cases

---
 src/components/rich_content/rich_content.jsx  | 10 +++-
 .../html_line_converter.service.js            | 55 +++++++++++++++----
 .../html_line_converter.spec.js               | 17 ++++--
 3 files changed, 67 insertions(+), 15 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index e188763f..328e9201 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -246,6 +246,7 @@ const getLinkData = (attrs, children, index) => {
  */
 export const preProcessPerLine = (html, greentext, handleLinks) => {
   const lastMentions = []
+  const greentextHandle = new Set(['p', 'div'])
 
   let nonEmptyIndex = -1
   const newHtml = convertHtmlToLines(html).reverse().map((item, index, array) => {
@@ -256,7 +257,14 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
     nonEmptyIndex += 1
 
     // Greentext stuff
-    if (greentext && (string.includes('&gt;') || string.includes('&lt;'))) {
+    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;'))
+    ) {
       const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
         .replace(/@\w+/gi, '') // remove mentions (even failed ones)
         .trim()
diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js
index e448d5cd..f43d162a 100644
--- a/src/services/html_converter/html_line_converter.service.js
+++ b/src/services/html_converter/html_line_converter.service.js
@@ -19,9 +19,42 @@ import { getTagName } from './utility.service.js'
  * @return {(string|{ text: string })[]} processed html in form of a list.
  */
 export const convertHtmlToLines = (html) => {
-  const ignoredTags = new Set(['code', 'blockquote'])
-  const handledTags = new Set(['p', 'br', 'div', 'pre', 'code', 'blockquote'])
-  const openCloseTags = new Set(['p', 'div', 'pre', 'code', 'blockquote'])
+  // Elements that are implicitly self-closing
+  // https://developer.mozilla.org/en-US/docs/Glossary/empty_element
+  const emptyElements = new Set([
+    'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
+    'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr'
+  ])
+  // Block-level element (they make a visual line)
+  // https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
+  const blockElements = new Set([
+    'address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'dd',
+    'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
+    'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', 'main',
+    'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul'
+  ])
+  // br is very weird in a way that it's technically not block-level, it's
+  // essentially converted to a \n (or \r\n). There's also wbr but it doesn't
+  // guarantee linebreak, only suggest it.
+  const linebreakElements = new Set(['br'])
+
+  const visualLineElements = new Set([
+    ...blockElements.values(),
+    ...linebreakElements.values()
+  ])
+
+  // All block-level elements that aren't empty elements, i.e. not <hr>
+  const nonEmptyElements = new Set(visualLineElements)
+  // Difference
+  for (let elem of emptyElements) {
+    nonEmptyElements.delete(elem)
+  }
+
+  // All elements that we are recognizing
+  const allElements = new Set([
+    ...nonEmptyElements.values(),
+    ...emptyElements.values()
+  ])
 
   let buffer = [] // Current output buffer
   const level = [] // How deep we are in tags and which tags were there
@@ -29,8 +62,8 @@ export const convertHtmlToLines = (html) => {
   let tagBuffer = null // Current tag buffer, if null = we are not currently reading a tag
 
   const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer
-    if (textBuffer.trim().length > 0 && !level.some(l => ignoredTags.has(l))) {
-      buffer.push({ text: textBuffer })
+    if (textBuffer.trim().length > 0) {
+      buffer.push({ level: [...level], text: textBuffer })
     } else {
       buffer.push(textBuffer)
     }
@@ -49,10 +82,12 @@ export const convertHtmlToLines = (html) => {
   }
 
   const handleClose = (tag) => { // handles closing tags
-    flush()
-    buffer.push(tag)
     if (level[0] === getTagName(tag)) {
+      flush()
+      buffer.push(tag)
       level.shift()
+    } else { // Broken case
+      textBuffer += tag
     }
   }
 
@@ -67,10 +102,10 @@ export const convertHtmlToLines = (html) => {
       const tagFull = tagBuffer
       tagBuffer = null
       const tagName = getTagName(tagFull)
-      if (handledTags.has(tagName)) {
-        if (tagName === 'br') {
+      if (allElements.has(tagName)) {
+        if (linebreakElements.has(tagName)) {
           handleBr(tagFull)
-        } else if (openCloseTags.has(tagName)) {
+        } else if (nonEmptyElements.has(tagName)) {
           if (tagFull[1] === '/') {
             handleClose(tagFull)
           } else if (tagFull[tagFull.length - 2] === '/') {
diff --git a/test/unit/specs/services/html_converter/html_line_converter.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js
index 9485233f..c8c89700 100644
--- a/test/unit/specs/services/html_converter/html_line_converter.spec.js
+++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js
@@ -1,8 +1,17 @@
 import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
 
-const mapOnlyText = (processor) => (input) => input.text ? processor(input.text) : input
+const greentextHandle = new Set(['p', 'div'])
+const mapOnlyText = (processor) => (input) => {
+  if (input.text && input.level.every(l => greentextHandle.has(l))) {
+    return processor(input.text)
+  } else if (input.text) {
+    return input.text
+  } else {
+    return input
+  }
+}
 
-describe('html_line_converter', () => {
+describe.only('html_line_converter', () => {
   describe('with processor that keeps original line should not make any changes to HTML when', () => {
     const processorKeep = (line) => line
     it('fed with regular HTML with newlines', () => {
@@ -81,7 +90,7 @@ describe('html_line_converter', () => {
 
     it('fed with very broken HTML with broken composition', () => {
       const input = '</p> lmao what </div> whats going on <div> wha <p>'
-      const output = '</p>_</div>_<div>_<p>'
+      const output = '_<div>_<p>'
       const result = convertHtmlToLines(input)
       const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
       expect(comparableResult).to.eql(output)
@@ -111,7 +120,7 @@ describe('html_line_converter', () => {
       expect(comparableResult).to.eql(output)
     })
 
-    it('fed with maybe valid HTML? self-closing divs and ps', () => {
+    it('fed with maybe valid HTML? (XHTML) self-closing divs and ps', () => {
       const input = 'a <div class="what"/> what now <p aria-label="wtf"/> ?'
       const output = '_<div class="what"/>_<p aria-label="wtf"/>_'
       const result = convertHtmlToLines(input)

From 609dc5da0c0c4cb64da68af8c9ec999be6dd0a83 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 13 Jun 2021 21:42:25 +0300
Subject: [PATCH 069/169] fix chats messages

---
 src/components/chat_message/chat_message.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index 9a2d1e7d..eb195bc1 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -58,8 +58,8 @@ const ChatMessage = {
       return {
         summary: '',
         emojis: this.message.emojis,
-        raw_html: this.message.content,
-        text: this.message.content,
+        raw_html: this.message.content || '',
+        text: this.message.content || '',
         attachments: this.message.attachments
       }
     },

From 1fdfc42159ed91090c5a7ab36c7e61f3d9527941 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 13 Jun 2021 21:43:45 +0300
Subject: [PATCH 070/169] fix mentions in chats

---
 src/components/chat_message/chat_message.scss | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index 220355ea..de6990b6 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -91,7 +91,7 @@
   .without-attachment {
     .message-content {
       // TODO figure out how to do it properly
-      .text::after {
+      .rich-content-wrapper::after {
         margin-right: 5.4em;
         content: " ";
         display: inline-block;

From 636dbdaba8375cb991368620419e2997df0f57a9 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 13 Jun 2021 22:22:59 +0300
Subject: [PATCH 071/169] more fixes

---
 src/components/rich_content/rich_content.jsx  | 19 ++--
 src/components/status_body/status_body.vue    |  2 +-
 .../specs/components/rich_content.spec.js     | 99 +++++++++++++++++++
 3 files changed, 113 insertions(+), 7 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 328e9201..ffb36f50 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -140,7 +140,7 @@ export default Vue.component('RichContent', {
         switch (Tag) {
           case 'span': // replace images with StillImage
             if (attrs['class'] && attrs['class'].includes('lastMentions')) {
-              if (firstMentions.length > 1) {
+              if (firstMentions.length > 1 && lastMentions.length > 1) {
                 break
               } else {
                 return ''
@@ -249,7 +249,9 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
   const greentextHandle = new Set(['p', 'div'])
 
   let nonEmptyIndex = -1
-  const newHtml = convertHtmlToLines(html).reverse().map((item, index, array) => {
+  const lines = convertHtmlToLines(html)
+  const linesNum = lines.filter(c => c.text).length
+  const newHtml = lines.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
@@ -281,7 +283,7 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
     // 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
+    let mentionsNum = 0
     const process = (item) => {
       if (Array.isArray(item)) {
         const [opener, children, closer] = item
@@ -292,7 +294,7 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
           const attrs = getAttrs(opener)
           if (attrs['class'] && attrs['class'].includes('mention')) {
             // Got mentions
-            hasMentions = true
+            mentionsNum++
             return [opener, children, closer]
           } else {
             // Not a mention? Means we have loose text or whatever
@@ -321,8 +323,13 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
     // We now processed our tree, now we need to mark line as lastMentions
     const result = [...tree].map(process)
 
-    // Only check last (first since list is reversed) line
-    if (handleLinks && hasMentions && !hasLooseText && nonEmptyIndex++ === 0) {
+    if (
+      handleLinks && // Do we handle links at all?
+        mentionsNum > 1 && // Does it have more than one mention?
+        !hasLooseText && // Don't do anything if it has something besides mentions
+        nonEmptyIndex === 0 && // Only check last (first since list is reversed) line
+        nonEmptyIndex !== linesNum - 1 // Don't do anything if there's only one line
+    ) {
       let mentionIndex = 0
       const process = (item) => {
         if (Array.isArray(item)) {
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 0eb11ad0..3dc4916c 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -56,7 +56,7 @@
             @parseReady="setHeadTailLinks"
           />
           <MentionsLine
-            v-if="!hideMentions && lastMentions.length > 0 && firstMentions.length <= 1"
+            v-if="!hideMentions && lastMentions.length > 1 && firstMentions.length <= 1"
             :mentions="lastMentions"
           />
         </span>
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index ff491a3a..835fbea2 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -416,4 +416,103 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
+
+  it('Don\'t remove last mention if it\'s the only one', () => {
+    const html = [
+      'Bruh',
+      'Bruh',
+      makeMention('foo'),
+      makeMention('bar'),
+      makeMention('baz')
+    ].join('<br>')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(html))
+  })
+
+  it('Don\'t remove last mentions if there are more than one first mention - remove first instead', () => {
+    const html = [
+      [
+        makeMention('foo'),
+        makeMention('bar')
+      ].join(' '),
+      'Bruh',
+      'Bruh',
+      [
+        makeMention('foo'),
+        makeMention('bar'),
+        makeMention('baz')
+      ].join(' ')
+    ].join('\n')
+
+    const expected = [
+      [
+        removedMentionSpan,
+        removedMentionSpan,
+        'Bruh' // Due to trim we remove extra newline
+      ].join(''),
+      'Bruh',
+      lastMentions([
+        stubMention('foo'),
+        stubMention('bar'),
+        stubMention('baz')
+      ].join(' '))
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('Remove last mentions if there\'s just one first mention - remove all', () => {
+    const html = [
+      [
+        makeMention('foo')
+      ].join(' '),
+      'Bruh',
+      'Bruh',
+      [
+        makeMention('foo'),
+        makeMention('bar'),
+        makeMention('baz')
+      ].join(' ')
+    ].join('\n')
+
+    const expected = [
+      [
+        removedMentionSpan,
+        'Bruh' // Due to trim we remove extra newline
+      ].join(''),
+      'Bruh\n' // Can't remove this one yet
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
 })

From c21b1cf89840297a781e6adc66cc195b8741cac6 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 14 Jun 2021 10:30:08 +0300
Subject: [PATCH 072/169] do the impossible, fix the unfixable

---
 src/components/rich_content/rich_content.jsx  | 16 +++-
 src/components/status_body/status_body.js     | 12 +--
 src/components/status_body/status_body.scss   |  2 +-
 src/components/status_body/status_body.vue    | 31 ++-----
 .../status_content/status_content.js          |  3 -
 .../status_content/status_content.vue         |  2 +-
 .../specs/components/rich_content.spec.js     | 91 ++++++++++++++++++-
 .../html_line_converter.spec.js               |  2 +-
 8 files changed, 118 insertions(+), 41 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index ffb36f50..4144d895 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -5,6 +5,7 @@ import { convertHtmlToTree } from 'src/services/html_converter/html_tree_convert
 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'
+import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
 
 import './rich_content.scss'
 
@@ -51,6 +52,11 @@ export default Vue.component('RichContent', {
       required: false,
       type: Boolean,
       default: false
+    },
+    hideMentions: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   // NEVER EVER TOUCH DATA INSIDE RENDER
@@ -64,6 +70,7 @@ export default Vue.component('RichContent', {
     // unique index for vue "tag" property
     let mentionIndex = 0
     let tagsIndex = 0
+    let firstMentionReplaced = false
 
     const renderImage = (tag) => {
       return <StillImage
@@ -90,7 +97,12 @@ export default Vue.component('RichContent', {
       writtenMentions.push(linkData)
       if (!encounteredText) {
         firstMentions.push(linkData)
-        return ''
+        if (!firstMentionReplaced && !this.hideMentions) {
+          firstMentionReplaced = true
+          return <MentionsLine mentions={ firstMentions } />
+        } else {
+          return ''
+        }
       } else {
         return <MentionLink
           url={attrs.href}
@@ -143,7 +155,7 @@ export default Vue.component('RichContent', {
               if (firstMentions.length > 1 && lastMentions.length > 1) {
                 break
               } else {
-                return ''
+                return !this.hideMentions ? <MentionsLine mentions={lastMentions} /> : ''
               }
             } else {
               break
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 26491e1b..9ee7a109 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -1,6 +1,5 @@
 import fileType from 'src/services/file_type/file_type.service'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
-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'
@@ -36,9 +35,6 @@ const StatusContent = {
       showingLongSubject: false,
       // not as computed because it sets the initial state which will be changed later
       expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
-      headTailLinks: null,
-      firstMentions: [],
-      lastMentions: []
     }
   },
   computed: {
@@ -81,8 +77,7 @@ const StatusContent = {
     ...mapGetters(['mergedConfig'])
   },
   components: {
-    RichContent,
-    MentionsLine
+    RichContent
   },
   mounted () {
     this.status.attentions && this.status.attentions.forEach(attn => {
@@ -98,11 +93,6 @@ 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.scss b/src/components/status_body/status_body.scss
index 81a687f1..c7732bfe 100644
--- a/src/components/status_body/status_body.scss
+++ b/src/components/status_body/status_body.scss
@@ -62,7 +62,7 @@
       overflow-y: hidden;
       z-index: 1;
 
-      .rich-content-wrapper {
+      .media-body {
         min-height: 0;
         mask:
           linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 3dc4916c..2be46303 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -38,28 +38,17 @@
         >
           {{ $t("general.show_more") }}
         </button>
-        <span
+        <RichContent
           v-if="!hideSubjectStatus && !(singleLine && status.summary_raw_html)"
-          class="rich-content-wrapper"
-        >
-          <MentionsLine
-            v-if="!hideMentions && firstMentions && firstMentions.length > 0"
-            :mentions="firstMentions"
-          />
-          <RichContent
-            :class="{ '-single-line': singleLine }"
-            class="text media-body"
-            :html="status.raw_html"
-            :emoji="status.emojis"
-            :handle-links="true"
-            :greentext="mergedConfig.greentext"
-            @parseReady="setHeadTailLinks"
-          />
-          <MentionsLine
-            v-if="!hideMentions && lastMentions.length > 1 && firstMentions.length <= 1"
-            :mentions="lastMentions"
-          />
-        </span>
+          :class="{ '-single-line': singleLine }"
+          class="text media-body"
+          :html="status.raw_html"
+          :emoji="status.emojis"
+          :handle-links="true"
+          :hide-mentions="hideMentions"
+          :greentext="mergedConfig.greentext"
+          @parseReady="$emit('parseReady', $event)"
+        />
 
         <button
           v-if="hideSubjectStatus"
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 11a4974b..51895ef6 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -92,9 +92,6 @@ 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 feb34d2c..2e71757d 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -5,7 +5,7 @@
       :status="status"
       :single-line="singleLine"
       :hide-mentions="hideMentions"
-      @parseReady="setHeadTailLinks"
+      @parseReady="$emit('parseReady', $event)"
     >
       <div v-if="status.poll && status.poll.options">
         <poll :base-poll="status.poll" />
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index 835fbea2..c6ec4315 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -16,6 +16,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -38,6 +39,34 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('replaces first mention with mentionsline if hideMentions=false', () => {
+    const html = p(
+      makeMention('John'),
+      ' how are you doing thoday?'
+    )
+    const expected = p(
+      '<span class="h-card">',
+      '<mentionsline-stub mentions="',
+      '[object Object]',
+      '"></mentionsline-stub>',
+      '</span>',
+      'how are you doing thoday?'
+    )
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        hideMentions: false,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -68,6 +97,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -78,6 +108,44 @@ describe('RichContent', () => {
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
 
+  it('replaces mentions at the end of the hellpost if hideMentions=false (<p>)', () => {
+    const html = [
+      p('How are you doing today, fine gentlemen?'),
+      p(
+        makeMention('John'),
+        makeMention('Josh'),
+        makeMention('Jeremy')
+      )
+    ].join('')
+    const expected = [
+      p(
+        'How are you doing today, fine gentlemen?'
+      ),
+      // TODO fix this extra line somehow?
+      p(
+        '<mentionsline-stub mentions="',
+        '[object Object],',
+        '[object Object],',
+        '[object Object]',
+        '"></mentionsline-stub>'
+      )
+    ].join('')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        hideMentions: false,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+
   it('removes mentions from the end of the hellpost (<br>)', () => {
     const html = [
       'How are you doing today, fine gentlemen?',
@@ -96,6 +164,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -124,6 +193,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -165,6 +235,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -199,6 +270,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -240,6 +312,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -267,6 +340,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: false,
         greentext: true,
         emoji: [],
@@ -290,6 +364,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: false,
         greentext: true,
         emoji: [],
@@ -309,6 +384,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: false,
         greentext: false,
         emoji: [],
@@ -329,6 +405,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: false,
         greentext: false,
         emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
@@ -345,6 +422,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: false,
         greentext: false,
         emoji: [],
@@ -407,6 +485,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -425,10 +504,18 @@ describe('RichContent', () => {
       makeMention('bar'),
       makeMention('baz')
     ].join('<br>')
+    const expected = [
+      'Bruh',
+      'Bruh',
+      stubMention('foo'),
+      stubMention('bar'),
+      stubMention('baz')
+    ].join('<br>')
 
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -436,7 +523,7 @@ describe('RichContent', () => {
       }
     })
 
-    expect(wrapper.html()).to.eql(compwrap(html))
+    expect(wrapper.html()).to.eql(compwrap(expected))
   })
 
   it('Don\'t remove last mentions if there are more than one first mention - remove first instead', () => {
@@ -471,6 +558,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -506,6 +594,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
diff --git a/test/unit/specs/services/html_converter/html_line_converter.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js
index c8c89700..de7c7fc2 100644
--- a/test/unit/specs/services/html_converter/html_line_converter.spec.js
+++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js
@@ -11,7 +11,7 @@ const mapOnlyText = (processor) => (input) => {
   }
 }
 
-describe.only('html_line_converter', () => {
+describe('html_line_converter', () => {
   describe('with processor that keeps original line should not make any changes to HTML when', () => {
     const processorKeep = (line) => line
     it('fed with regular HTML with newlines', () => {

From 7309f8ce1a5df1d34e33a242201b6a7b9eced2b7 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 14 Jun 2021 10:31:07 +0300
Subject: [PATCH 073/169] lint

---
 src/components/status_body/status_body.js       | 3 +--
 test/unit/specs/components/rich_content.spec.js | 1 -
 2 files changed, 1 insertion(+), 3 deletions(-)

diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 9ee7a109..601c963b 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -2,7 +2,6 @@ import fileType from 'src/services/file_type/file_type.service'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
-import { set } from 'vue'
 import {
   faFile,
   faMusic,
@@ -34,7 +33,7 @@ const StatusContent = {
       showingTall: this.fullContent || (this.inConversation && this.focused),
       showingLongSubject: false,
       // not as computed because it sets the initial state which will be changed later
-      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
+      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
     }
   },
   computed: {
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index c6ec4315..e364a5b3 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -145,7 +145,6 @@ describe('RichContent', () => {
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
 
-
   it('removes mentions from the end of the hellpost (<br>)', () => {
     const html = [
       'How are you doing today, fine gentlemen?',

From 63c22ad131d8f599714fe62df708758869fac3fb Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 14 Jun 2021 10:58:32 +0300
Subject: [PATCH 074/169] Fixed mergedConfig misbehaving on first boot

---
 src/modules/config.js | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/modules/config.js b/src/modules/config.js
index 0f596750..b488ecc2 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -93,7 +93,8 @@ const config = {
       const { defaultConfig } = rootGetters
       return {
         ...defaultConfig,
-        ...state
+        // Do not override with undefined
+        ...Object.fromEntries(Object.entries(state).filter(([k, v]) => v !== undefined))
       }
     }
   },

From adfe56a3a31b3c4a8c2c206c06bebf9c4af675d1 Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Mon, 14 Jun 2021 17:54:40 +0000
Subject: [PATCH 075/169] New option: Always show floating New Post button

---
 .gitignore                                               | 2 ++
 CHANGELOG.md                                             | 2 +-
 CONTRIBUTORS.md                                          | 1 +
 src/App.js                                               | 3 +++
 src/App.vue                                              | 1 +
 .../mobile_post_status_button.js                         | 3 +++
 .../mobile_post_status_button.vue                        | 4 ++--
 src/components/settings_modal/tabs/general_tab.vue       | 5 +++++
 src/components/shout_panel/shout_panel.vue               | 9 ++++++++-
 src/i18n/en.json                                         | 1 +
 src/modules/config.js                                    | 1 +
 11 files changed, 28 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
index 479d57c4..8b23774f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@ test/e2e/reports
 selenium-debug.log
 .idea/
 config/local.json
+.gitignore
+config/local.example.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 905d9f65..4651c01d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Added option to mark posts as sensitive by default
 - Added quick filters for notifications
 - Implemented user option to change sidebar position to the right side
-
+- Implemented user option to always show floating New Post button (normally mobile-only)
 
 ## [2.3.0] - 2021-03-01
 ### Fixed
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index d7c217ce..f666a4ef 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -3,6 +3,7 @@ Contributors of this project.
 - Constance Variable (lambadalambda@social.heldscal.la): Code
 - Coco Snuss (cocosnuss@social.heldscal.la): Code
 - wakarimasen (wakarimasen@shitposter.club): NSFW hiding image
+- eris (eris@disqordia.space): Code
 - dtluna (dtluna@social.heldscal.la): Code
 - sonyam (sonyam@social.heldscal.la): Background images
 - hakui (hakui@freezepeach.xyz): CSS and styling
diff --git a/src/App.js b/src/App.js
index fe4c30cb..e7ea3851 100644
--- a/src/App.js
+++ b/src/App.js
@@ -73,6 +73,9 @@ export default {
         this.$store.state.instance.instanceSpecificPanelContent
     },
     showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
+    shoutboxPosition () {
+      return this.$store.getters.mergedConfig.showNewPostButton || false
+    },
     isMobileLayout () { return this.$store.state.interface.mobileLayout },
     privateMode () { return this.$store.state.instance.private },
     sidebarAlign () {
diff --git a/src/App.vue b/src/App.vue
index 6c582c03..6b867498 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -53,6 +53,7 @@
       v-if="currentUser && shout"
       :floating="true"
       class="floating-shout mobile-hidden"
+      :class="{ 'left': shoutboxPosition }"
     />
     <MobilePostStatusButton />
     <UserReportingModal />
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
index 366ea89c..d27fb3b8 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.js
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -44,6 +44,9 @@ const MobilePostStatusButton = {
 
       return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
     },
+    isPersistent () {
+      return !!this.$store.getters.mergedConfig.showNewPostButton
+    },
     autohideFloatingPostButton () {
       return !!this.$store.getters.mergedConfig.autohideFloatingPostButton
     }
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue
index 767f8244..37becf4c 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.vue
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue
@@ -2,7 +2,7 @@
   <div v-if="isLoggedIn">
     <button
       class="button-default new-status-button"
-      :class="{ 'hidden': isHidden }"
+      :class="{ 'hidden': isHidden, 'always-show': isPersistent }"
       @click="openPostForm"
     >
       <FAIcon icon="pen" />
@@ -47,7 +47,7 @@
 }
 
 @media all and (min-width: 801px) {
-  .new-status-button {
+  .new-status-button:not(.always-show) {
     display: none;
   }
 }
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index bdb29edf..4c09d047 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -117,6 +117,11 @@
             {{ $t('settings.sensitive_by_default') }}
           </BooleanSetting>
         </li>
+        <li>
+          <BooleanSetting path="showNewPostButton">
+            {{ $t('settings.showPostButton') }}
+          </BooleanSetting>
+        </li>
         <li>
           <BooleanSetting path="autohideFloatingPostButton">
             {{ $t('settings.autohide_floating_post_button') }}
diff --git a/src/components/shout_panel/shout_panel.vue b/src/components/shout_panel/shout_panel.vue
index f90baf80..c88797d1 100644
--- a/src/components/shout_panel/shout_panel.vue
+++ b/src/components/shout_panel/shout_panel.vue
@@ -79,12 +79,19 @@
 
 .floating-shout {
   position: fixed;
-  right: 0px;
   bottom: 0px;
   z-index: 1000;
   max-width: 25em;
 }
 
+.floating-shout.left {
+  left: 0px;
+}
+
+.floating-shout:not(.left) {
+  right: 0px;
+}
+
 .shout-panel {
   .shout-heading {
     cursor: pointer;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index aa440ac1..26f51cea 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -349,6 +349,7 @@
     "max_thumbnails": "Maximum amount of thumbnails per post",
     "hide_isp": "Hide instance-specific panel",
     "right_sidebar": "Show sidebar on the right side",
+    "showPostButton": "Always show floating New Post button",
     "hide_wallpaper": "Hide instance wallpaper",
     "preload_images": "Preload images",
     "use_one_click_nsfw": "Open NSFW attachments with just one click",
diff --git a/src/modules/config.js b/src/modules/config.js
index 0f596750..30c77a7b 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -34,6 +34,7 @@ export const defaultState = {
   loopVideoSilentOnly: true,
   streaming: false,
   emojiReactionsOnTimeline: true,
+  showNewPostButton: false,
   autohideFloatingPostButton: false,
   pauseOnUnfocused: true,
   stopGifs: false,

From 67c9d8bd55b94d85ecb38c8034680b25ea5196fe Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Mon, 14 Jun 2021 18:49:37 +0000
Subject: [PATCH 076/169] revert gitignore file change

---
 .gitignore | 2 --
 1 file changed, 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index 8b23774f..479d57c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,3 @@ test/e2e/reports
 selenium-debug.log
 .idea/
 config/local.json
-.gitignore
-config/local.example.json

From 8fa0331771812e67fa4aa045f2997c80bc72cde2 Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Mon, 14 Jun 2021 20:09:28 +0000
Subject: [PATCH 077/169] Add apply and reset themes to top of theme tab

---
 .../settings_modal/tabs/theme_tab/theme_tab.vue   | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index 548dc852..8d5a7bc6 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -1,6 +1,21 @@
 <template>
   <div class="theme-tab">
     <div class="presets-container">
+      <div class="apply-container">
+        <button
+          class="btn button-default submit"
+          :disabled="!themeValid"
+          @click="setCustomTheme"
+        >
+          {{ $t('general.apply') }}
+        </button>
+        <button
+          class="btn button-default"
+          @click="clearAll"
+        >
+          {{ $t('settings.style.switcher.reset') }}
+        </button>
+      </div><br>
       <div class="save-load">
         <div
           v-if="themeWarning"

From b88e6b8ab074e97659b9096b7d5e14d2005b494c Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Mon, 14 Jun 2021 20:11:57 +0000
Subject: [PATCH 078/169] Update CHANGELOG.md

---
 CHANGELOG.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 905d9f65..7a18c51c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Added option to mark posts as sensitive by default
 - Added quick filters for notifications
 - Implemented user option to change sidebar position to the right side
+- Added Apply and Reset buttons to top of theme tab to minimize UI travel
 
 
 ## [2.3.0] - 2021-03-01

From 4639e30cb8e6086a57acbe35d864e8b453e197ba Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Mon, 14 Jun 2021 20:41:34 +0000
Subject: [PATCH 079/169] Fix config naming for consistency

---
 src/components/settings_modal/tabs/general_tab.vue | 4 ++--
 src/i18n/en.json                                   | 2 +-
 src/modules/config.js                              | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 4c09d047..44f6fa3e 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -118,8 +118,8 @@
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="showNewPostButton">
-            {{ $t('settings.showPostButton') }}
+          <BooleanSetting path="alwaysShowNewPostButton">
+            {{ $t('settings.always_show_post_button') }}
           </BooleanSetting>
         </li>
         <li>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 26f51cea..0ee871de 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -349,7 +349,7 @@
     "max_thumbnails": "Maximum amount of thumbnails per post",
     "hide_isp": "Hide instance-specific panel",
     "right_sidebar": "Show sidebar on the right side",
-    "showPostButton": "Always show floating New Post button",
+    "always_show_post_button": "Always show floating New Post button",
     "hide_wallpaper": "Hide instance wallpaper",
     "preload_images": "Preload images",
     "use_one_click_nsfw": "Open NSFW attachments with just one click",
diff --git a/src/modules/config.js b/src/modules/config.js
index 30c77a7b..28ee651b 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -34,7 +34,7 @@ export const defaultState = {
   loopVideoSilentOnly: true,
   streaming: false,
   emojiReactionsOnTimeline: true,
-  showNewPostButton: false,
+  alwaysShowNewPostButton: false,
   autohideFloatingPostButton: false,
   pauseOnUnfocused: true,
   stopGifs: false,

From 312a237ca4ea3add8b2859b93c80ee4265578fce Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Mon, 14 Jun 2021 23:31:16 +0000
Subject: [PATCH 080/169] Revert duplicate buttons and move existing buttons to
 bottom-right corner independent of scroll

---
 .../settings_modal/tabs/theme_tab/theme_tab.scss  |  3 +++
 .../settings_modal/tabs/theme_tab/theme_tab.vue   | 15 ---------------
 2 files changed, 3 insertions(+), 15 deletions(-)

diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
index 1b7d9f31..0db21537 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.scss
@@ -270,6 +270,9 @@
 
   .apply-container {
     justify-content: center;
+    position: absolute;
+    bottom: 8px;
+    right: 5px;
   }
 
   .radius-item,
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index 8d5a7bc6..548dc852 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -1,21 +1,6 @@
 <template>
   <div class="theme-tab">
     <div class="presets-container">
-      <div class="apply-container">
-        <button
-          class="btn button-default submit"
-          :disabled="!themeValid"
-          @click="setCustomTheme"
-        >
-          {{ $t('general.apply') }}
-        </button>
-        <button
-          class="btn button-default"
-          @click="clearAll"
-        >
-          {{ $t('settings.style.switcher.reset') }}
-        </button>
-      </div><br>
       <div class="save-load">
         <div
           v-if="themeWarning"

From e67f2954978b229fd703c0ce8fe8ca6a0947ad8e Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Mon, 14 Jun 2021 23:32:18 +0000
Subject: [PATCH 081/169] Update CHANGELOG.md

---
 CHANGELOG.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a18c51c..38d54f84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Added option to mark posts as sensitive by default
 - Added quick filters for notifications
 - Implemented user option to change sidebar position to the right side
-- Added Apply and Reset buttons to top of theme tab to minimize UI travel
+- Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
 
 
 ## [2.3.0] - 2021-03-01

From 4aac0125e591c6f2ef51bad475e79802be51f068 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 15 Jun 2021 14:43:44 +0300
Subject: [PATCH 082/169] fixed bug with hashtags

---
 src/components/rich_content/rich_content.jsx  | 33 ++++++++++-------
 .../specs/components/rich_content.spec.js     | 36 +++++++++++++++++++
 2 files changed, 57 insertions(+), 12 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 4144d895..c8e1af9c 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -85,7 +85,6 @@ export default Vue.component('RichContent', {
       attrs.target = '_blank'
       if (!encounteredTextReverse) {
         lastTags.push(linkData)
-        attrs['data-parser-last'] = true
       }
       return <a {...{ attrs }}>
         { children.map(processItem) }
@@ -128,7 +127,7 @@ export default Vue.component('RichContent', {
           encounteredText = true
         }
         if (item.includes(':')) {
-          unescapedItem = processTextForEmoji(
+          unescapedItem = ['', processTextForEmoji(
             unescapedItem,
             this.emoji,
             ({ shortcode, url }) => {
@@ -139,14 +138,14 @@ export default Vue.component('RichContent', {
                 alt={`:${shortcode}:`}
               />
             }
-          )
+          )]
         }
         return unescapedItem
       }
 
       // Handle tag nodes
       if (Array.isArray(item)) {
-        const [opener, children] = item
+        const [opener, children, closer] = item
         const Tag = getTagName(opener)
         const attrs = getAttrs(opener)
         switch (Tag) {
@@ -176,14 +175,10 @@ export default Vue.component('RichContent', {
               </a>
             }
         }
-
-        // Render tag as is
         if (children !== undefined) {
-          return <Tag {...{ attrs: getAttrs(opener) }}>
-            { children.map(processItem) }
-          </Tag>
+          return [opener, children.map(processItem), closer]
         } else {
-          return <Tag/>
+          return item
         }
       }
     }
@@ -200,7 +195,7 @@ export default Vue.component('RichContent', {
       } else if (Array.isArray(item)) {
         // Handle tag nodes
         const [opener, children] = item
-        const Tag = getTagName(opener)
+        const Tag = opener === '' ? '' : getTagName(opener)
         switch (Tag) {
           case 'a': // replace mentions with MentionLink
             if (!this.handleLinks) break
@@ -209,16 +204,30 @@ export default Vue.component('RichContent', {
             if (attrs['class'] && attrs['class'].includes('hashtag')) {
               return renderHashtag(attrs, children, encounteredTextReverse)
             }
+            break
+          case '':
+            return [...children].reverse().map(processItemReverse).reverse()
+        }
+
+        // Render tag as is
+        if (children !== undefined) {
+          return <Tag {...{ attrs: getAttrs(opener) }}>
+            { Array.isArray(children) ? [...children].reverse().map(processItemReverse).reverse() : children }
+          </Tag>
+        } else {
+          return <Tag/>
         }
       }
       return item
     }
 
+    const pass1 = convertHtmlToTree(html).map(processItem)
+    const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
     // 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">
-      { convertHtmlToTree(html).map(processItem).reverse().map(processItemReverse).reverse() }
+      { pass2 }
     </span>
 
     const event = {
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index e364a5b3..82f1ae89 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -603,4 +603,40 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
+
+  it('buggy example/hashtags', () => {
+    const html = [
+      '<p>',
+      '<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg">',
+      'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
+      ' <a class="hashtag" data-tag="nou" href="https://shitposter.club/tag/nou">',
+      '#nou</a>',
+      ' <a class="hashtag" data-tag="screencap" href="https://shitposter.club/tag/screencap">',
+      '#screencap</a>',
+      ' </p>'
+    ].join('')
+    const expected = [
+      '<p>',
+      '<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg" target="_blank">',
+      'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
+      ' <a class="hashtag" data-tag="nou" href="https://shitposter.club/tag/nou" target="_blank">',
+      '#nou</a>',
+      ' <a class="hashtag" data-tag="screencap" href="https://shitposter.club/tag/screencap" target="_blank">',
+      '#screencap</a>',
+      ' </p>'
+    ].join('')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        hideMentions: true,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
 })

From ad3a2fd4e5a7811107790cfba0cd83e33d2f4115 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 16 Jun 2021 01:20:20 +0300
Subject: [PATCH 083/169] fixed "invisible" spans inside links

---
 src/components/rich_content/rich_content.jsx  | 26 +++++++----
 .../specs/components/rich_content.spec.js     | 44 +++++++++++++++++++
 2 files changed, 62 insertions(+), 8 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index c8e1af9c..ce562f13 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -164,17 +164,15 @@ export default Vue.component('RichContent', {
           case 'a': // replace mentions with MentionLink
             if (!this.handleLinks) break
             if (attrs['class'] && attrs['class'].includes('mention')) {
+              // Handling mentions here
               return renderMention(attrs, children, encounteredText)
-            } else if (attrs['class'] && attrs['class'].includes('hashtag')) {
+            } else {
+              // Everything else will be handled in reverse pass
               encounteredText = true
               return item // We'll handle it later
-            } else {
-              attrs.target = '_blank'
-              return <a {...{ attrs }}>
-                { children.map(processItem) }
-              </a>
             }
         }
+
         if (children !== undefined) {
           return [opener, children.map(processItem), closer]
         } else {
@@ -203,16 +201,28 @@ export default Vue.component('RichContent', {
             // should only be this
             if (attrs['class'] && attrs['class'].includes('hashtag')) {
               return renderHashtag(attrs, children, encounteredTextReverse)
+            } else {
+              attrs.target = '_blank'
+              html.includes('freenode') && console.log('PASS1', children)
+              const newChildren = [...children].reverse().map(processItemReverse).reverse()
+              html.includes('freenode') && console.log('PASS1b', newChildren)
+
+              return <a {...{ attrs }}>
+                { newChildren }
+              </a>
             }
-            break
           case '':
             return [...children].reverse().map(processItemReverse).reverse()
         }
 
         // Render tag as is
         if (children !== undefined) {
+          html.includes('freenode') && console.log('PASS2', children)
+          const newChildren = Array.isArray(children)
+            ? [...children].reverse().map(processItemReverse).reverse()
+            : children
           return <Tag {...{ attrs: getAttrs(opener) }}>
-            { Array.isArray(children) ? [...children].reverse().map(processItemReverse).reverse() : children }
+            { newChildren }
           </Tag>
         } else {
           return <Tag/>
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index 82f1ae89..be51bbd1 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -639,4 +639,48 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
+
+  it('contents of a link', () => {
+    const html = [
+      '<p>',
+      'Freenode is dead.</p>',
+      '<p>',
+      '<a href="https://isfreenodedeadyet.com/">',
+      '<span>',
+      'https://</span>',
+      '<span>',
+      'isfreenodedeadyet.com/</span>',
+      '<span>',
+      '</span>',
+      '</a>',
+      '</p>'
+    ].join('')
+    const expected = [
+      '<p>',
+      'Freenode is dead.</p>',
+      '<p>',
+      '<a href="https://isfreenodedeadyet.com/" target="_blank">',
+      '<span>',
+      'https://</span>',
+      '<span>',
+      'isfreenodedeadyet.com/</span>',
+      '<span>',
+      '</span>',
+      '</a>',
+      '</p>'
+    ].join('')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        hideMentions: false,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
 })

From 25bf28f051f2adc22bdac82b7f1ab5c3fc003a39 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 16 Jun 2021 01:44:29 +0300
Subject: [PATCH 084/169] added tests just in case

---
 .../specs/components/rich_content.spec.js     | 104 +++++++++++++++++-
 1 file changed, 102 insertions(+), 2 deletions(-)

diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index be51bbd1..96c480ea 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -1,4 +1,4 @@
-import { shallowMount, createLocalVue } from '@vue/test-utils'
+import { mount, shallowMount, createLocalVue } from '@vue/test-utils'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 
 const localVue = createLocalVue()
@@ -640,7 +640,107 @@ describe('RichContent', () => {
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
 
-  it('contents of a link', () => {
+  it('rich contents of a mention are handled properly', () => {
+    const html = [
+      p(
+        'Testing'
+      ),
+      p(
+        '<a href="lol" class="mention">',
+        '<span>',
+        'https://</span>',
+        '<span>',
+        'lol.tld/</span>',
+        '<span>',
+        '</span>',
+        '</a>'
+      )
+    ].join('')
+    const expected = [
+      p(
+        'Testing'
+      ),
+      p(
+        '<mentionlink-stub url="lol" content="',
+        '<span>',
+        'https://</span>',
+        '<span>',
+        'lol.tld/</span>',
+        '<span>',
+        '</span>',
+        '">',
+        '</mentionlink-stub>'
+      )
+    ].join('')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        hideMentions: false,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('rich contents of a mention in beginning are handled properly', () => {
+    const html = [
+      p(
+        '<a href="lol" class="mention">',
+        '<span>',
+        'https://</span>',
+        '<span>',
+        'lol.tld/</span>',
+        '<span>',
+        '</span>',
+        '</a>'
+      ),
+      p(
+        'Testing'
+      )
+    ].join('')
+    const expected = [
+      p(
+        '<span class="MentionsLine">',
+        '<mentionlink-stub content="',
+        '<span>',
+        'https://</span>',
+        '<span>',
+        'lol.tld/</span>',
+        '<span>',
+        '</span>',
+        '" url="lol" class="mention-link">',
+        '</mentionlink-stub>',
+        '<!---->', // v-if placeholder
+        '</span>'
+      ),
+      p(
+        'Testing'
+      )
+    ].join('')
+
+    const wrapper = mount(RichContent, {
+      localVue,
+      stubs: {
+        MentionLink: true
+      },
+      propsData: {
+        hideMentions: false,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('rich contents of a link are handled properly', () => {
     const html = [
       '<p>',
       'Freenode is dead.</p>',

From 1717a3aaf2fb104797669ecddbd4418dca10c0cd Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 16 Jun 2021 12:44:04 +0300
Subject: [PATCH 085/169] fix chats again

---
 src/components/chat_message/chat_message.scss | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index de6990b6..fcfa7c8a 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -91,7 +91,7 @@
   .without-attachment {
     .message-content {
       // TODO figure out how to do it properly
-      .rich-content-wrapper::after {
+      .RichContent::after {
         margin-right: 5.4em;
         content: " ";
         display: inline-block;

From e1361a1caef3aa9d1faaeb420b03c5400a44c943 Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Thu, 17 Jun 2021 19:29:58 +0000
Subject: [PATCH 086/169] Add edit profile button

---
 CHANGELOG.md                           |  1 +
 CONTRIBUTORS.md                        |  1 +
 src/components/user_card/user_card.js  |  9 +++++++--
 src/components/user_card/user_card.vue | 14 +++++++++++++-
 src/i18n/en.json                       |  1 +
 5 files changed, 23 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 905d9f65..39ae2f94 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Added option to mark posts as sensitive by default
 - Added quick filters for notifications
 - Implemented user option to change sidebar position to the right side
+- Implemented "edit profile" button if viewing own profile which opens profile settings
 
 
 ## [2.3.0] - 2021-03-01
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index d7c217ce..f666a4ef 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -3,6 +3,7 @@ Contributors of this project.
 - Constance Variable (lambadalambda@social.heldscal.la): Code
 - Coco Snuss (cocosnuss@social.heldscal.la): Code
 - wakarimasen (wakarimasen@shitposter.club): NSFW hiding image
+- eris (eris@disqordia.space): Code
 - dtluna (dtluna@social.heldscal.la): Code
 - sonyam (sonyam@social.heldscal.la): Background images
 - hakui (hakui@freezepeach.xyz): CSS and styling
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index d9fb64d1..367fbc6c 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -12,14 +12,16 @@ import {
   faBell,
   faRss,
   faSearchPlus,
-  faExternalLinkAlt
+  faExternalLinkAlt,
+  faEdit
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
   faRss,
   faBell,
   faSearchPlus,
-  faExternalLinkAlt
+  faExternalLinkAlt,
+  faEdit
 )
 
 export default {
@@ -153,6 +155,9 @@ export default {
         this.$store.state.instance.restrictedNicknames
       )
     },
+    openProfileTab () {
+      this.$store.dispatch('openSettingsModalTab', 'profile')
+    },
     zoomAvatar () {
       const attachment = {
         url: this.user.profile_image_url_original,
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index a16f7873..972b72d2 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -53,6 +53,18 @@
               >
                 {{ user.name }}
               </div>
+              <button
+                v-if="!isOtherUser && user.is_local"
+                class="button-unstyled edit-profile-button"
+                @click.stop="openProfileTab"
+              >
+                <FAIcon
+                  fixed-width
+                  class="icon"
+                  icon="edit"
+                  :title="$t('user_card.edit_profile')"
+                />
+              </button>
               <button
                 v-if="isOtherUser && !user.is_local"
                 :href="user.statusnet_profile_url"
@@ -426,7 +438,7 @@
     }
   }
 
-  .external-link-button {
+  .external-link-button, .edit-profile-button {
     cursor: pointer;
     width: 2.5em;
     text-align: center;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index aa440ac1..1b20b9a2 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -718,6 +718,7 @@
     "block": "Block",
     "blocked": "Blocked!",
     "deny": "Deny",
+    "edit_profile": "Edit profile",
     "favorites": "Favorites",
     "follow": "Follow",
     "follow_sent": "Request sent!",

From 85e2f8f78c55c3bda7733205be49c93cb34d96d2 Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Thu, 17 Jun 2021 20:57:23 +0000
Subject: [PATCH 087/169] Don't show profile edit button in sidebar

---
 src/components/user_card/user_card.vue | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 972b72d2..528b92fb 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -590,6 +590,10 @@
   }
 }
 
+.sidebar .edit-profile-button {
+  display: none;
+}
+
 .user-counts {
   display: flex;
   line-height:16px;

From 8fe4355a6b84ae81b54228e6749a4ab82966ff2e Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 18 Jun 2021 21:29:47 +0300
Subject: [PATCH 088/169] fix rich images

---
 src/services/html_converter/html_line_converter.service.js | 2 ++
 .../services/html_converter/html_line_converter.spec.js    | 7 +++++++
 2 files changed, 9 insertions(+)

diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js
index f43d162a..74103b02 100644
--- a/src/services/html_converter/html_line_converter.service.js
+++ b/src/services/html_converter/html_line_converter.service.js
@@ -114,6 +114,8 @@ export const convertHtmlToLines = (html) => {
           } else {
             handleOpen(tagFull)
           }
+        } else {
+          textBuffer += tagFull
         }
       } else {
         textBuffer += tagFull
diff --git a/test/unit/specs/services/html_converter/html_line_converter.spec.js b/test/unit/specs/services/html_converter/html_line_converter.spec.js
index de7c7fc2..86bd7e8b 100644
--- a/test/unit/specs/services/html_converter/html_line_converter.spec.js
+++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js
@@ -69,6 +69,13 @@ describe('html_line_converter', () => {
       const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
       expect(comparableResult).to.eql(inputOutput)
     })
+
+    it('fed with some recognized but not handled elements', () => {
+      const inputOutput = 'testing images\n\n<img src="benis.png">'
+      const result = convertHtmlToLines(inputOutput)
+      const comparableResult = result.map(mapOnlyText(processorKeep)).join('')
+      expect(comparableResult).to.eql(inputOutput)
+    })
   })
   describe('with processor that replaces lines with word "_" should match expected line when', () => {
     const processorReplace = (line) => '_'

From c6831a381033fa160fba95fa88b1179d3c670d9d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 18 Jun 2021 21:42:46 +0300
Subject: [PATCH 089/169] fix not escaping some stuff

---
 src/components/rich_content/rich_content.jsx  | 13 ++++-------
 .../specs/components/rich_content.spec.js     | 23 +++++++++++++++++++
 2 files changed, 28 insertions(+), 8 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index ce562f13..79acd1de 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -121,14 +121,13 @@ export default Vue.component('RichContent', {
         if (emptyText) {
           return encounteredText ? item : item.trim()
         }
-        let unescapedItem = unescape(item)
         if (!encounteredText) {
-          unescapedItem = unescapedItem.trimStart()
+          item = item.trimStart()
           encounteredText = true
         }
         if (item.includes(':')) {
-          unescapedItem = ['', processTextForEmoji(
-            unescapedItem,
+          item = ['', processTextForEmoji(
+            item,
             this.emoji,
             ({ shortcode, url }) => {
               return <StillImage
@@ -140,7 +139,7 @@ export default Vue.component('RichContent', {
             }
           )]
         }
-        return unescapedItem
+        return item
       }
 
       // Handle tag nodes
@@ -189,7 +188,7 @@ export default Vue.component('RichContent', {
         const emptyText = item.trim() === ''
         if (emptyText) return item
         if (!encounteredTextReverse) encounteredTextReverse = true
-        return item
+        return unescape(item)
       } else if (Array.isArray(item)) {
         // Handle tag nodes
         const [opener, children] = item
@@ -203,9 +202,7 @@ export default Vue.component('RichContent', {
               return renderHashtag(attrs, children, encounteredTextReverse)
             } else {
               attrs.target = '_blank'
-              html.includes('freenode') && console.log('PASS1', children)
               const newChildren = [...children].reverse().map(processItemReverse).reverse()
-              html.includes('freenode') && console.log('PASS1b', newChildren)
 
               return <a {...{ attrs }}>
                 { newChildren }
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index 96c480ea..fbf8973d 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -27,6 +27,29 @@ describe('RichContent', () => {
     expect(wrapper.html()).to.eql(compwrap(html))
   })
 
+  it('unescapes everything as needed', () => {
+    const html = [
+      p('Testing &#39;em all'),
+      'Testing &#39;em all'
+    ].join('')
+    const expected = [
+      p('Testing \'em all'),
+      'Testing \'em all'
+    ].join('')
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        hideMentions: true,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
   it('removes mentions from the beginning of post', () => {
     const html = p(
       makeMention('John'),

From cd9dd352e34314d0af28f69289fc46c85f298a36 Mon Sep 17 00:00:00 2001
From: Eris <femmediscord@gmail.com>
Date: Sun, 20 Jun 2021 06:27:32 +0000
Subject: [PATCH 090/169] Fix follow request count position on mobile

---
 CHANGELOG.md                               | 2 ++
 src/components/side_drawer/side_drawer.vue | 4 +---
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec9cfde8..ab689c77 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Implemented user option to change sidebar position to the right side
 - Implemented user option to hide floating shout panel
 
+### Fixed
+- Fixed follow request count showing in the wrong location in mobile view
 
 ## [2.3.0] - 2021-03-01
 ### Fixed
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index 223b1632..575052be 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -273,9 +273,7 @@
   --icon: var(--popoverIcon, $fallback--icon);
 
   .badge {
-    position: absolute;
-    right: 0.7rem;
-    top: 1em;
+    margin-left: 10px;
   }
 }
 

From a258182522e85c31fe9dfbfbddf7a314ca36d0ca Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 22 Jun 2021 20:16:26 +0300
Subject: [PATCH 091/169] fix non-notifying mentions and original mention
 display

---
 src/components/mention_link/mention_link.vue |  3 ++-
 src/components/rich_content/rich_content.jsx | 12 ++++++++++--
 src/components/status_body/status_body.js    | 14 ++++++++++++++
 src/components/status_body/status_body.vue   |  3 ++-
 4 files changed, 28 insertions(+), 4 deletions(-)

diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index e4d395fa..514b7475 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -5,8 +5,9 @@
     <!-- eslint-disable vue/no-v-html -->
     <a
       v-if="!user"
-      href="url"
+      :href="url"
       class="original"
+      target="_blank"
       v-html="content"
     />
     <!-- eslint-enable vue/no-v-html -->
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 79acd1de..cd73f2e5 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -36,6 +36,10 @@ export default Vue.component('RichContent', {
       required: true,
       type: String
     },
+    attentions: {
+      required: false,
+      default: () => []
+    },
     // Emoji object, as in status.emojis, note the "s" at the end...
     emoji: {
       required: true,
@@ -91,8 +95,12 @@ export default Vue.component('RichContent', {
       </a>
     }
 
-    const renderMention = (attrs, children, encounteredText) => {
+    const renderMention = (attrs, children) => {
       const linkData = getLinkData(attrs, children, mentionIndex++)
+      linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
+      if (!linkData.notifying) {
+        encounteredText = true
+      }
       writtenMentions.push(linkData)
       if (!encounteredText) {
         firstMentions.push(linkData)
@@ -148,7 +156,7 @@ export default Vue.component('RichContent', {
         const Tag = getTagName(opener)
         const attrs = getAttrs(opener)
         switch (Tag) {
-          case 'span': // replace images with StillImage
+          case 'span': // Replace last mentions class with mentionsline
             if (attrs['class'] && attrs['class'].includes('lastMentions')) {
               if (firstMentions.length > 1 && lastMentions.length > 1) {
                 break
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 601c963b..d1736ba3 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -85,6 +85,20 @@ const StatusContent = {
     })
   },
   methods: {
+    onParseReady (event) {
+      this.$emit('parseReady', event)
+      const { writtenMentions } = event
+      writtenMentions
+        .filter(mention => !mention.notifying)
+        .forEach(mention => {
+          const { content, url } = mention
+          const cleanedString = content.replace(/<[^>]+?>/gi, '') // remove all tags
+          if (!cleanedString.startsWith('@')) return
+          const handle = cleanedString.slice(1)
+          const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '')
+          this.$store.dispatch('fetchUserIfMissing', `${handle}@${host}`)
+        })
+    },
     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 2be46303..d77ccd54 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -47,7 +47,8 @@
           :handle-links="true"
           :hide-mentions="hideMentions"
           :greentext="mergedConfig.greentext"
-          @parseReady="$emit('parseReady', $event)"
+          :attentions="status.attentions"
+          @parseReady="onParseReady"
         />
 
         <button

From a0eaac2216e7f279e10c6b688b9fa81f7a4fad2d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 22 Jun 2021 21:09:29 +0300
Subject: [PATCH 092/169] fix tests

---
 .../specs/components/rich_content.spec.js     | 32 ++++++++++++++++++-
 1 file changed, 31 insertions(+), 1 deletion(-)

diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index fbf8973d..9ac9bedb 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -2,8 +2,12 @@ import { mount, shallowMount, createLocalVue } from '@vue/test-utils'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 
 const localVue = createLocalVue()
+const attentions = []
 
-const makeMention = (who) => `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
+const makeMention = (who) => {
+  attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` })
+  return `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
+}
 const stubMention = (who) => `<span class="h-card"><mentionlink-stub url="https://fake.tld/@${who}" content="@<span>${who}</span>"></mentionlink-stub></span>`
 const lastMentions = (...data) => `<span class="lastMentions">${data.join('')}</span>`
 const p = (...data) => `<p>${data.join('')}</p>`
@@ -16,6 +20,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -39,6 +44,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -62,6 +68,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -89,6 +96,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: false,
         handleLinks: true,
         greentext: true,
@@ -120,6 +128,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -157,6 +166,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: false,
         handleLinks: true,
         greentext: true,
@@ -186,6 +196,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -215,6 +226,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -257,6 +269,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -292,6 +305,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -334,6 +348,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -362,6 +377,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: false,
         greentext: true,
@@ -386,6 +402,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: false,
         greentext: true,
@@ -406,6 +423,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: false,
         greentext: false,
@@ -427,6 +445,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: false,
         greentext: false,
@@ -444,6 +463,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: false,
         greentext: false,
@@ -472,6 +492,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -507,6 +528,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -537,6 +559,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -580,6 +603,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -616,6 +640,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -652,6 +677,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: true,
         handleLinks: true,
         greentext: true,
@@ -699,6 +725,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: false,
         handleLinks: true,
         greentext: true,
@@ -711,6 +738,7 @@ describe('RichContent', () => {
   })
 
   it('rich contents of a mention in beginning are handled properly', () => {
+    attentions.push({ statusnet_profile_url: 'lol' })
     const html = [
       p(
         '<a href="lol" class="mention">',
@@ -752,6 +780,7 @@ describe('RichContent', () => {
         MentionLink: true
       },
       propsData: {
+        attentions,
         hideMentions: false,
         handleLinks: true,
         greentext: true,
@@ -796,6 +825,7 @@ describe('RichContent', () => {
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
+        attentions,
         hideMentions: false,
         handleLinks: true,
         greentext: true,

From ba961b784f68a2d82eec0ea466f3ff7e6b997943 Mon Sep 17 00:00:00 2001
From: HJ <30-hj@users.noreply.git.pleroma.social>
Date: Mon, 19 Jul 2021 17:10:13 +0000
Subject: [PATCH 093/169] Apply 1 suggestion(s) to 1 file(s)

---
 src/components/settings_modal/tabs/theme_tab/theme_tab.js | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 49b548fb..8b81db5d 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -757,7 +757,6 @@ export default {
     },
     selectedTheme () {
       this.dismissWarning()
-      console.log(this.selectedVersion)
       if (this.selectedVersion === 1) {
         if (!this.keepRoundness) {
           this.clearRoundness()

From ef277ae4e272d2f30dacc6e5d447d8b655538a67 Mon Sep 17 00:00:00 2001
From: Tusooa Zhu <tusooa@kazv.moe>
Date: Tue, 3 Aug 2021 19:58:24 -0400
Subject: [PATCH 094/169] Fix mobile shoutbox

---
 src/components/side_drawer/side_drawer.js  | 1 +
 src/components/side_drawer/side_drawer.vue | 4 ++--
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 0faf3b9e..89719df3 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -49,6 +49,7 @@ const SideDrawer = {
     currentUser () {
       return this.$store.state.users.currentUser
     },
+    shout () { return this.$store.state.shout.channel.state === 'joined' },
     unseenNotifications () {
       return unseenNotificationsFromStore(this.$store)
     },
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index 575052be..dd88de7d 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -106,10 +106,10 @@
           </router-link>
         </li>
         <li
-          v-if="chat"
+          v-if="shout"
           @click="toggleDrawer"
         >
-          <router-link :to="{ name: 'chat-panel' }">
+          <router-link :to="{ name: 'shout-panel' }">
             <FAIcon
               fixed-width
               class="fa-scale-110 fa-old-padding"

From 435f80133aee4f83098b0b65b3cac27e41c716f8 Mon Sep 17 00:00:00 2001
From: Shpuld Shpludson <shp@cock.li>
Date: Sun, 8 Aug 2021 12:38:51 +0000
Subject: [PATCH 095/169] Update CHANGELOG.md for 2.4.0

---
 CHANGELOG.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbadc601..0420db5b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
-## [Unreleased]
+## [2.4.0] - 2021-08-08
 ### Added
 - Added a quick settings to timeline header for easier access
 - Added option to mark posts as sensitive by default

From 2cfff1b8b9c642e9284483d6658312d9a3763417 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 12 Aug 2021 02:49:37 +0300
Subject: [PATCH 096/169] remove new options for style and separate line, now
 groups all chained mentions on a mentionsline regardless of placement. fixes
 spacing

---
 src/components/mention_link/mention_link.js   |   6 +-
 src/components/mention_link/mention_link.scss |  41 --
 src/components/mention_link/mention_link.vue  |   3 +-
 src/components/mentions_line/mentions_line.js |  15 +-
 .../mentions_line/mentions_line.scss          |  15 +-
 .../mentions_line/mentions_line.vue           |   6 +-
 src/components/rich_content/rich_content.jsx  | 128 ++----
 .../settings_modal/tabs/general_tab.vue       |  10 -
 src/components/status/status.js               |   5 +-
 src/components/status/status.vue              |   1 -
 src/components/status_body/status_body.js     |   3 +-
 src/components/status_body/status_body.vue    |   1 -
 .../status_content/status_content.js          |   3 +-
 .../status_content/status_content.vue         |   1 -
 src/modules/config.js                         |   2 -
 .../specs/components/rich_content.spec.js     | 399 +-----------------
 16 files changed, 55 insertions(+), 584 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index eec116db..a60a8040 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -65,9 +65,6 @@ const MentionLink = {
     highlightClass () {
       if (this.highlight) return highlightClass(this.user)
     },
-    oldStyle () {
-      return !this.mergedConfig.mentionsNewStyle
-    },
     style () {
       if (this.highlight) {
         const {
@@ -83,8 +80,7 @@ const MentionLink = {
       return [
         {
           '-you': this.isYou,
-          '-highlighted': this.highlight,
-          '-oldStyle': this.oldStyle
+          '-highlighted': this.highlight
         },
         this.highlightType
       ]
diff --git a/src/components/mention_link/mention_link.scss b/src/components/mention_link/mention_link.scss
index 5f5da98f..ec2689f8 100644
--- a/src/components/mention_link/mention_link.scss
+++ b/src/components/mention_link/mention_link.scss
@@ -10,10 +10,6 @@
     border-radius: 2px;
   }
 
-  .original {
-    margin-right: 0.25em;
-  }
-
   .full {
     position: absolute;
     display: inline-block;
@@ -41,8 +37,6 @@
   }
 
   .new {
-    margin-right: 0.25em;
-
     &.-you {
       & .shortName,
       & .full {
@@ -61,41 +55,6 @@
       margin: 0;
     }
 
-    &:not(.-oldStyle) {
-      .short {
-        padding-left: 0.25em;
-        padding-right: 0;
-        padding-top: 0;
-        padding-bottom: 0;
-        line-height: 1.5;
-        font-size: inherit;
-
-        .at {
-          color: var(--faint);
-          opacity: 0.8;
-          padding-right: 0.25em;
-          vertical-align: -20%;
-        }
-      }
-
-      .you {
-        padding-right: 0.25em;
-      }
-
-      .userName {
-        display: inline-block;
-        color: var(--link);
-        line-height: inherit;
-        margin-left: 0;
-        padding-left: 0.125em;
-        padding-right: 0.25em;
-        padding-top: 0;
-        padding-bottom: 0;
-        border-top-right-radius: var(--btnRadius);
-        border-bottom-right-radius: var(--btnRadius);
-      }
-    }
-
     &.-striped {
       & .userName,
       & .full {
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 514b7475..625eb727 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -18,8 +18,7 @@
       :class="classnames"
     >
       <button
-        class="short"
-        :class="[{ '-sublime': !highlight }, oldStyle ? 'button-unstyled' : 'button-default']"
+        class="short button-unstyled"
         @click.prevent="onClick"
       >
         <!-- eslint-disable vue/no-v-html -->
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
index e52045ec..33e25391 100644
--- a/src/components/mentions_line/mentions_line.js
+++ b/src/components/mentions_line/mentions_line.js
@@ -14,11 +14,8 @@ const MentionsLine = {
     MentionLink
   },
   computed: {
-    oldStyle () {
-      return !this.mergedConfig.mentionsNewStyle
-    },
     limit () {
-      return 6
+      return 2
     },
     mentionsComputed () {
       return this.mentions.slice(0, this.limit)
@@ -29,16 +26,6 @@ const MentionsLine = {
     manyMentions () {
       return this.extraMentions.length > 0
     },
-    buttonClasses () {
-      return [
-        this.oldStyle
-          ? 'button-unstyled'
-          : 'button-default -sublime',
-        this.oldStyle
-          ? '-oldStyle'
-          : '-newStyle'
-      ]
-    },
     ...mapGetters(['mergedConfig'])
   },
   methods: {
diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index 90d1e0a4..222940c8 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -1,17 +1,10 @@
 .MentionsLine {
   .showMoreLess {
     white-space: normal;
+    color: var(--link);
+  }
 
-    &.-newStyle {
-      line-height: 1.5;
-      font-size: inherit;
-      display: inline-block;
-      padding-top: 0;
-      padding-bottom: 0;
-    }
-
-    &.-oldStyle {
-      color: var(--link);
-    }
+  .mention-link:not(:last-child) {
+    margin-right: 0.25em;
   }
 }
diff --git a/src/components/mentions_line/mentions_line.vue b/src/components/mentions_line/mentions_line.vue
index f4b3abb9..f375e3b0 100644
--- a/src/components/mentions_line/mentions_line.vue
+++ b/src/components/mentions_line/mentions_line.vue
@@ -25,15 +25,13 @@
         />
       </span><button
         v-if="!expanded"
-        class="showMoreLess"
-        :class="buttonClasses"
+        class="button-unstyled showMoreLess"
         @click="toggleShowMore"
       >
         {{ $t('status.plus_more', { number: extraMentions.length }) }}
       </button><button
         v-if="expanded"
-        class="showMoreLess"
-        :class="buttonClasses"
+        class="button-unstyled showMoreLess"
         @click="toggleShowMore"
       >
         {{ $t('general.show_less') }}
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index cd73f2e5..4b7d4d37 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -56,25 +56,21 @@ export default Vue.component('RichContent', {
       required: false,
       type: Boolean,
       default: false
-    },
-    hideMentions: {
-      required: false,
-      type: Boolean,
-      default: false
     }
   },
   // NEVER EVER TOUCH DATA INSIDE RENDER
   render (h) {
     // Pre-process HTML
-    const { newHtml: html, lastMentions } = preProcessPerLine(this.html, this.greentext, this.handleLinks)
-    const firstMentions = [] // Mentions that appear in the beginning of post body
+    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
-    let firstMentionReplaced = false
 
     const renderImage = (tag) => {
       return <StillImage
@@ -98,41 +94,32 @@ export default Vue.component('RichContent', {
     const renderMention = (attrs, children) => {
       const linkData = getLinkData(attrs, children, mentionIndex++)
       linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
-      if (!linkData.notifying) {
-        encounteredText = true
-      }
       writtenMentions.push(linkData)
-      if (!encounteredText) {
-        firstMentions.push(linkData)
-        if (!firstMentionReplaced && !this.hideMentions) {
-          firstMentionReplaced = true
-          return <MentionsLine mentions={ firstMentions } />
-        } else {
-          return ''
-        }
+      if (currentMentions === null) {
+        currentMentions = []
+      }
+      currentMentions.push(linkData)
+      if (currentMentions.length === 1) {
+        return <MentionsLine mentions={ currentMentions } />
       } else {
-        return <MentionLink
-          url={attrs.href}
-          content={flattenDeep(children).join('')}
-        />
+        return ''
       }
     }
 
-    // We stop treating mentions as "first" ones when we encounter
-    // non-whitespace text
-    let encounteredText = false
     // Processor to use with html_tree_converter
     const processItem = (item, index, array, what) => {
       // Handle text nodes - just add emoji
       if (typeof item === 'string') {
         const emptyText = item.trim() === ''
+        if (item.includes('\n')) {
+          currentMentions = null
+        }
         if (emptyText) {
-          return encounteredText ? item : item.trim()
-        }
-        if (!encounteredText) {
-          item = item.trimStart()
-          encounteredText = true
+          // don't include spaces when processing mentions - we'll include them
+          // in MentionsLine
+          return currentMentions !== null ? item.trim() : item
         }
+        currentMentions = null
         if (item.includes(':')) {
           item = ['', processTextForEmoji(
             item,
@@ -156,28 +143,25 @@ export default Vue.component('RichContent', {
         const Tag = getTagName(opener)
         const attrs = getAttrs(opener)
         switch (Tag) {
-          case 'span': // Replace last mentions class with mentionsline
-            if (attrs['class'] && attrs['class'].includes('lastMentions')) {
-              if (firstMentions.length > 1 && lastMentions.length > 1) {
-                break
-              } else {
-                return !this.hideMentions ? <MentionsLine mentions={lastMentions} /> : ''
-              }
-            } else {
-              break
-            }
+          case 'br':
+            currentMentions = null
+            break
           case 'img': // replace images with StillImage
             return renderImage(opener)
           case 'a': // replace mentions with MentionLink
             if (!this.handleLinks) break
             if (attrs['class'] && attrs['class'].includes('mention')) {
               // Handling mentions here
-              return renderMention(attrs, children, encounteredText)
+              return renderMention(attrs, children)
             } else {
               // Everything else will be handled in reverse pass
-              encounteredText = true
+              currentMentions = null
               return item // We'll handle it later
             }
+          case 'span':
+            if (attrs['class'].includes('h-card')) {
+              return ['', children.map(processItem), '']
+            }
         }
 
         if (children !== undefined) {
@@ -246,8 +230,6 @@ export default Vue.component('RichContent', {
     </span>
 
     const event = {
-      firstMentions,
-      lastMentions,
       lastTags,
       writtenMentions,
       writtenTags
@@ -284,15 +266,12 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
   const lastMentions = []
   const greentextHandle = new Set(['p', 'div'])
 
-  let nonEmptyIndex = -1
   const lines = convertHtmlToLines(html)
-  const linesNum = lines.filter(c => c.text).length
   const newHtml = lines.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
-    nonEmptyIndex += 1
 
     // Greentext stuff
     if (
@@ -316,42 +295,19 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
     // Converting that line part into tree
     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 mentionsNum = 0
     const process = (item) => {
       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') {
-          if (!handleLinks) return [opener, children, closer]
-          const attrs = getAttrs(opener)
-          if (attrs['class'] && attrs['class'].includes('mention')) {
-            // Got mentions
-            mentionsNum++
-            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') {
+        if (tag === 'span' || tag === 'p') {
           // 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]
         }
       }
 
       if (typeof item === 'string') {
-        if (item.trim() !== '') {
-          // only meaningful strings are loose text
-          hasLooseText = true
-        }
         return item
       }
     }
@@ -359,33 +315,7 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
     // We now processed our tree, now we need to mark line as lastMentions
     const result = [...tree].map(process)
 
-    if (
-      handleLinks && // Do we handle links at all?
-        mentionsNum > 1 && // Does it have more than one mention?
-        !hasLooseText && // Don't do anything if it has something besides mentions
-        nonEmptyIndex === 0 && // Only check last (first since list is reversed) line
-        nonEmptyIndex !== linesNum - 1 // Don't do anything if there's only one line
-    ) {
-      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('')
-    }
+    return flattenDeep(result).join('')
   }).reverse().join('')
 
   return { newHtml, lastMentions }
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 71780e00..d3e71b31 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -41,16 +41,6 @@
             {{ $t('settings.collapse_subject') }}
           </BooleanSetting>
         </li>
-        <li>
-          <BooleanSetting path="mentionsOwnLine">
-            {{ $t('settings.mentions_new_place') }}
-          </BooleanSetting>
-        </li>
-        <li>
-          <BooleanSetting path="mentionsNewStyle">
-            {{ $t('settings.mentions_new_style') }}
-          </BooleanSetting>
-        </li>
         <li>
           <BooleanSetting path="streaming">
             {{ $t('settings.streaming') }}
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 3c21cb76..8aa73a22 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -184,10 +184,7 @@ const Status = {
       })
     },
     mentionsLine () {
-      return this.mentionsOwnLine ? this.mentions : this.alsoMentions
-    },
-    mentionsOwnLine () {
-      return this.mergedConfig.mentionsOwnLine
+      return this.alsoMentions
     },
     hasMentionsLine () {
       return this.mentionsLine.length > 0
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index a5f347a6..2684e415 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -306,7 +306,6 @@
             :no-heading="noHeading"
             :highlight="highlight"
             :focused="isFocused"
-            :hide-mentions="mentionsOwnLine && (isReply || true)"
             @mediaplay="addMediaPlaying($event)"
             @mediapause="removeMediaPlaying($event)"
             @parseReady="setHeadTailLinks"
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index d1736ba3..8757154d 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -25,8 +25,7 @@ const StatusContent = {
     'focused',
     'noHeading',
     'fullContent',
-    'singleLine',
-    'hideMentions'
+    'singleLine'
   ],
   data () {
     return {
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index d77ccd54..9f01c470 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -45,7 +45,6 @@
           :html="status.raw_html"
           :emoji="status.emojis"
           :handle-links="true"
-          :hide-mentions="hideMentions"
           :greentext="mergedConfig.greentext"
           :attentions="status.attentions"
           @parseReady="onParseReady"
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 51895ef6..1b80ee09 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -31,8 +31,7 @@ const StatusContent = {
     'focused',
     'noHeading',
     'fullContent',
-    'singleLine',
-    'hideMentions'
+    'singleLine'
   ],
   computed: {
     hideAttachments () {
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 2e71757d..23c90913 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -4,7 +4,6 @@
     <StatusBody
       :status="status"
       :single-line="singleLine"
-      :hide-mentions="hideMentions"
       @parseReady="$emit('parseReady', $event)"
     >
       <div v-if="status.poll && status.poll.options">
diff --git a/src/modules/config.js b/src/modules/config.js
index db9d5ffb..bdab3f4d 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -55,8 +55,6 @@ export const defaultState = {
   interfaceLanguage: browserLocale,
   hideScopeNotice: false,
   useStreamingApi: false,
-  mentionsOwnLine: false,
-  mentionsNewStyle: false,
   sidebarRight: undefined, // instance default
   scopeCopy: undefined, // instance default
   subjectLineBehavior: undefined, // instance default
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index 9ac9bedb..dfc229c0 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -8,11 +8,13 @@ const makeMention = (who) => {
   attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` })
   return `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
 }
-const stubMention = (who) => `<span class="h-card"><mentionlink-stub url="https://fake.tld/@${who}" content="@<span>${who}</span>"></mentionlink-stub></span>`
-const lastMentions = (...data) => `<span class="lastMentions">${data.join('')}</span>`
 const p = (...data) => `<p>${data.join('')}</p>`
 const compwrap = (...data) => `<span class="RichContent">${data.join('')}</span>`
-const removedMentionSpan = '<span class="h-card"></span>'
+const mentionsLine = (times) => [
+  '<mentionsline-stub mentions="',
+  new Array(times).fill('[object Object]').join(','),
+  '"></mentionsline-stub>'
+].join('')
 
 describe('RichContent', () => {
   it('renders simple post without exploding', () => {
@@ -21,7 +23,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -45,7 +46,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -56,20 +56,15 @@ describe('RichContent', () => {
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
 
-  it('removes mentions from the beginning of post', () => {
+  it('replaces first mention with mentionsline', () => {
     const html = p(
       makeMention('John'),
-      ' how are you doing thoday?'
-    )
-    const expected = p(
-      removedMentionSpan,
-      'how are you doing thoday?'
+      ' how are you doing today?'
     )
     const wrapper = shallowMount(RichContent, {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -77,70 +72,13 @@ describe('RichContent', () => {
       }
     })
 
-    expect(wrapper.html()).to.eql(compwrap(expected))
+    expect(wrapper.html()).to.eql(compwrap(p(
+      mentionsLine(1),
+      ' how are you doing today?'
+    )))
   })
 
-  it('replaces first mention with mentionsline if hideMentions=false', () => {
-    const html = p(
-      makeMention('John'),
-      ' how are you doing thoday?'
-    )
-    const expected = p(
-      '<span class="h-card">',
-      '<mentionsline-stub mentions="',
-      '[object Object]',
-      '"></mentionsline-stub>',
-      '</span>',
-      'how are you doing thoday?'
-    )
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: false,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('removes mentions from the end of the hellpost (<p>)', () => {
-    const html = [
-      p('How are you doing today, fine gentlemen?'),
-      p(
-        makeMention('John'),
-        makeMention('Josh'),
-        makeMention('Jeremy')
-      )
-    ].join('')
-    const expected = [
-      p(
-        'How are you doing today, fine gentlemen?'
-      ),
-      // TODO fix this extra line somehow?
-      p()
-    ].join('')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('replaces mentions at the end of the hellpost if hideMentions=false (<p>)', () => {
+  it('replaces mentions at the end of the hellpost', () => {
     const html = [
       p('How are you doing today, fine gentlemen?'),
       p(
@@ -167,189 +105,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: false,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('removes mentions from the end of the hellpost (<br>)', () => {
-    const html = [
-      'How are you doing today, fine gentlemen?',
-      [
-        makeMention('John'),
-        makeMention('Josh'),
-        makeMention('Jeremy')
-      ].join('')
-    ].join('<br>')
-    const expected = [
-      'How are you doing today, fine gentlemen?',
-      // TODO fix this extra line somehow?
-      '<br>'
-    ].join('')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('removes mentions from the end of the hellpost (\\n)', () => {
-    const html = [
-      'How are you doing today, fine gentlemen?',
-      [
-        makeMention('John'),
-        makeMention('Josh'),
-        makeMention('Jeremy')
-      ].join('')
-    ].join('\n')
-    const expected = [
-      'How are you doing today, fine gentlemen?',
-      // TODO fix this extra line somehow?
-      ''
-    ].join('\n')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('Does not remove mentions in the middle or at the end of text string', () => {
-    const html = [
-      [
-        makeMention('Jack'),
-        'let\'s meet up with ',
-        makeMention('Janet')
-      ].join(''),
-      [
-        'cc: ',
-        makeMention('John'),
-        makeMention('Josh'),
-        makeMention('Jeremy')
-      ].join('')
-    ].join('\n')
-    const expected = [
-      [
-        removedMentionSpan,
-        'let\'s meet up with ',
-        stubMention('Janet')
-      ].join(''),
-      [
-        'cc: ',
-        stubMention('John'),
-        stubMention('Josh'),
-        stubMention('Jeremy')
-      ].join('')
-    ].join('\n')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('removes mentions from the end if there\'s only one first mention', () => {
-    const html = [
-      p(
-        makeMention('Todd'),
-        'so anyway you are wrong'
-      ),
-      p(
-        makeMention('Tom'),
-        makeMention('Trace'),
-        makeMention('Theodor')
-      )
-    ].join('')
-    const expected = [
-      p(
-        removedMentionSpan,
-        'so anyway you are wrong'
-      ),
-      // TODO fix this extra line somehow?
-      p()
-    ].join('')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('does not remove mentions from the end if there\'s more than one first mention', () => {
-    const html = [
-      p(
-        makeMention('Zacharie'),
-        makeMention('Zinaide'),
-        'you guys have cool names, and so do these guys: '
-      ),
-      p(
-        makeMention('Watson'),
-        makeMention('Wallace'),
-        makeMention('Wakamoto')
-      )
-    ].join('')
-    const expected = [
-      p(
-        removedMentionSpan,
-        removedMentionSpan,
-        'you guys have cool names, and so do these guys: '
-      ),
-      p(
-        lastMentions(
-          stubMention('Watson'),
-          stubMention('Wallace'),
-          stubMention('Wakamoto')
-        )
-      )
-    ].join('')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -378,7 +133,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: false,
         greentext: true,
         emoji: [],
@@ -403,7 +157,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: false,
         greentext: true,
         emoji: [],
@@ -424,7 +177,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: false,
         greentext: false,
         emoji: [],
@@ -446,7 +198,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: false,
         greentext: false,
         emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
@@ -464,7 +215,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: false,
         greentext: false,
         emoji: [],
@@ -484,7 +234,7 @@ describe('RichContent', () => {
     ].join('\n')
     const expected = [
       '<span class="greentext">&gt;quote</span>',
-      stubMention('lol'),
+      mentionsLine(1)
       '<span class="greentext">&gt;quote</span>',
       '<span class="greentext">&gt;quote</span>'
     ].join('\n')
@@ -517,11 +267,7 @@ describe('RichContent', () => {
     const expected = [
       'Bruh',
       'Bruh',
-      [
-        stubMention('foo'),
-        stubMention('bar'),
-        stubMention('baz')
-      ].join(''),
+      mentionsLine(3)
       'Bruh'
     ].join('<br>')
 
@@ -529,119 +275,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('Don\'t remove last mention if it\'s the only one', () => {
-    const html = [
-      'Bruh',
-      'Bruh',
-      makeMention('foo'),
-      makeMention('bar'),
-      makeMention('baz')
-    ].join('<br>')
-    const expected = [
-      'Bruh',
-      'Bruh',
-      stubMention('foo'),
-      stubMention('bar'),
-      stubMention('baz')
-    ].join('<br>')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('Don\'t remove last mentions if there are more than one first mention - remove first instead', () => {
-    const html = [
-      [
-        makeMention('foo'),
-        makeMention('bar')
-      ].join(' '),
-      'Bruh',
-      'Bruh',
-      [
-        makeMention('foo'),
-        makeMention('bar'),
-        makeMention('baz')
-      ].join(' ')
-    ].join('\n')
-
-    const expected = [
-      [
-        removedMentionSpan,
-        removedMentionSpan,
-        'Bruh' // Due to trim we remove extra newline
-      ].join(''),
-      'Bruh',
-      lastMentions([
-        stubMention('foo'),
-        stubMention('bar'),
-        stubMention('baz')
-      ].join(' '))
-    ].join('\n')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('Remove last mentions if there\'s just one first mention - remove all', () => {
-    const html = [
-      [
-        makeMention('foo')
-      ].join(' '),
-      'Bruh',
-      'Bruh',
-      [
-        makeMention('foo'),
-        makeMention('bar'),
-        makeMention('baz')
-      ].join(' ')
-    ].join('\n')
-
-    const expected = [
-      [
-        removedMentionSpan,
-        'Bruh' // Due to trim we remove extra newline
-      ].join(''),
-      'Bruh\n' // Can't remove this one yet
-    ].join('\n')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -678,7 +311,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: true,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -726,7 +358,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: false,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -781,7 +412,6 @@ describe('RichContent', () => {
       },
       propsData: {
         attentions,
-        hideMentions: false,
         handleLinks: true,
         greentext: true,
         emoji: [],
@@ -826,7 +456,6 @@ describe('RichContent', () => {
       localVue,
       propsData: {
         attentions,
-        hideMentions: false,
         handleLinks: true,
         greentext: true,
         emoji: [],

From 2182af405864cffcc7ddca8d373cc14f38770c9e Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 12 Aug 2021 03:08:57 +0300
Subject: [PATCH 097/169] made the code responsible for showing unwritten
 mentions actually work

---
 src/components/status/status.js | 19 +++++++++++--------
 1 file changed, 11 insertions(+), 8 deletions(-)

diff --git a/src/components/status/status.js b/src/components/status/status.js
index 8aa73a22..54f304a2 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -176,15 +176,18 @@ const Status = {
         userId: attn.id
       }))
     },
-    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)
-      })
-    },
     mentionsLine () {
-      return this.alsoMentions
+      const writtenMentions = this.headTailLinks ? this.headTailLinks.writtenMentions : []
+      const set = new Set(writtenMentions.map(_ => _.url))
+      return this.status.attentions.filter(attn => {
+        return attn.screen_name !== this.replyToName &&
+          attn.screen_name !== this.status.user.screen_name &&
+          !set.has(attn.url)
+      }).map(attn => ({
+        url: attn.statusnet_profile_url,
+        content: attn.screen_name,
+        userId: attn.id
+      }))
     },
     hasMentionsLine () {
       return this.mentionsLine.length > 0

From add5921b8b3579b153bef6ee2b1916227016d200 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Thu, 12 Aug 2021 19:37:04 +0300
Subject: [PATCH 098/169] fix tests, add performance test (skipped, doesn't
 assert anything), tweak max mentions count

---
 src/components/mention_link/mention_link.js   |   2 +-
 src/components/mentions_line/mentions_line.js |   2 +-
 src/components/rich_content/rich_content.jsx  |   2 +-
 .../specs/components/rich_content.spec.js     | 126 ++++++++++--------
 4 files changed, 72 insertions(+), 60 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index a60a8040..4d27fe6d 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -41,7 +41,7 @@ const MentionLink = {
   },
   computed: {
     user () {
-      return this.url && this.$store.getters.findUserByUrl(this.url)
+      return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
     },
     isYou () {
       // FIXME why user !== currentUser???
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
index 33e25391..83eeea4c 100644
--- a/src/components/mentions_line/mentions_line.js
+++ b/src/components/mentions_line/mentions_line.js
@@ -15,7 +15,7 @@ const MentionsLine = {
   },
   computed: {
     limit () {
-      return 2
+      return 5
     },
     mentionsComputed () {
       return this.mentions.slice(0, this.limit)
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 4b7d4d37..32b737ec 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -159,7 +159,7 @@ export default Vue.component('RichContent', {
               return item // We'll handle it later
             }
           case 'span':
-            if (attrs['class'].includes('h-card')) {
+            if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
               return ['', children.map(processItem), '']
             }
         }
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index dfc229c0..b29edeab 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -56,7 +56,7 @@ describe('RichContent', () => {
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
 
-  it('replaces first mention with mentionsline', () => {
+  it('replaces mention with mentionsline', () => {
     const html = p(
       makeMention('John'),
       ' how are you doing today?'
@@ -234,7 +234,7 @@ describe('RichContent', () => {
     ].join('\n')
     const expected = [
       '<span class="greentext">&gt;quote</span>',
-      mentionsLine(1)
+      mentionsLine(1),
       '<span class="greentext">&gt;quote</span>',
       '<span class="greentext">&gt;quote</span>'
     ].join('\n')
@@ -267,7 +267,7 @@ describe('RichContent', () => {
     const expected = [
       'Bruh',
       'Bruh',
-      mentionsLine(3)
+      mentionsLine(3),
       'Bruh'
     ].join('<br>')
 
@@ -322,53 +322,6 @@ describe('RichContent', () => {
   })
 
   it('rich contents of a mention are handled properly', () => {
-    const html = [
-      p(
-        'Testing'
-      ),
-      p(
-        '<a href="lol" class="mention">',
-        '<span>',
-        'https://</span>',
-        '<span>',
-        'lol.tld/</span>',
-        '<span>',
-        '</span>',
-        '</a>'
-      )
-    ].join('')
-    const expected = [
-      p(
-        'Testing'
-      ),
-      p(
-        '<mentionlink-stub url="lol" content="',
-        '<span>',
-        'https://</span>',
-        '<span>',
-        'lol.tld/</span>',
-        '<span>',
-        '</span>',
-        '">',
-        '</mentionlink-stub>'
-      )
-    ].join('')
-
-    const wrapper = shallowMount(RichContent, {
-      localVue,
-      propsData: {
-        attentions,
-        handleLinks: true,
-        greentext: true,
-        emoji: [],
-        html
-      }
-    })
-
-    expect(wrapper.html()).to.eql(compwrap(expected))
-  })
-
-  it('rich contents of a mention in beginning are handled properly', () => {
     attentions.push({ statusnet_profile_url: 'lol' })
     const html = [
       p(
@@ -388,16 +341,19 @@ describe('RichContent', () => {
     const expected = [
       p(
         '<span class="MentionsLine">',
-        '<mentionlink-stub content="',
+        '<span class="MentionLink mention-link">',
+        '<a href="lol" target="_blank" class="original">',
         '<span>',
         'https://</span>',
         '<span>',
         'lol.tld/</span>',
         '<span>',
         '</span>',
-        '" url="lol" class="mention-link">',
-        '</mentionlink-stub>',
-        '<!---->', // v-if placeholder
+        '</a>',
+        ' ',
+        '<!---->', // v-if placeholder, mentionlink's "new" (i.e. rich) display
+        '</span>',
+        '<!---->', // v-if placeholder, mentionsline's extra mentions and stuff
         '</span>'
       ),
       p(
@@ -407,9 +363,6 @@ describe('RichContent', () => {
 
     const wrapper = mount(RichContent, {
       localVue,
-      stubs: {
-        MentionLink: true
-      },
       propsData: {
         attentions,
         handleLinks: true,
@@ -465,4 +418,63 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(expected))
   })
+
+  it.skip('[INFORMATIVE] Performance testing, 10 000 simple posts', () => {
+    const amount = 20
+
+    const onePost = p(
+      makeMention('Lain'),
+      makeMention('Lain'),
+      makeMention('Lain'),
+      makeMention('Lain'),
+      makeMention('Lain'),
+      makeMention('Lain'),
+      makeMention('Lain'),
+      makeMention('Lain'),
+      makeMention('Lain'),
+      makeMention('Lain'),
+      ' i just landed in l a where are you'
+    )
+
+    const TestComponent = {
+      template: `
+      <div v-if="!vhtml">
+        ${new Array(amount).fill(`<RichContent html="${onePost}" :greentext="true" :handleLinks="handeLinks" :emoji="[]" :attentions="attentions"/>`)}
+      </div>
+      <div v-else="vhtml">
+        ${new Array(amount).fill(`<div v-html="${onePost}"/>`)}
+      </div>
+      `,
+      props: ['handleLinks', 'attentions', 'vhtml']
+    }
+    console.log(1)
+
+    const ptest = (handleLinks, vhtml) => {
+      const t0 = performance.now()
+
+      const wrapper = mount(TestComponent, {
+        localVue,
+        propsData: {
+          attentions,
+          handleLinks,
+          vhtml
+        }
+      })
+
+      const t1 = performance.now()
+
+      wrapper.destroy()
+
+      const t2 = performance.now()
+
+      return `Mount: ${t1 - t0}ms, destroy: ${t2 - t1}ms, avg ${(t1 - t0) / amount}ms - ${(t2 - t1) / amount}ms per item`
+    }
+
+    console.log(`${amount} items with links handling:`)
+    console.log(ptest(true))
+    console.log(`${amount} items without links handling:`)
+    console.log(ptest(false))
+    console.log(`${amount} items plain v-html:`)
+    console.log(ptest(false, true))
+  })
 })

From 6c6df29ed3b76ee5f2e6ba43a6185ff66b91aace Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 13 Aug 2021 12:19:57 +0300
Subject: [PATCH 099/169] support richcontent in polls

---
 src/components/poll/poll.js                            | 10 +++++++---
 src/components/poll/poll.vue                           |  6 ++----
 src/components/status_content/status_content.vue       |  2 +-
 .../entity_normalizer/entity_normalizer.service.js     |  2 +-
 4 files changed, 11 insertions(+), 9 deletions(-)

diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index 98db5582..a69b7886 100644
--- a/src/components/poll/poll.js
+++ b/src/components/poll/poll.js
@@ -1,10 +1,14 @@
-import Timeago from '../timeago/timeago.vue'
+import Timeago from 'components/timeago/timeago.vue'
+import RichContent from 'components/rich_content/rich_content.jsx'
 import { forEach, map } from 'lodash'
 
 export default {
   name: 'Poll',
-  props: ['basePoll'],
-  components: { Timeago },
+  props: ['basePoll', 'emoji'],
+  components: {
+    Timeago,
+    RichContent
+  },
   data () {
     return {
       loading: false,
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index 187d1829..6e0234b3 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -17,8 +17,7 @@
           <span class="result-percentage">
             {{ percentageForOption(option.votes_count) }}%
           </span>
-          <!-- eslint-disable-next-line vue/no-v-html -->
-          <span v-html="option.title_html" />
+          <RichContent :html="option.title_html" :handle-links="false" :emoji="emoji" />
         </div>
         <div
           class="result-fill"
@@ -42,8 +41,7 @@
           :value="index"
         >
         <label class="option-vote">
-          <!-- eslint-disable-next-line vue/no-v-html -->
-          <div v-html="option.title_html" />
+          <RichContent :html="option.title_html" :handle-links="false" :emoji="emoji" />
         </label>
       </div>
     </div>
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 23c90913..3f65e64a 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -7,7 +7,7 @@
       @parseReady="$emit('parseReady', $event)"
     >
       <div v-if="status.poll && status.poll.options">
-        <poll :base-poll="status.poll" />
+        <poll :base-poll="status.poll" :emoji="status.emojis" />
       </div>
 
       <div
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 477b861f..c80ea487 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -301,7 +301,7 @@ export const parseStatus = (data) => {
     if (output.poll) {
       output.poll.options = (output.poll.options || []).map(field => ({
         ...field,
-        title_html: addEmojis(escape(field.title), data.emojis)
+        title_html: escape(field.title)
       }))
     }
     output.pinned = data.pinned

From 4c974f5ca21c6a514d0dfc09a0aeab7789da95c5 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 13 Aug 2021 13:06:42 +0300
Subject: [PATCH 100/169] richcontent support in polls, user cards and user
 profiles

---
 .../basic_user_card/basic_user_card.js        |  4 +-
 .../basic_user_card/basic_user_card.vue       | 12 +---
 src/components/notification/notification.js   |  4 +-
 src/components/notification/notification.scss |  2 +
 src/components/notification/notification.vue  | 14 +++--
 .../notifications/notifications.scss          |  7 ---
 src/components/poll/poll.vue                  | 12 +++-
 src/components/rich_content/rich_content.scss |  1 +
 .../status_content/status_content.vue         |  5 +-
 src/components/user_card/user_card.js         |  4 +-
 src/components/user_card/user_card.vue        | 61 +++++--------------
 src/components/user_profile/user_profile.js   |  4 +-
 src/components/user_profile/user_profile.vue  | 20 +++---
 .../entity_normalizer.service.js              |  5 +-
 14 files changed, 69 insertions(+), 86 deletions(-)

diff --git a/src/components/basic_user_card/basic_user_card.js b/src/components/basic_user_card/basic_user_card.js
index 87085a28..8f41e2fb 100644
--- a/src/components/basic_user_card/basic_user_card.js
+++ b/src/components/basic_user_card/basic_user_card.js
@@ -1,5 +1,6 @@
 import UserCard from '../user_card/user_card.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 
 const BasicUserCard = {
@@ -13,7 +14,8 @@ const BasicUserCard = {
   },
   components: {
     UserCard,
-    UserAvatar
+    UserAvatar,
+    RichContent
   },
   methods: {
     toggleUserExpanded () {
diff --git a/src/components/basic_user_card/basic_user_card.vue b/src/components/basic_user_card/basic_user_card.vue
index c53f6a9c..53deb1df 100644
--- a/src/components/basic_user_card/basic_user_card.vue
+++ b/src/components/basic_user_card/basic_user_card.vue
@@ -25,17 +25,11 @@
         :title="user.name"
         class="basic-user-card-user-name"
       >
-        <!-- eslint-disable vue/no-v-html -->
-        <span
-          v-if="user.name_html"
+        <RichContent
           class="basic-user-card-user-name-value"
-          v-html="user.name_html"
+          :html="user.name"
+          :emoji="user.emoji"
         />
-        <!-- eslint-enable vue/no-v-html -->
-        <span
-          v-else
-          class="basic-user-card-user-name-value"
-        >{{ user.name }}</span>
       </div>
       <div>
         <router-link
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 4aa9affd..398bb7a9 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -4,6 +4,7 @@ import Status from '../status/status.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
 import UserCard from '../user_card/user_card.vue'
 import Timeago from '../timeago/timeago.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
 import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -44,7 +45,8 @@ const Notification = {
     UserAvatar,
     UserCard,
     Timeago,
-    Status
+    Status,
+    RichContent
   },
   methods: {
     toggleUserExpanded () {
diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss
index f5905560..ec291547 100644
--- a/src/components/notification/notification.scss
+++ b/src/components/notification/notification.scss
@@ -2,6 +2,8 @@
 
 // TODO Copypaste from Status, should unify it somehow
 .Notification {
+  --emoji-size: 14px;
+
   &.-muted {
     padding: 0.25em 0.6em;
     height: 1.2em;
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 0081dee4..634ec8ee 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -51,12 +51,14 @@
         <span class="notification-details">
           <div class="name-and-action">
             <!-- eslint-disable vue/no-v-html -->
-            <bdi
-              v-if="!!notification.from_profile.name_html"
-              class="username"
-              :title="'@'+notification.from_profile.screen_name_ui"
-              v-html="notification.from_profile.name_html"
-            />
+            <bdi v-if="!!notification.from_profile.name_html">
+              <RichContent
+                class="username"
+                :title="'@'+notification.from_profile.screen_name_ui"
+                :html="notification.from_profile.name_html"
+                :emoji="notification.from_profile.emoji"
+              />
+            </bdi>
             <!-- eslint-enable vue/no-v-html -->
             <span
               v-else
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 2bb627a8..77b3c438 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -148,13 +148,6 @@
       max-width: 100%;
       text-overflow: ellipsis;
       white-space: nowrap;
-
-      img {
-        width: 14px;
-        height: 14px;
-        vertical-align: middle;
-        object-fit: contain
-      }
     }
 
     .timeago {
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index 6e0234b3..63b44e4f 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -17,7 +17,11 @@
           <span class="result-percentage">
             {{ percentageForOption(option.votes_count) }}%
           </span>
-          <RichContent :html="option.title_html" :handle-links="false" :emoji="emoji" />
+          <RichContent
+            :html="option.title_html"
+            :handle-links="false"
+            :emoji="emoji"
+          />
         </div>
         <div
           class="result-fill"
@@ -41,7 +45,11 @@
           :value="index"
         >
         <label class="option-vote">
-          <RichContent :html="option.title_html" :handle-links="false" :emoji="emoji" />
+          <RichContent
+            :html="option.title_html"
+            :handle-links="false"
+            :emoji="emoji"
+          />
         </label>
       </div>
     </div>
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
index 12cb9776..db08ef1e 100644
--- a/src/components/rich_content/rich_content.scss
+++ b/src/components/rich_content/rich_content.scss
@@ -49,6 +49,7 @@
   }
 
   .emoji {
+    display: inline-block;
     width: var(--emoji-size, 32px);
     height: var(--emoji-size, 32px);
   }
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 3f65e64a..5cebc697 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -7,7 +7,10 @@
       @parseReady="$emit('parseReady', $event)"
     >
       <div v-if="status.poll && status.poll.options">
-        <poll :base-poll="status.poll" :emoji="status.emojis" />
+        <Poll
+          :base-poll="status.poll"
+          :emoji="status.emojis"
+        />
       </div>
 
       <div
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index d9fb64d1..98e6c2f2 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -5,6 +5,7 @@ import FollowButton from '../follow_button/follow_button.vue'
 import ModerationTools from '../moderation_tools/moderation_tools.vue'
 import AccountActions from '../account_actions/account_actions.vue'
 import Select from '../select/select.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
@@ -118,7 +119,8 @@ export default {
     AccountActions,
     ProgressButton,
     FollowButton,
-    Select
+    Select,
+    RichContent
   },
   methods: {
     muteUser () {
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index a16f7873..794a2350 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -38,21 +38,12 @@
           </router-link>
           <div class="user-summary">
             <div class="top-line">
-              <!-- eslint-disable vue/no-v-html -->
-              <div
-                v-if="user.name_html"
+              <RichContent
                 :title="user.name"
                 class="user-name"
-                v-html="user.name_html"
+                :html="user.name"
+                :emoji="user.emoji"
               />
-              <!-- eslint-enable vue/no-v-html -->
-              <div
-                v-else
-                :title="user.name"
-                class="user-name"
-              >
-                {{ user.name }}
-              </div>
               <button
                 v-if="isOtherUser && !user.is_local"
                 :href="user.statusnet_profile_url"
@@ -255,20 +246,12 @@
           <span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
         </div>
       </div>
-      <!-- eslint-disable vue/no-v-html -->
-      <p
-        v-if="!hideBio && user.description_html"
+      <RichContent
+        v-if="!hideBio"
         class="user-card-bio"
-        @click.prevent="linkClicked"
-        v-html="user.description_html"
+        :html="user.description_html"
+        :emoji="user.emoji"
       />
-      <!-- eslint-enable vue/no-v-html -->
-      <p
-        v-else-if="!hideBio"
-        class="user-card-bio"
-      >
-        {{ user.description }}
-      </p>
     </div>
   </div>
 </template>
@@ -281,9 +264,10 @@
 .user-card {
   position: relative;
 
-  &:hover .Avatar {
+  &:hover {
     --_still-image-img-visibility: visible;
     --_still-image-canvas-visibility: hidden;
+    --_still-image-label-visibility: hidden;
   }
 
   .panel-heading {
@@ -327,12 +311,12 @@
     }
   }
 
-  p {
-    margin-bottom: 0;
-  }
-
   &-bio {
     text-align: center;
+    display: block;
+    line-height: 18px;
+    padding: 1em;
+    margin: 0;
 
     a {
       color: $fallback--link;
@@ -344,11 +328,6 @@
       vertical-align: middle;
       max-width: 100%;
       max-height: 400px;
-
-      &.emoji {
-        width: 32px;
-        height: 32px;
-      }
     }
   }
 
@@ -450,13 +429,6 @@
     // big one
     z-index: 1;
 
-    img {
-      width: 26px;
-      height: 26px;
-      vertical-align: middle;
-      object-fit: contain
-    }
-
     .top-line {
       display: flex;
     }
@@ -469,12 +441,7 @@
     margin-right: 1em;
     font-size: 15px;
 
-    img {
-      object-fit: contain;
-      height: 16px;
-      width: 16px;
-      vertical-align: middle;
-    }
+    --emoji-size: 14px;
   }
 
   .bottom-line {
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index c0b55a6c..7a475609 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -4,6 +4,7 @@ import FollowCard from '../follow_card/follow_card.vue'
 import Timeline from '../timeline/timeline.vue'
 import Conversation from '../conversation/conversation.vue'
 import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
 import List from '../list/list.vue'
 import withLoadMore from '../../hocs/with_load_more/with_load_more'
 import { library } from '@fortawesome/fontawesome-svg-core'
@@ -164,7 +165,8 @@ const UserProfile = {
     FriendList,
     FollowCard,
     TabSwitcher,
-    Conversation
+    Conversation,
+    RichContent
   }
 }
 
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index aef897ae..726216ff 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -20,20 +20,24 @@
           :key="index"
           class="user-profile-field"
         >
-          <!-- eslint-disable vue/no-v-html -->
           <dt
             :title="user.fields_text[index].name"
             class="user-profile-field-name"
-            @click.prevent="linkClicked"
-            v-html="field.name"
-          />
+          >
+            <RichContent
+              :html="field.name"
+              :emoji="user.emoji"
+            />
+          </dt>
           <dd
             :title="user.fields_text[index].value"
             class="user-profile-field-value"
-            @click.prevent="linkClicked"
-            v-html="field.value"
-          />
-          <!-- eslint-enable vue/no-v-html -->
+          >
+            <RichContent
+              :html="field.value"
+              :emoji="user.emoji"
+            />
+          </dd>
         </dl>
       </div>
       <tab-switcher
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index c80ea487..d84e3422 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -56,10 +56,11 @@ export const parseUser = (data) => {
 
     output.emoji = data.emojis
     output.name = data.display_name
-    output.name_html = addEmojis(escape(data.display_name), data.emojis)
+    output.name_html = escape(data.display_name)
 
     output.description = data.note
-    output.description_html = addEmojis(data.note, data.emojis)
+    // TODO cleanup this shit, output.description is overriden with source data
+    output.description_html = data.note
 
     output.fields = data.fields
     output.fields_html = data.fields.map(field => {

From 97e86381c868fbd49a33e190722934dee698600d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Fri, 13 Aug 2021 13:12:33 +0300
Subject: [PATCH 101/169] remove old emoji added, everything emoji-bearing uses
 RichContent now

---
 .../entity_normalizer.service.js              | 14 +------
 .../entity_normalizer.spec.js                 | 37 +------------------
 2 files changed, 3 insertions(+), 48 deletions(-)

diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index d84e3422..04bb45a4 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -65,8 +65,8 @@ export const parseUser = (data) => {
     output.fields = data.fields
     output.fields_html = data.fields.map(field => {
       return {
-        name: addEmojis(escape(field.name), data.emojis),
-        value: addEmojis(field.value, data.emojis)
+        name: escape(field.name),
+        value: field.value
       }
     })
     output.fields_text = data.fields.map(field => {
@@ -241,16 +241,6 @@ export const parseAttachment = (data) => {
 
   return output
 }
-export const addEmojis = (string, emojis) => {
-  const matchOperatorsRegex = /[|\\{}()[\]^$+*?.-]/g
-  return emojis.reduce((acc, emoji) => {
-    const regexSafeShortCode = emoji.shortcode.replace(matchOperatorsRegex, '\\$&')
-    return acc.replace(
-      new RegExp(`:${regexSafeShortCode}:`, 'g'),
-      `<img src='${emoji.url}' alt=':${emoji.shortcode}:' title=':${emoji.shortcode}:' class='emoji' />`
-    )
-  }, string)
-}
 
 export const parseStatus = (data) => {
   const output = {}
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index 8a5a6ef9..8932bc7c 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -1,4 +1,4 @@
-import { parseStatus, parseUser, parseNotification, addEmojis, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseUser, parseNotification, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
 import mastoapidata from '../../../../fixtures/mastoapi.json'
 import qvitterapidata from '../../../../fixtures/statuses.json'
 
@@ -338,41 +338,6 @@ describe('API Entities normalizer', () => {
     })
   })
 
-  describe('MastoAPI emoji adder', () => {
-    const emojis = makeMockEmojiMasto()
-    const imageHtml = '<img src="https://example.com/image.png" alt=":image:" title=":image:" class="emoji" />'
-      .replace(/"/g, '\'')
-    const thinkHtml = '<img src="https://example.com/think.png" alt=":thinking:" title=":thinking:" class="emoji" />'
-      .replace(/"/g, '\'')
-
-    it('correctly replaces shortcodes in supplied string', () => {
-      const result = addEmojis('This post has :image: emoji and :thinking: emoji', emojis)
-      expect(result).to.include(thinkHtml)
-      expect(result).to.include(imageHtml)
-    })
-
-    it('handles consecutive emojis correctly', () => {
-      const result = addEmojis('Lelel emoji spam :thinking::thinking::thinking::thinking:', emojis)
-      expect(result).to.include(thinkHtml + thinkHtml + thinkHtml + thinkHtml)
-    })
-
-    it('Doesn\'t replace nonexistent emojis', () => {
-      const result = addEmojis('Admin add the :tenshi: emoji', emojis)
-      expect(result).to.equal('Admin add the :tenshi: emoji')
-    })
-
-    it('Doesn\'t blow up on regex special characters', () => {
-      const emojis = makeMockEmojiMasto([{
-        shortcode: 'c++'
-      }, {
-        shortcode: '[a-z] {|}*'
-      }])
-      const result = addEmojis('This post has :c++: emoji and :[a-z] {|}*: emoji', emojis)
-      expect(result).to.include('title=\':c++:\'')
-      expect(result).to.include('title=\':[a-z] {|}*:\'')
-    })
-  })
-
   describe('Link header pagination', () => {
     it('Parses min and max ids as integers', () => {
       const linkHeader = '<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'

From 4465de5241a6ed148d00d20de9e348f4991a4400 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sat, 14 Aug 2021 21:55:38 +0300
Subject: [PATCH 102/169] fixed mentions line again

---
 src/components/status/status.js | 23 ++++++++---------------
 1 file changed, 8 insertions(+), 15 deletions(-)

diff --git a/src/components/status/status.js b/src/components/status/status.js
index 54f304a2..ac481534 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -166,23 +166,16 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
-    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
-      }))
-    },
     mentionsLine () {
-      const writtenMentions = this.headTailLinks ? this.headTailLinks.writtenMentions : []
-      const set = new Set(writtenMentions.map(_ => _.url))
+      if (!this.headTailLinks) return []
+      const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
       return this.status.attentions.filter(attn => {
-        return attn.screen_name !== this.replyToName &&
-          attn.screen_name !== this.status.user.screen_name &&
-          !set.has(attn.url)
+        // no reply user
+        return attn.id !== this.status.in_reply_to_user_id &&
+          // no self-replies
+          attn.statusnet_profile_url !== this.status.user.statusnet_profile_url &&
+          // don't include if mentions is written
+          !writtenSet.has(attn.statusnet_profile_url)
       }).map(attn => ({
         url: attn.statusnet_profile_url,
         content: attn.screen_name,

From 530ac4442b498c90c73533d2a03ae5b7d6875900 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 15 Aug 2021 02:41:53 +0300
Subject: [PATCH 103/169] removed useless code, review change, fixed bug with
 tall statuses

---
 src/components/mention_link/mention_link.js   |  2 +-
 src/components/mentions_line/mentions_line.js |  9 ++-
 src/components/rich_content/rich_content.jsx  | 69 ++++++++-----------
 src/components/status_body/status_body.js     | 16 ++++-
 4 files changed, 45 insertions(+), 51 deletions(-)

diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index 4d27fe6d..65c62baa 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -45,7 +45,7 @@ const MentionLink = {
     },
     isYou () {
       // FIXME why user !== currentUser???
-      return this.user && this.user.screen_name === this.currentUser.screen_name
+      return this.user && this.user.id === this.currentUser.id
     },
     userName () {
       return this.user && this.userNameFullUi.split('@')[0]
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
index 83eeea4c..a4a0c724 100644
--- a/src/components/mentions_line/mentions_line.js
+++ b/src/components/mentions_line/mentions_line.js
@@ -1,6 +1,8 @@
 import MentionLink from 'src/components/mention_link/mention_link.vue'
 import { mapGetters } from 'vuex'
 
+export const MENTIONS_LIMIT = 5
+
 const MentionsLine = {
   name: 'MentionsLine',
   props: {
@@ -14,14 +16,11 @@ const MentionsLine = {
     MentionLink
   },
   computed: {
-    limit () {
-      return 5
-    },
     mentionsComputed () {
-      return this.mentions.slice(0, this.limit)
+      return this.mentions.slice(0, MENTIONS_LIMIT)
     },
     extraMentions () {
-      return this.mentions.slice(this.limit)
+      return this.mentions.slice(MENTIONS_LIMIT)
     },
     manyMentions () {
       return this.extraMentions.length > 0
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 32b737ec..1353541f 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -4,8 +4,7 @@ import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_con
 import { convertHtmlToTree } 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'
-import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
+import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
 
 import './rich_content.scss'
 
@@ -13,12 +12,11 @@ import './rich_content.scss'
  * 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)
+ * - Groups all mentions into <MentionsLine>, this affects all mentions regardles
+ *   of where they are (beginning/middle/end), even single mentions are converted
+ *   to a <MentionsLine> containing single <MentionLink>.
  * - 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.
@@ -61,12 +59,13 @@ export default Vue.component('RichContent', {
   // NEVER EVER TOUCH DATA INSIDE RENDER
   render (h) {
     // Pre-process HTML
-    const { newHtml: html } = preProcessPerLine(this.html, this.greentext, this.handleLinks)
+    const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
     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 invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
+    // to collapse too many mentions in a row
     const writtenTags = [] // All tags that appear in post body
     // unique index for vue "tag" property
     let mentionIndex = 0
@@ -99,6 +98,9 @@ export default Vue.component('RichContent', {
         currentMentions = []
       }
       currentMentions.push(linkData)
+      if (currentMentions.length > MENTIONS_LIMIT) {
+        invisibleMentions.push(linkData)
+      }
       if (currentMentions.length === 1) {
         return <MentionsLine mentions={ currentMentions } />
       } else {
@@ -232,7 +234,8 @@ export default Vue.component('RichContent', {
     const event = {
       lastTags,
       writtenMentions,
-      writtenTags
+      writtenTags,
+      invisibleMentions
     }
 
     // DO NOT MOVE TO UPDATE. BAD IDEA.
@@ -243,27 +246,32 @@ export default Vue.component('RichContent', {
 })
 
 const getLinkData = (attrs, children, index) => {
+  const stripTags = (item) => {
+    if (typeof item === 'string') {
+      return item
+    } else {
+      return item[1].map(stripTags).join('')
+    }
+  }
+  const textContent = children.map(stripTags).join('')
   return {
     index,
     url: attrs.href,
     hashtag: attrs['data-tag'],
-    content: flattenDeep(children).join('')
+    content: flattenDeep(children).join(''),
+    textContent
   }
 }
 
 /** Pre-processing HTML
  *
- * Currently this does two things:
+ * Currently this does one thing:
  * - 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
  */
-export const preProcessPerLine = (html, greentext, handleLinks) => {
-  const lastMentions = []
+export const preProcessPerLine = (html, greentext) => {
   const greentextHandle = new Set(['p', 'div'])
 
   const lines = convertHtmlToLines(html)
@@ -277,7 +285,7 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
     if (
       // Only if greentext is engaged
       greentext &&
-        // Only handle p's and divs. Don't want to affect blocquotes, code etc
+        // Only handle p's and divs. Don't want to affect blockquotes, code etc
         item.level.every(l => greentextHandle.has(l)) &&
         // Only if line begins with '>' or '<'
         (string.includes('&gt;') || string.includes('&lt;'))
@@ -292,31 +300,8 @@ export const preProcessPerLine = (html, greentext, handleLinks) => {
       }
     }
 
-    // Converting that line part into tree
-    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 {
-          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)
-
-    return flattenDeep(result).join('')
+    return string
   }).reverse().join('')
 
-  return { newHtml, lastMentions }
+  return { newHtml }
 }
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 8757154d..b941765c 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -32,7 +32,8 @@ const StatusContent = {
       showingTall: this.fullContent || (this.inConversation && this.focused),
       showingLongSubject: false,
       // not as computed because it sets the initial state which will be changed later
-      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
+      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
+      postLength: this.status.text.length
     }
   },
   computed: {
@@ -47,7 +48,7 @@ const StatusContent = {
     // Using max-height + overflow: auto for status components resulted in false positives
     // very often with japanese characters, and it was very annoying.
     tallStatus () {
-      const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.status.text.length / 80
+      const lengthScore = this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
       return lengthScore > 20
     },
     longSubject () {
@@ -86,7 +87,7 @@ const StatusContent = {
   methods: {
     onParseReady (event) {
       this.$emit('parseReady', event)
-      const { writtenMentions } = event
+      const { writtenMentions, invisibleMentions } = event
       writtenMentions
         .filter(mention => !mention.notifying)
         .forEach(mention => {
@@ -97,6 +98,15 @@ const StatusContent = {
           const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '')
           this.$store.dispatch('fetchUserIfMissing', `${handle}@${host}`)
         })
+      /* This is a bit of a hack to make current tall status detector work
+       * with rich mentions. Invisible mentions are detected at RichContent level
+       * and also we generate plaintext version of mentions by stripping tags
+       * so here we subtract from post length by each mention that became invisible
+       * via MentionsLine
+       */
+      this.postLength = invisibleMentions.reduce((acc, mention) => {
+        return acc - mention.textContent.length - 1
+      }, this.postLength)
     },
     toggleShowMore () {
       if (this.mightHideBecauseTall) {

From 7d67e8f1cccd1ef924a97b8285756590ac29224e Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 15 Aug 2021 02:44:36 +0300
Subject: [PATCH 104/169] remove obsolete tests

---
 .../entity_normalizer.spec.js                 | 29 -------------------
 1 file changed, 29 deletions(-)

diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index 8932bc7c..03fb32c9 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -244,35 +244,6 @@ describe('API Entities normalizer', () => {
       expect(parseUser(remote)).to.have.property('is_local', false)
     })
 
-    it('adds emojis to user name', () => {
-      const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), display_name: 'The :thinking: thinker' })
-
-      const parsedUser = parseUser(user)
-
-      expect(parsedUser).to.have.property('name_html').that.contains('<img')
-    })
-
-    it('adds emojis to user bio', () => {
-      const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), note: 'Hello i like to :thinking: a lot' })
-
-      const parsedUser = parseUser(user)
-
-      expect(parsedUser).to.have.property('description_html').that.contains('<img')
-    })
-
-    it('adds emojis to user profile fields', () => {
-      const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), fields: [{ name: ':thinking:', value: ':image:' }] })
-
-      const parsedUser = parseUser(user)
-
-      expect(parsedUser).to.have.property('fields_html').to.be.an('array')
-
-      const field = parsedUser.fields_html[0]
-
-      expect(field).to.have.property('name').that.contains('<img')
-      expect(field).to.have.property('value').that.contains('<img')
-    })
-
     it('removes html tags from user profile fields', () => {
       const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), fields: [{ name: 'user', value: '<a rel="me" href="https://example.com/@user">@user</a>' }] })
 

From 68b432318198b6835790112d51f0572e19238158 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 15 Aug 2021 02:55:45 +0300
Subject: [PATCH 105/169] prevent infinite update loops

---
 src/components/status_body/status_body.js | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index b941765c..ef542307 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -33,7 +33,8 @@ const StatusContent = {
       showingLongSubject: false,
       // not as computed because it sets the initial state which will be changed later
       expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
-      postLength: this.status.text.length
+      postLength: this.status.text.length,
+      parseReadyDone: false
     }
   },
   computed: {
@@ -86,6 +87,8 @@ const StatusContent = {
   },
   methods: {
     onParseReady (event) {
+      if (this.parseReadyDone) return
+      this.parseReadyDone = true
       this.$emit('parseReady', event)
       const { writtenMentions, invisibleMentions } = event
       writtenMentions

From f16658adfc897a3b07ed7f79d872acd2c3837cc8 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 15 Aug 2021 02:59:14 +0300
Subject: [PATCH 106/169] fix tests

---
 src/services/html_converter/html_line_converter.service.js | 2 +-
 src/services/html_converter/html_tree_converter.service.js | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js
index 74103b02..5eeaa7cb 100644
--- a/src/services/html_converter/html_line_converter.service.js
+++ b/src/services/html_converter/html_line_converter.service.js
@@ -18,7 +18,7 @@ import { getTagName } from './utility.service.js'
  * @param {Object} input - input data
  * @return {(string|{ text: string })[]} processed html in form of a list.
  */
-export const convertHtmlToLines = (html) => {
+export const convertHtmlToLines = (html = '') => {
   // Elements that are implicitly self-closing
   // https://developer.mozilla.org/en-US/docs/Glossary/empty_element
   const emptyElements = new Set([
diff --git a/src/services/html_converter/html_tree_converter.service.js b/src/services/html_converter/html_tree_converter.service.js
index 804d35d7..6a8796c4 100644
--- a/src/services/html_converter/html_tree_converter.service.js
+++ b/src/services/html_converter/html_tree_converter.service.js
@@ -19,7 +19,7 @@ import { getTagName } from './utility.service.js'
  * @param {Object} input - input data
  * @return {string} processed html
  */
-export const convertHtmlToTree = (html) => {
+export const convertHtmlToTree = (html = '') => {
   // Elements that are implicitly self-closing
   // https://developer.mozilla.org/en-US/docs/Glossary/empty_element
   const emptyElements = new Set([

From 8cc1ad67dff93b5faa1ed1bab663d16748eec68d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 15 Aug 2021 18:11:38 +0300
Subject: [PATCH 107/169] fix links sticking to mentionsline

---
 src/components/mentions_line/mentions_line.scss | 2 +-
 src/components/rich_content/rich_content.jsx    | 7 +++++++
 test/unit/specs/components/rich_content.spec.js | 4 ++--
 3 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index 222940c8..50eb0c27 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -4,7 +4,7 @@
     color: var(--link);
   }
 
-  .mention-link:not(:last-child) {
+  .mention-link {
     margin-right: 0.25em;
   }
 }
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 1353541f..8ab007e3 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -121,6 +121,13 @@ export default Vue.component('RichContent', {
           // in MentionsLine
           return currentMentions !== null ? item.trim() : item
         }
+        // We add space with mentionsLine, otherwise non-text elements will
+        // stick to them.
+        if (currentMentions !== null) {
+          // single whitespace trim
+          item = item[0].match(/\s/) ? item.slice(1) : item
+        }
+
         currentMentions = null
         if (item.includes(':')) {
           item = ['', processTextForEmoji(
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index b29edeab..c84df34b 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -59,7 +59,7 @@ describe('RichContent', () => {
   it('replaces mention with mentionsline', () => {
     const html = p(
       makeMention('John'),
-      ' how are you doing today?'
+      '  how are you doing today?' // also testing single-trimming function
     )
     const wrapper = shallowMount(RichContent, {
       localVue,
@@ -74,7 +74,7 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(p(
       mentionsLine(1),
-      ' how are you doing today?'
+      ' how are you doing today?' // space removed to compensate for <ML> padding
     )))
   })
 

From 0087d33c7516551ac674110859027a10855ab98f Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Sun, 15 Aug 2021 18:41:13 +0300
Subject: [PATCH 108/169] fix "+X more" sticking

---
 src/components/mentions_line/mentions_line.scss | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index 50eb0c27..976a3fc7 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -2,6 +2,7 @@
   .showMoreLess {
     white-space: normal;
     color: var(--link);
+    margin-right: 0.25em;
   }
 
   .mention-link {

From dbdc5e050fa900fb31ae76ba1047390ae19407c5 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 16 Aug 2021 01:41:52 +0300
Subject: [PATCH 109/169] fix ext profile bug

---
 src/components/user_card/user_card.vue | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 528b92fb..72c151b9 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -65,7 +65,7 @@
                   :title="$t('user_card.edit_profile')"
                 />
               </button>
-              <button
+              <a
                 v-if="isOtherUser && !user.is_local"
                 :href="user.statusnet_profile_url"
                 target="_blank"
@@ -75,7 +75,7 @@
                   class="icon"
                   icon="external-link-alt"
                 />
-              </button>
+              </a>
               <AccountActions
                 v-if="isOtherUser && loggedIn"
                 :user="user"

From e98a2af39e01e42a6dc24aa148eb67e98cb1dbef Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 18 Aug 2021 20:54:04 +0300
Subject: [PATCH 110/169] hopefully final fix for spacings

---
 .../mentions_line/mentions_line.scss          |  3 +-
 src/components/rich_content/rich_content.jsx  | 37 ++++++++++++-------
 2 files changed, 24 insertions(+), 16 deletions(-)

diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index 976a3fc7..222940c8 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -2,10 +2,9 @@
   .showMoreLess {
     white-space: normal;
     color: var(--link);
-    margin-right: 0.25em;
   }
 
-  .mention-link {
+  .mention-link:not(:last-child) {
     margin-right: 0.25em;
   }
 }
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 8ab007e3..d98947df 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -61,6 +61,8 @@ export default Vue.component('RichContent', {
     // Pre-process HTML
     const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
     let currentMentions = null // Current chain of mentions, we group all mentions together
+    // This is used to recover spacing removed when parsing mentions
+    let lastSpacing = ''
 
     const lastTags = [] // Tags that appear at the end of post body
     const writtenMentions = [] // All mentions that appear in post body
@@ -119,14 +121,9 @@ export default Vue.component('RichContent', {
         if (emptyText) {
           // don't include spaces when processing mentions - we'll include them
           // in MentionsLine
+          lastSpacing = item
           return currentMentions !== null ? item.trim() : item
         }
-        // We add space with mentionsLine, otherwise non-text elements will
-        // stick to them.
-        if (currentMentions !== null) {
-          // single whitespace trim
-          item = item[0].match(/\s/) ? item.slice(1) : item
-        }
 
         currentMentions = null
         if (item.includes(':')) {
@@ -151,21 +148,32 @@ export default Vue.component('RichContent', {
         const [opener, children, closer] = item
         const Tag = getTagName(opener)
         const attrs = getAttrs(opener)
+        const previouslyMentions = currentMentions !== null
+        /* During grouping of mentions we trim all the empty text elements
+         * This padding is added to recover last space removed in case
+         * we have a tag right next to mentions
+         */
+        const mentionsLinePadding =
+              // Padding is only needed if we just finished parsing mentions
+              previouslyMentions &&
+              // Don't add padding if content is string and has padding already
+              !(children && typeof children[0] === 'string' && children[0].match(/^\s/))
+                ? lastSpacing
+                : ''
         switch (Tag) {
           case 'br':
             currentMentions = null
             break
           case 'img': // replace images with StillImage
-            return renderImage(opener)
+            return ['', [mentionsLinePadding, renderImage(opener)], '']
           case 'a': // replace mentions with MentionLink
             if (!this.handleLinks) break
             if (attrs['class'] && attrs['class'].includes('mention')) {
               // Handling mentions here
               return renderMention(attrs, children)
             } else {
-              // Everything else will be handled in reverse pass
               currentMentions = null
-              return item // We'll handle it later
+              break
             }
           case 'span':
             if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
@@ -174,9 +182,13 @@ export default Vue.component('RichContent', {
         }
 
         if (children !== undefined) {
-          return [opener, children.map(processItem), closer]
+          return [
+            opener,
+            [mentionsLinePadding].concat(children.map(processItem)),
+            closer
+          ]
         } else {
-          return item
+          return ['', [mentionsLinePadding, item], '']
         }
       }
     }
@@ -215,7 +227,6 @@ export default Vue.component('RichContent', {
 
         // Render tag as is
         if (children !== undefined) {
-          html.includes('freenode') && console.log('PASS2', children)
           const newChildren = Array.isArray(children)
             ? [...children].reverse().map(processItemReverse).reverse()
             : children
@@ -283,8 +294,6 @@ export const preProcessPerLine = (html, greentext) => {
 
   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
     if (!item.text) return item
     const string = item.text
 

From cbb34e2b0e0ecd6b0b0040e0ab2dc2b8dfca3fa1 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 18 Aug 2021 20:58:26 +0300
Subject: [PATCH 111/169] fix expanded mentions spacing

---
 src/components/mentions_line/mentions_line.scss | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/components/mentions_line/mentions_line.scss b/src/components/mentions_line/mentions_line.scss
index 222940c8..b9d5c14a 100644
--- a/src/components/mentions_line/mentions_line.scss
+++ b/src/components/mentions_line/mentions_line.scss
@@ -4,6 +4,7 @@
     color: var(--link);
   }
 
+  .fullExtraMentions,
   .mention-link:not(:last-child) {
     margin-right: 0.25em;
   }

From c3576211cbbf9ada109c0869dcf4e4fc7b3ef925 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Wed, 18 Aug 2021 21:17:51 +0300
Subject: [PATCH 112/169] fix tests

---
 test/unit/specs/components/rich_content.spec.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index c84df34b..b29edeab 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -59,7 +59,7 @@ describe('RichContent', () => {
   it('replaces mention with mentionsline', () => {
     const html = p(
       makeMention('John'),
-      '  how are you doing today?' // also testing single-trimming function
+      ' how are you doing today?'
     )
     const wrapper = shallowMount(RichContent, {
       localVue,
@@ -74,7 +74,7 @@ describe('RichContent', () => {
 
     expect(wrapper.html()).to.eql(compwrap(p(
       mentionsLine(1),
-      ' how are you doing today?' // space removed to compensate for <ML> padding
+      ' how are you doing today?'
     )))
   })
 

From 39494439d359fa058079c9325efdff244a7e7890 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 23 Aug 2021 20:57:21 +0300
Subject: [PATCH 113/169] very minimalist hashtaglink implementation, also you
 can middle-click mentions now.

---
 src/components/hashtag_link/hashtag_link.js   | 36 +++++++++++++++++++
 src/components/hashtag_link/hashtag_link.scss |  6 ++++
 src/components/hashtag_link/hashtag_link.vue  | 19 ++++++++++
 src/components/mention_link/mention_link.vue  |  5 +--
 src/components/rich_content/rich_content.jsx  | 13 +++----
 .../specs/components/rich_content.spec.js     |  8 ++---
 6 files changed, 75 insertions(+), 12 deletions(-)
 create mode 100644 src/components/hashtag_link/hashtag_link.js
 create mode 100644 src/components/hashtag_link/hashtag_link.scss
 create mode 100644 src/components/hashtag_link/hashtag_link.vue

diff --git a/src/components/hashtag_link/hashtag_link.js b/src/components/hashtag_link/hashtag_link.js
new file mode 100644
index 00000000..a2433c2a
--- /dev/null
+++ b/src/components/hashtag_link/hashtag_link.js
@@ -0,0 +1,36 @@
+import { extractTagFromUrl } from 'src/services/matcher/matcher.service.js'
+
+const HashtagLink = {
+  name: 'HashtagLink',
+  props: {
+    url: {
+      required: true,
+      type: String
+    },
+    content: {
+      required: true,
+      type: String
+    },
+    tag: {
+      required: false,
+      type: String,
+      default: ''
+    }
+  },
+  methods: {
+    onClick () {
+      const tag = this.tag || extractTagFromUrl(this.url)
+      if (tag) {
+        const link = this.generateTagLink(tag)
+        this.$router.push(link)
+      } else {
+        window.open(this.url, '_blank')
+      }
+    },
+    generateTagLink (tag) {
+      return `/tag/${tag}`
+    }
+  }
+}
+
+export default HashtagLink
diff --git a/src/components/hashtag_link/hashtag_link.scss b/src/components/hashtag_link/hashtag_link.scss
new file mode 100644
index 00000000..78e8fb99
--- /dev/null
+++ b/src/components/hashtag_link/hashtag_link.scss
@@ -0,0 +1,6 @@
+.HashtagLink {
+  position: relative;
+  white-space: normal;
+  display: inline-block;
+  color: var(--link);
+}
diff --git a/src/components/hashtag_link/hashtag_link.vue b/src/components/hashtag_link/hashtag_link.vue
new file mode 100644
index 00000000..918ed26b
--- /dev/null
+++ b/src/components/hashtag_link/hashtag_link.vue
@@ -0,0 +1,19 @@
+<template>
+  <span
+    class="HashtagLink"
+  >
+    <!-- eslint-disable vue/no-v-html -->
+    <a
+      :href="url"
+      class="original"
+      target="_blank"
+      @click.prevent="onClick"
+      v-html="content"
+    />
+    <!-- eslint-enable vue/no-v-html -->
+  </span>
+</template>
+
+<script src="./hashtag_link.js"/>
+
+<style lang="scss" src="./hashtag_link.scss"/>
diff --git a/src/components/mention_link/mention_link.vue b/src/components/mention_link/mention_link.vue
index 625eb727..a22b486c 100644
--- a/src/components/mention_link/mention_link.vue
+++ b/src/components/mention_link/mention_link.vue
@@ -17,8 +17,9 @@
       :style="style"
       :class="classnames"
     >
-      <button
+      <a
         class="short button-unstyled"
+        :href="url"
         @click.prevent="onClick"
       >
         <!-- eslint-disable vue/no-v-html -->
@@ -35,7 +36,7 @@
           class="you"
         >{{ $t('status.you') }}</span>
         <!-- eslint-enable vue/no-v-html -->
-      </button>
+      </a>
       <span
         v-if="userName !== userNameFull"
         class="full popover-default"
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index d98947df..f52b0370 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -5,6 +5,7 @@ import { convertHtmlToTree } from 'src/services/html_converter/html_tree_convert
 import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
 import StillImage from 'src/components/still-image/still-image.vue'
 import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
+import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
 
 import './rich_content.scss'
 
@@ -83,13 +84,10 @@ 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)
       }
-      return <a {...{ attrs }}>
-        { children.map(processItem) }
-      </a>
+      return <HashtagLink {...{ props: linkData }}/>
     }
 
     const renderMention = (attrs, children) => {
@@ -211,7 +209,10 @@ export default Vue.component('RichContent', {
             if (!this.handleLinks) break
             const attrs = getAttrs(opener)
             // should only be this
-            if (attrs['class'] && attrs['class'].includes('hashtag')) {
+            if (
+              (attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
+                (attrs['rel'] === 'tag') // Mastodon style
+            ) {
               return renderHashtag(attrs, children, encounteredTextReverse)
             } else {
               attrs.target = '_blank'
@@ -275,7 +276,7 @@ const getLinkData = (attrs, children, index) => {
   return {
     index,
     url: attrs.href,
-    hashtag: attrs['data-tag'],
+    tag: attrs['data-tag'],
     content: flattenDeep(children).join(''),
     textContent
   }
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index b29edeab..f6c478a9 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -300,10 +300,10 @@ describe('RichContent', () => {
       '<p>',
       '<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg" target="_blank">',
       'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
-      ' <a class="hashtag" data-tag="nou" href="https://shitposter.club/tag/nou" target="_blank">',
-      '#nou</a>',
-      ' <a class="hashtag" data-tag="screencap" href="https://shitposter.club/tag/screencap" target="_blank">',
-      '#screencap</a>',
+      ' <hashtaglink-stub url="https://shitposter.club/tag/nou" content="#nou" tag="nou">',
+      '</hashtaglink-stub>',
+      ' <hashtaglink-stub url="https://shitposter.club/tag/screencap" content="#screencap" tag="screencap">',
+      '</hashtaglink-stub>',
       ' </p>'
     ].join('')
 

From 4d73eaa6cef7a8b81bcca719755b11c05717d395 Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Mon, 23 Aug 2021 21:36:18 +0300
Subject: [PATCH 114/169] fix spacing before hashtags

---
 src/components/rich_content/rich_content.jsx | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index f52b0370..c0d20c5e 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -181,9 +181,12 @@ export default Vue.component('RichContent', {
 
         if (children !== undefined) {
           return [
-            opener,
-            [mentionsLinePadding].concat(children.map(processItem)),
-            closer
+            '',
+            [
+              mentionsLinePadding,
+              [opener, children.map(processItem), closer]
+            ],
+            ''
           ]
         } else {
           return ['', [mentionsLinePadding, item], '']

From 4302db597590cbb1fe8c38167d01ce450d9f3e0f Mon Sep 17 00:00:00 2001
From: Ben Is <spambenis@fastwebnet.it>
Date: Thu, 27 May 2021 18:40:03 +0000
Subject: [PATCH 115/169] Translated using Weblate (Italian)

Currently translated at 100.0% (711 of 711 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/it/
---
 src/i18n/it.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/i18n/it.json b/src/i18n/it.json
index a88686ae..a4382780 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -443,7 +443,8 @@
       "backup_settings_theme": "Archivia impostazioni e tema localmente",
       "backup_settings": "Archivia impostazioni localmente",
       "backup_restore": "Archiviazione impostazioni"
-    }
+    },
+    "right_sidebar": "Mostra barra laterale a destra"
   },
   "timeline": {
     "error_fetching": "Errore nell'aggiornamento",

From f57f61ca5375db0da8771efa0f28bbf823650062 Mon Sep 17 00:00:00 2001
From: ZEN <xinit.info@gmail.com>
Date: Sun, 30 May 2021 15:49:18 +0000
Subject: [PATCH 116/169] Translated using Weblate (Ukrainian)

Currently translated at 100.0% (711 of 711 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/uk/
---
 src/i18n/uk.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/i18n/uk.json b/src/i18n/uk.json
index e616291e..3caeadba 100644
--- a/src/i18n/uk.json
+++ b/src/i18n/uk.json
@@ -633,7 +633,8 @@
       "backup_settings_theme": "Резервне копіювання налаштувань та теми у файл",
       "backup_settings": "Резервне копіювання налаштувань у файл",
       "backup_restore": "Резервне копіювання налаштувань"
-    }
+    },
+    "right_sidebar": "Показувати бокову панель справа"
   },
   "selectable_list": {
     "select_all": "Вибрати все"

From 04a49e4c4212141e6adc6505e68221b4903366f6 Mon Sep 17 00:00:00 2001
From: Tirifto <tirifto@posteo.cz>
Date: Mon, 31 May 2021 01:56:53 +0000
Subject: [PATCH 117/169] Translated using Weblate (Esperanto)

Currently translated at 99.7% (709 of 711 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/eo/
---
 src/i18n/eo.json | 45 +++++++++++++++++++++++++++++++--------------
 1 file changed, 31 insertions(+), 14 deletions(-)

diff --git a/src/i18n/eo.json b/src/i18n/eo.json
index 0d24a8f8..3eaa6537 100644
--- a/src/i18n/eo.json
+++ b/src/i18n/eo.json
@@ -87,7 +87,8 @@
     "interactions": "Interagoj",
     "administration": "Administrado",
     "bookmarks": "Legosignoj",
-    "timelines": "Historioj"
+    "timelines": "Historioj",
+    "home_timeline": "Hejma historio"
   },
   "notifications": {
     "broken_favorite": "Nekonata stato, serĉante ĝin…",
@@ -119,10 +120,10 @@
     "direct_warning": "Ĉi tiu afiŝo estos videbla nur por ĉiuj menciitaj uzantoj.",
     "posting": "Afiŝante",
     "scope": {
-      "direct": "Rekta – Afiŝi nur al menciitaj uzantoj",
-      "private": "Nur abonantoj – Afiŝi nur al abonantoj",
-      "public": "Publika – Afiŝi al publikaj historioj",
-      "unlisted": "Nelistigita – Ne afiŝi al publikaj historioj"
+      "direct": "Rekta – afiŝi nur al menciitaj uzantoj",
+      "private": "Nur abonantoj – afiŝi nur al abonantoj",
+      "public": "Publika – afiŝi al publikaj historioj",
+      "unlisted": "Nelistigita – ne afiŝi al publikaj historioj"
     },
     "scope_notice": {
       "unlisted": "Ĉi tiu afiŝo ne estos videbla en la Publika historio kaj La tuta konata reto",
@@ -135,7 +136,8 @@
     "preview": "Antaŭrigardo",
     "direct_warning_to_first_only": "Ĉi tiu afiŝo estas nur videbla al uzantoj menciitaj je la komenco de la mesaĝo.",
     "direct_warning_to_all": "Ĉi tiu afiŝo estos videbla al ĉiuj menciitaj uzantoj.",
-    "media_description": "Priskribo de vidaŭdaĵo"
+    "media_description": "Priskribo de vidaŭdaĵo",
+    "post": "Afiŝo"
   },
   "registration": {
     "bio": "Priskribo",
@@ -143,7 +145,7 @@
     "fullname": "Prezenta nomo",
     "password_confirm": "Konfirmo de pasvorto",
     "registration": "Registriĝo",
-    "token": "Invita ĵetono",
+    "token": "Invita peco",
     "captcha": "TESTO DE HOMECO",
     "new_captcha": "Klaku la bildon por akiri novan teston",
     "username_placeholder": "ekz. lain",
@@ -158,7 +160,8 @@
       "password_confirmation_match": "samu la pasvorton"
     },
     "reason_placeholder": "Ĉi-node oni aprobas registriĝojn permane.\nSciigu la administrantojn kial vi volas registriĝi.",
-    "reason": "Kialo registriĝi"
+    "reason": "Kialo registriĝi",
+    "register": "Registriĝi"
   },
   "settings": {
     "app_name": "Nomo de aplikaĵo",
@@ -244,9 +247,9 @@
     "show_admin_badge": "Montri la insignon de administranto en mia profilo",
     "show_moderator_badge": "Montri la insignon de reguligisto en mia profilo",
     "nsfw_clickthrough": "Ŝalti traklakan kaŝadon de kunsendaĵoj kaj antaŭmontroj de ligiloj por konsternaj statoj",
-    "oauth_tokens": "Ĵetonoj de OAuth",
-    "token": "Ĵetono",
-    "refresh_token": "Ĵetono de aktualigo",
+    "oauth_tokens": "Pecoj de OAuth",
+    "token": "Peco",
+    "refresh_token": "Aktualiga peco",
     "valid_until": "Valida ĝis",
     "revoke_token": "Senvalidigi",
     "panelRadius": "Bretoj",
@@ -532,7 +535,21 @@
     "hide_all_muted_posts": "Kaŝi silentigitajn afiŝojn",
     "hide_media_previews": "Kaŝi antaŭrigardojn al vidaŭdaĵoj",
     "word_filter": "Vortofiltro",
-    "reply_visibility_self_short": "Montri nur respondojn por mi"
+    "reply_visibility_self_short": "Montri nur respondojn por mi",
+    "file_export_import": {
+      "errors": {
+        "file_slightly_new": "Etversio de dosiero malsamas, iuj agordoj eble ne funkcios",
+        "file_too_old": "Nekonforma ĉefa versio: {fileMajor}, versio de dosiero estas tro malnova kaj nesubtenata (minimuma estas {feMajor})",
+        "file_too_new": "Nekonforma ĉefa versio: {fileMajor}, ĉi tiu PleromaFE (agordoj je versio {feMajor}) tro malnovas por tio",
+        "invalid_file": "La elektita dosiero ne estas subtenata savkopio de agordoj de Pleroma. Nenio ŝanĝiĝis."
+      },
+      "restore_settings": "Rehavi agordojn el dosiero",
+      "backup_settings_theme": "Savkopii agordojn kaj haŭton al dosiero",
+      "backup_settings": "Savkopii agordojn al dosiero",
+      "backup_restore": "Savkopio de agordoj"
+    },
+    "right_sidebar": "Montri flankan breton dekstre",
+    "save": "Konservi ŝanĝojn"
   },
   "timeline": {
     "collapse": "Maletendi",
@@ -696,7 +713,7 @@
         "media_nsfw": "Devige marki vidaŭdaĵojn konsternaj",
         "media_removal_desc": "Ĉi tiu nodo forigas vidaŭdaĵojn de afiŝoj el la jenaj nodoj:",
         "media_removal": "Forigo de vidaŭdaĵoj",
-        "ftl_removal": "Forigo el la historio de «La tuta konata reto»",
+        "ftl_removal": "Forigo el la historio de «Konata reto»",
         "quarantine_desc": "Ĉi tiu nodo sendos nur publikajn afiŝojn al la jenaj nodoj:",
         "quarantine": "Kvaranteno",
         "reject_desc": "Ĉi tiu nodo ne akceptos mesaĝojn de la jenaj nodoj:",
@@ -704,7 +721,7 @@
         "accept_desc": "Ĉi tiu nodo nur akceptas mesaĝojn de la jenaj nodoj:",
         "accept": "Akcepti",
         "simple_policies": "Specialaj politikoj de la nodo",
-        "ftl_removal_desc": "Ĉi tiu nodo forigas la jenajn nodojn el la historio de «La tuta konata reto»:"
+        "ftl_removal_desc": "Ĉi tiu nodo forigas la jenajn nodojn el la historio de «Konata reto»:"
       },
       "mrf_policies": "Ŝaltis politikon de Mesaĝa ŝanĝilaro (MRF)",
       "keyword": {

From 0e53b2916e4fefdb81244158cd56a944e18fc3d7 Mon Sep 17 00:00:00 2001
From: Ben Is <spambenis@fastwebnet.it>
Date: Mon, 31 May 2021 13:21:27 +0000
Subject: [PATCH 118/169] Translated using Weblate (Italian)

Currently translated at 100.0% (714 of 714 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/it/
---
 src/i18n/it.json | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/i18n/it.json b/src/i18n/it.json
index a4382780..d7179564 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -21,7 +21,10 @@
     "role": {
       "moderator": "Moderatore",
       "admin": "Amministratore"
-    }
+    },
+    "flash_fail": "Contenuto Flash non caricato, vedi console del browser.",
+    "flash_content": "Mostra contenuto Flash tramite Ruffle (funzione in prova).",
+    "flash_security": "Può essere pericoloso perché i contenuti in Flash sono eseguibili."
   },
   "nav": {
     "mentions": "Menzioni",

From 4d529c13ba3a21c3868baf62c79567e7742f9e04 Mon Sep 17 00:00:00 2001
From: Ben Is <spambenis@fastwebnet.it>
Date: Tue, 1 Jun 2021 08:03:38 +0000
Subject: [PATCH 119/169] Translated using Weblate (Italian)

Currently translated at 100.0% (714 of 714 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/it/
---
 src/i18n/it.json | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/i18n/it.json b/src/i18n/it.json
index d7179564..083844ae 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -68,7 +68,7 @@
     "current_avatar": "La tua icona attuale",
     "current_profile_banner": "Il tuo stendardo attuale",
     "filtering": "Filtri",
-    "filtering_explanation": "Tutti i post contenenti queste parole saranno silenziati, una per riga",
+    "filtering_explanation": "Tutti i messaggi contenenti queste parole saranno silenziati, una per riga",
     "hide_attachments_in_convo": "Nascondi gli allegati presenti nelle conversazioni",
     "hide_attachments_in_tl": "Nascondi gli allegati presenti nelle sequenze",
     "name": "Nome",
@@ -664,7 +664,7 @@
   },
   "domain_mute_card": {
     "mute": "Silenzia",
-    "mute_progress": "Silenzio…",
+    "mute_progress": "Procedo…",
     "unmute": "Ascolta",
     "unmute_progress": "Procedo…"
   },

From 33e2bcce31c15113a6d2a69e43522d99ab220a84 Mon Sep 17 00:00:00 2001
From: Kana <gudzpoz@live.com>
Date: Sat, 12 Jun 2021 12:26:46 +0000
Subject: [PATCH 120/169] Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (714 of 714 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/zh_Hans/
---
 src/i18n/zh.json | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/i18n/zh.json b/src/i18n/zh.json
index bee75d84..f1a78c0c 100644
--- a/src/i18n/zh.json
+++ b/src/i18n/zh.json
@@ -43,7 +43,10 @@
     "role": {
       "moderator": "监察员",
       "admin": "管理员"
-    }
+    },
+    "flash_content": "点击以使用 Ruffle 显示 Flash 内容(实验性,可能无效)。",
+    "flash_security": "注意这可能有潜在的危险,因为 Flash 内容仍然是任意的代码。",
+    "flash_fail": "Flash 内容加载失败,请在控制台查看详情。"
   },
   "image_cropper": {
     "crop_picture": "裁剪图片",
@@ -584,7 +587,8 @@
       "backup_settings_theme": "备份设置和主题到文件",
       "backup_settings": "备份设置到文件",
       "backup_restore": "设置备份"
-    }
+    },
+    "right_sidebar": "在右侧显示侧边栏"
   },
   "time": {
     "day": "{0} 天",

From 18871684c7ddb406f6c405512f2aa8cd98bd16cd Mon Sep 17 00:00:00 2001
From: Ben Is <spambenis@fastwebnet.it>
Date: Wed, 16 Jun 2021 18:09:39 +0000
Subject: [PATCH 121/169] Translated using Weblate (Italian)

Currently translated at 100.0% (715 of 715 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/it/
---
 src/i18n/it.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/i18n/it.json b/src/i18n/it.json
index 083844ae..16d419b7 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -447,7 +447,8 @@
       "backup_settings": "Archivia impostazioni localmente",
       "backup_restore": "Archiviazione impostazioni"
     },
-    "right_sidebar": "Mostra barra laterale a destra"
+    "right_sidebar": "Mostra barra laterale a destra",
+    "hide_shoutbox": "Nascondi muro dei graffiti"
   },
   "timeline": {
     "error_fetching": "Errore nell'aggiornamento",

From c509ed357abdd2240251d1f89a8c3c28df8bf96b Mon Sep 17 00:00:00 2001
From: Issabella Deinschnitzel <ghostofcordoba@protonmail.com>
Date: Sat, 19 Jun 2021 13:54:20 +0000
Subject: [PATCH 122/169] Translated using Weblate (German)

Currently translated at 100.0% (715 of 715 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/de/
---
 src/i18n/de.json | 13 +++++++++----
 1 file changed, 9 insertions(+), 4 deletions(-)

diff --git a/src/i18n/de.json b/src/i18n/de.json
index 6655479b..7439f494 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -9,7 +9,7 @@
     "scope_options": "Reichweitenoptionen",
     "text_limit": "Zeichenlimit",
     "title": "Funktionen",
-    "who_to_follow": "Wem folgen?",
+    "who_to_follow": "Vorschläge",
     "upload_limit": "Maximale Upload Größe",
     "pleroma_chat_messages": "Pleroma Chat"
   },
@@ -39,7 +39,10 @@
     "close": "Schliessen",
     "retry": "Versuche es erneut",
     "error_retry": "Bitte versuche es erneut",
-    "loading": "Lade…"
+    "loading": "Lade…",
+    "flash_content": "Klicken, um den Flash-Inhalt mit Ruffle anzuzeigen (Die Funktion ist experimentell und funktioniert daher möglicherweise nicht).",
+    "flash_security": "Diese Funktion stellt möglicherweise eine Risiko dar, weil Flash-Inhalte weiterhin potentiell gefährlich sind.",
+    "flash_fail": "Falsh-Inhalt konnte nicht geladen werden, Details werden in der Konsole angezeigt."
   },
   "login": {
     "login": "Anmelden",
@@ -538,7 +541,9 @@
     "reset_background_confirm": "Hintergrund wirklich zurücksetzen?",
     "reset_banner_confirm": "Banner wirklich zurücksetzen?",
     "reset_avatar_confirm": "Avatar wirklich zurücksetzen?",
-    "reset_profile_banner": "Profilbanner zurücksetzen"
+    "reset_profile_banner": "Profilbanner zurücksetzen",
+    "hide_shoutbox": "Shoutbox der Instanz verbergen",
+    "right_sidebar": "Seitenleiste rechts anzeigen"
   },
   "timeline": {
     "collapse": "Einklappen",
@@ -779,7 +784,7 @@
     "error_sending_message": "Beim Senden der Nachricht ist ein Fehler aufgetreten.",
     "error_loading_chat": "Beim Laden des Chats ist ein Fehler aufgetreten.",
     "delete_confirm": "Soll diese Nachricht wirklich gelöscht werden?",
-    "empty_message_error": "Die Nachricht darf nicht leer sein.",
+    "empty_message_error": "Die Nachricht darf nicht leer sein",
     "delete": "Löschen",
     "message_user": "Nachricht an {nickname} senden",
     "empty_chat_list_placeholder": "Es sind noch keine Chats vorhanden. Jetzt einen Chat starten!",

From 761f91f7ef59a0328c637cea9d239cde331518fd Mon Sep 17 00:00:00 2001
From: ZEN <xinit.info@gmail.com>
Date: Tue, 22 Jun 2021 21:44:11 +0000
Subject: [PATCH 123/169] Translated using Weblate (Ukrainian)

Currently translated at 100.0% (715 of 715 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/uk/
---
 src/i18n/uk.json | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/i18n/uk.json b/src/i18n/uk.json
index 3caeadba..942252f2 100644
--- a/src/i18n/uk.json
+++ b/src/i18n/uk.json
@@ -21,7 +21,10 @@
     "role": {
       "moderator": "Модератор",
       "admin": "Адміністратор"
-    }
+    },
+    "flash_content": "Натисніть для перегляду змісту Flash за допомогою Ruffle (експериментально, може не працювати).",
+    "flash_security": "Ця функція може становити ризик, оскільки Flash-вміст все ще є потенційно небезпечним.",
+    "flash_fail": "Не вдалося завантажити Flash-вміст, докладнішу інформацію дивись у консолі."
   },
   "finder": {
     "error_fetching_user": "Користувача не знайдено",
@@ -634,7 +637,8 @@
       "backup_settings": "Резервне копіювання налаштувань у файл",
       "backup_restore": "Резервне копіювання налаштувань"
     },
-    "right_sidebar": "Показувати бокову панель справа"
+    "right_sidebar": "Показувати бокову панель справа",
+    "hide_shoutbox": "Приховати оголошення інстансу"
   },
   "selectable_list": {
     "select_all": "Вибрати все"

From b3f15fe3e18b4f2b8371da985f8707f8960f9a96 Mon Sep 17 00:00:00 2001
From: retiolus <retiolus@protonmail.com>
Date: Sun, 4 Jul 2021 23:48:23 +0000
Subject: [PATCH 124/169] Translated using Weblate (Catalan)

Currently translated at 44.4% (318 of 715 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ca/
---
 src/i18n/ca.json | 57 ++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 45 insertions(+), 12 deletions(-)

diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index b15b69f7..f2f9cc33 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -10,7 +10,8 @@
     "text_limit": "Límit de text",
     "title": "Funcionalitats",
     "who_to_follow": "A qui seguir",
-    "pleroma_chat_messages": "Xat de Pleroma"
+    "pleroma_chat_messages": "Xat de Pleroma",
+    "upload_limit": "Límit de càrrega"
   },
   "finder": {
     "error_fetching_user": "No s'ha pogut carregar l'usuari/a",
@@ -32,7 +33,16 @@
     "error_retry": "Si us plau, prova de nou",
     "generic_error": "Hi ha hagut un error",
     "loading": "Carregant…",
-    "more": "Més"
+    "more": "Més",
+    "flash_content": "Fes clic per mostrar el contingut Flash utilitzant Ruffle (experimental, pot no funcionar).",
+    "flash_security": "Tingues en compte que això pot ser potencialment perillós, ja que el contingut Flash encara és un codi arbitrari.",
+    "flash_fail": "No s'ha pogut carregar el contingut del flaix, consulta la consola per als detalls.",
+    "role": {
+      "moderator": "Moderador/a",
+      "admin": "Administrador/a"
+    },
+    "dismiss": "Descartar",
+    "peek": "Donar un cop d'ull"
   },
   "login": {
     "login": "Inicia sessió",
@@ -45,15 +55,20 @@
     "enter_recovery_code": "Posa un codi de recuperació",
     "authentication_code": "Codi d'autenticació",
     "hint": "Entra per participar a la conversa",
-    "description": "Entra amb OAuth"
+    "description": "Entra amb OAuth",
+    "heading": {
+      "totp": "Autenticació de dos factors",
+      "recovery": "Recuperació de dos factors"
+    },
+    "enter_two_factor_code": "Introdueix un codi de dos factors"
   },
   "nav": {
     "chat": "Xat local públic",
-    "friend_requests": "Soŀlicituds de connexió",
+    "friend_requests": "Sol·licituds de seguiment",
     "mentions": "Mencions",
-    "public_tl": "Flux públic del node",
+    "public_tl": "Línia temporal pública",
     "timeline": "Flux personal",
-    "twkn": "Flux de la xarxa coneguda",
+    "twkn": "Xarxa coneguda",
     "chats": "Xats",
     "timelines": "Línies de temps",
     "preferences": "Preferències",
@@ -62,10 +77,14 @@
     "dms": "Missatges directes",
     "interactions": "Interaccions",
     "back": "Enrere",
-    "administration": "Administració"
+    "administration": "Administració",
+    "about": "Quant a",
+    "bookmarks": "Marcadors",
+    "user_search": "Cerca d'usuaris",
+    "home_timeline": "Línea temporal personal"
   },
   "notifications": {
-    "broken_favorite": "No es coneix aquest estat. S'està cercant.",
+    "broken_favorite": "Publicació desconeguda, s'està cercant…",
     "favorited_you": "ha marcat un estat teu",
     "followed_you": "ha començat a seguir-te",
     "load_older": "Carrega més notificacions",
@@ -365,7 +384,8 @@
   },
   "importer": {
     "error": "Ha succeït un error mentre s'importava aquest arxiu.",
-    "success": "Importat amb èxit."
+    "success": "Importat amb èxit.",
+    "submit": "Enviar"
   },
   "image_cropper": {
     "cancel": "Cancel·la",
@@ -379,7 +399,9 @@
   },
   "domain_mute_card": {
     "mute_progress": "Silenciant…",
-    "mute": "Silencia"
+    "mute": "Silencia",
+    "unmute": "Deixar de  silenciar",
+    "unmute_progress": "Deixant de silenciar…"
   },
   "about": {
     "staff": "Equip responsable",
@@ -391,16 +413,27 @@
         "reject": "Rebutja",
         "accept_desc": "Aquesta instància només accepta missatges de les següents instàncies:",
         "accept": "Accepta",
-        "simple_policies": "Polítiques específiques de la instància"
+        "simple_policies": "Polítiques específiques de la instància",
+        "ftl_removal_desc": "Aquesta instància elimina aquestes instàncies de la línia de temps coneguda:",
+        "ftl_removal": "Eliminació de la línia de temps coneguda",
+        "media_nsfw_desc": "Aquesta instància obliga el contingut multimèdia a establir-se com a sensible dins de les publicacions en les següents instàncies:",
+        "media_removal": "Eliminació de la multimèdia",
+        "media_removal_desc": "Aquesta instància elimina els suports multimèdia de les publicacions en les següents instàncies:",
+        "media_nsfw": "Forçar contingut multimèdia com a sensible"
       },
       "mrf_policies_desc": "Les polítiques MRF controlen el comportament federat de la instància. Les següents polítiques estan habilitades:",
       "mrf_policies": "Polítiques MRF habilitades",
       "keyword": {
         "replace": "Reemplaça",
         "reject": "Rebutja",
-        "keyword_policies": "Polítiques de paraules clau"
+        "keyword_policies": "Filtratge per paraules clau",
+        "is_replaced_by": "→",
+        "ftl_removal": "Eliminació de la línia de temps federada"
       },
       "federation": "Federació"
     }
+  },
+  "shoutbox": {
+    "title": "Gàbia de Grills"
   }
 }

From 738e7923e4a4c1c797758bd13ed5c1296d28f1e4 Mon Sep 17 00:00:00 2001
From: Ben Is <spambenis@fastwebnet.it>
Date: Sat, 17 Jul 2021 22:15:16 +0000
Subject: [PATCH 125/169] Translated using Weblate (Italian)

Currently translated at 100.0% (715 of 715 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/it/
---
 src/i18n/it.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/i18n/it.json b/src/i18n/it.json
index 16d419b7..d8b483d3 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -706,7 +706,7 @@
   },
   "interactions": {
     "favs_repeats": "Condivisi e Graditi",
-    "load_older": "Carica vecchie interazioni",
+    "load_older": "Carica interazioni precedenti",
     "moves": "Utenti migrati",
     "follows": "Nuovi seguìti"
   },

From f72671a1aa0e8de8ef84a9ff223588947fe7c224 Mon Sep 17 00:00:00 2001
From: Tirifto <tirifto@posteo.cz>
Date: Mon, 19 Jul 2021 04:44:52 +0000
Subject: [PATCH 126/169] Translated using Weblate (Esperanto)

Currently translated at 100.0% (715 of 715 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/eo/
---
 src/i18n/eo.json | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/src/i18n/eo.json b/src/i18n/eo.json
index 3eaa6537..16a904b7 100644
--- a/src/i18n/eo.json
+++ b/src/i18n/eo.json
@@ -39,7 +39,10 @@
     "role": {
       "moderator": "Reguligisto",
       "admin": "Administranto"
-    }
+    },
+    "flash_content": "Klaku por montri enhavon de Flash per Ruffle. (Eksperimente, eble ne funkcios.)",
+    "flash_security": "Sciu, ke tio povas esti danĝera, ĉar la enhavo de Flash ja estas arbitra programo.",
+    "flash_fail": "Malsukcesis enlegi enhavon de Flash; vidu detalojn en konzolo."
   },
   "image_cropper": {
     "crop_picture": "Tondi bildon",
@@ -549,7 +552,8 @@
       "backup_restore": "Savkopio de agordoj"
     },
     "right_sidebar": "Montri flankan breton dekstre",
-    "save": "Konservi ŝanĝojn"
+    "save": "Konservi ŝanĝojn",
+    "hide_shoutbox": "Kaŝi kriujon de nodo"
   },
   "timeline": {
     "collapse": "Maletendi",
@@ -563,7 +567,9 @@
     "no_more_statuses": "Neniuj pliaj statoj",
     "no_statuses": "Neniuj statoj",
     "reload": "Enlegi ree",
-    "error": "Eraris akirado de historio: {0}"
+    "error": "Eraris akirado de historio: {0}",
+    "socket_reconnected": "Realtempa konekto fariĝis",
+    "socket_broke": "Realtempa konekto perdiĝis: CloseEvent code {0}"
   },
   "user_card": {
     "approve": "Aprobi",

From bc08f998cf08ba6a2029364ffb839af09c781d4c Mon Sep 17 00:00:00 2001
From: Ben Is <spambenis@fastwebnet.it>
Date: Sat, 24 Jul 2021 12:38:55 +0000
Subject: [PATCH 127/169] Translated using Weblate (Italian)

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/it/
---
 src/i18n/it.json | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

diff --git a/src/i18n/it.json b/src/i18n/it.json
index d8b483d3..ee872328 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -74,7 +74,7 @@
     "name": "Nome",
     "name_bio": "Nome ed introduzione",
     "nsfw_clickthrough": "Fai click per visualizzare gli allegati offuscati",
-    "profile_background": "Sfondo della tua pagina",
+    "profile_background": "Sfondo del tuo profilo",
     "profile_banner": "Gonfalone del tuo profilo",
     "set_new_avatar": "Scegli una nuova icona",
     "set_new_profile_background": "Scegli un nuovo sfondo",
@@ -368,8 +368,8 @@
     "search_user_to_mute": "Cerca utente da silenziare",
     "search_user_to_block": "Cerca utente da bloccare",
     "autohide_floating_post_button": "Nascondi automaticamente il pulsante di composizione (mobile)",
-    "show_moderator_badge": "Mostra l'insegna di moderatore sulla mia pagina",
-    "show_admin_badge": "Mostra l'insegna di amministratore sulla mia pagina",
+    "show_moderator_badge": "Mostra l'insegna di moderatore sul mio profilo",
+    "show_admin_badge": "Mostra l'insegna di amministratore sul mio profilo",
     "hide_followers_count_description": "Non mostrare quanti seguaci ho",
     "hide_follows_count_description": "Non mostrare quanti utenti seguo",
     "hide_followers_description": "Non mostrare i miei seguaci",
@@ -527,7 +527,8 @@
       "striped": "A righe",
       "solid": "Un colore",
       "disabled": "Nessun risalto"
-    }
+    },
+    "edit_profile": "Modifica profilo"
   },
   "chat": {
     "title": "Chat"

From d3139a92b33444dfcd98ba8b98469008ff8b9804 Mon Sep 17 00:00:00 2001
From: "Haelwenn (lanodan) Monnier"
 <contact+translate.pleroma.social@hacktivis.me>
Date: Sun, 1 Aug 2021 06:49:53 +0000
Subject: [PATCH 128/169] Translated using Weblate (French)

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/fr/
---
 src/i18n/fr.json | 26 ++++++++++++++++++++++----
 1 file changed, 22 insertions(+), 4 deletions(-)

diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index e51657e4..41f54393 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -43,7 +43,10 @@
     "role": {
       "moderator": "Modo'",
       "admin": "Admin"
-    }
+    },
+    "flash_content": "Clique pour afficher le contenu Flash avec Ruffle (Expérimental, peut ne pas fonctionner).",
+    "flash_security": "Cela reste potentiellement dangereux, Flash restant du code arbitraire.",
+    "flash_fail": "Échec de chargement du contenu Flash, voir la console pour les détails."
   },
   "image_cropper": {
     "crop_picture": "Rogner l'image",
@@ -282,7 +285,7 @@
     "new_password": "Nouveau mot de passe",
     "notification_visibility": "Types de notifications à afficher",
     "notification_visibility_follows": "Suivis",
-    "notification_visibility_likes": "J'aime",
+    "notification_visibility_likes": "Favoris",
     "notification_visibility_mentions": "Mentionnés",
     "notification_visibility_repeats": "Partages",
     "no_rich_text_description": "Ne formatez pas le texte",
@@ -553,7 +556,21 @@
     "hide_wallpaper": "Cacher le fond d'écran",
     "hide_all_muted_posts": "Cacher les messages masqués",
     "word_filter": "Filtrage par mots",
-    "save": "Enregistrer les changements"
+    "save": "Enregistrer les changements",
+    "file_export_import": {
+      "backup_settings_theme": "Sauvegarder les paramètres et le thème dans un fichier",
+      "errors": {
+        "invalid_file": "Le fichier sélectionné n'est pas un format supporté pour les sauvegarde Pleroma. Aucun changement n'a été fait.",
+        "file_too_new": "Version majeure incompatible. {fileMajor}, ce PleromaFE ({feMajor}) est trop ancien",
+        "file_too_old": "Version majeure incompatible : {fileMajor}, la version du fichier est trop vielle et n'est plus supportée (vers. min. {feMajor})",
+        "file_slightly_new": "La version mineure du fichier est différente, quelques paramètres on pût ne pas chargés"
+      },
+      "backup_restore": "Sauvegarde des Paramètres",
+      "backup_settings": "Sauvegarder les paramètres dans un fichier",
+      "restore_settings": "Restaurer les paramètres depuis un fichier"
+    },
+    "hide_shoutbox": "Cacher la shoutbox de l'instance",
+    "right_sidebar": "Afficher le paneau latéral à droite"
   },
   "timeline": {
     "collapse": "Fermer",
@@ -663,7 +680,8 @@
       "side": "Coté rayé",
       "striped": "Fond rayé"
     },
-    "bot": "Robot"
+    "bot": "Robot",
+    "edit_profile": "Éditer le profil"
   },
   "user_profile": {
     "timeline_title": "Flux du compte",

From 90d553f4be2d439629b7933ac009bccea6281699 Mon Sep 17 00:00:00 2001
From: Snow <build-a-website@protonmail.com>
Date: Mon, 2 Aug 2021 08:38:17 +0000
Subject: [PATCH 129/169] Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/zh_Hant/
---
 src/i18n/zh_Hant.json | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/src/i18n/zh_Hant.json b/src/i18n/zh_Hant.json
index 8579ebd3..7af2cf39 100644
--- a/src/i18n/zh_Hant.json
+++ b/src/i18n/zh_Hant.json
@@ -115,7 +115,10 @@
     "role": {
       "moderator": "主持人",
       "admin": "管理員"
-    }
+    },
+    "flash_content": "點擊以使用 Ruffle 顯示 Flash 內容(實驗性,可能無效)。",
+    "flash_security": "請注意,這可能有潜在的危險,因為Flash內容仍然是武斷的程式碼。",
+    "flash_fail": "無法加載flash內容,請參閱控制台瞭解詳細資訊。"
   },
   "finder": {
     "find_user": "尋找用戶",
@@ -556,7 +559,9 @@
       "backup_settings": "備份設置到文件",
       "backup_restore": "設定備份"
     },
-    "sensitive_by_default": "默認標記發文為敏感內容"
+    "sensitive_by_default": "默認標記發文為敏感內容",
+    "right_sidebar": "在右側顯示側邊欄",
+    "hide_shoutbox": "隱藏實例留言框"
   },
   "chats": {
     "more": "更多",
@@ -797,7 +802,8 @@
       "striped": "條紋背景",
       "side": "彩條"
     },
-    "bot": "機器人"
+    "bot": "機器人",
+    "edit_profile": "編輯個人資料"
   },
   "user_profile": {
     "timeline_title": "用戶時間線",

From 8574db1cf1326f7164dc87f0933f47335659794a Mon Sep 17 00:00:00 2001
From: "M. Strange" <strangealt@protonmail.com>
Date: Tue, 3 Aug 2021 09:40:05 +0000
Subject: [PATCH 130/169] Translated using Weblate (Catalan)

Currently translated at 56.1% (402 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ca/
---
 src/i18n/ca.json | 108 ++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 98 insertions(+), 10 deletions(-)

diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index f2f9cc33..f6f4b78f 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -93,7 +93,9 @@
     "repeated_you": "ha repetit el teu estat",
     "migrated_to": "migrat a",
     "no_more_notifications": "No més notificacions",
-    "follow_request": "et vol seguir"
+    "follow_request": "et vol seguir",
+    "reacted_with": "ha reaccionat amb {0}",
+    "error": "Error obtenint notificacions: {0}"
   },
   "post_status": {
     "account_not_locked_warning": "El teu compte no està {0}. Qualsevol persona pot seguir-te per llegir les teves entrades reservades només a seguidores.",
@@ -102,7 +104,8 @@
     "content_type": {
       "text/plain": "Text pla",
       "text/markdown": "Markdown",
-      "text/html": "HTML"
+      "text/html": "HTML",
+      "text/bbcode": "BBCode"
     },
     "content_warning": "Assumpte (opcional)",
     "default": "Em sento…",
@@ -119,7 +122,13 @@
       "public": "Aquesta entrada serà visible per a tothom"
     },
     "preview_empty": "Buida",
-    "preview": "Vista prèvia"
+    "preview": "Vista prèvia",
+    "direct_warning_to_first_only": "Aquesta publicació només serà visible per als usuaris mencionats al principi del missatge.",
+    "empty_status_error": "No es pot publicar un estat buit sense fitxers adjunts",
+    "media_description": "Descripció multimèdia",
+    "direct_warning_to_all": "Aquesta publicació serà visible per a tots els usuaris mencionats.",
+    "new_status": "Publicar un nou estat",
+    "post": "Publicació"
   },
   "registration": {
     "bio": "Presentació",
@@ -137,7 +146,10 @@
       "username_required": "no es pot deixar en blanc"
     },
     "fullname_placeholder": "p. ex. Lain Iwakura",
-    "username_placeholder": "p. ex. lain"
+    "username_placeholder": "p. ex. lain",
+    "captcha": "CAPTCHA",
+    "register": "Registrar-se",
+    "reason": "Raó per a registrar-se"
   },
   "settings": {
     "attachmentRadius": "Adjunts",
@@ -273,17 +285,81 @@
     "allow_following_move": "Permet el seguiment automàtic quan un compte a qui seguim es mou",
     "mfa": {
       "scan": {
-        "secret_code": "Clau"
+        "secret_code": "Clau",
+        "title": "Escanejar"
       },
       "authentication_methods": "Mètodes d'autenticació",
       "waiting_a_recovery_codes": "Rebent còpies de seguretat dels codis…",
       "recovery_codes": "Codis de recuperació.",
       "warning_of_generate_new_codes": "Quan generes nous codis de recuperació, els antics ja no funcionaran més.",
-      "generate_new_recovery_codes": "Genera nous codis de recuperació"
+      "generate_new_recovery_codes": "Genera nous codis de recuperació",
+      "otp": "OTP",
+      "confirm_and_enable": "Confirmar i habilitar OTP",
+      "recovery_codes_warning": "Anote els codis o guarda'ls en un lloc segur, o no els veuràs una altra volta. Si perds l'accés a la teua aplicació 2FA i els codis de recuperació, no podràs accedir al compte.",
+      "title": "Autenticació de dos factors",
+      "setup_otp": "Configurar OTP"
     },
     "enter_current_password_to_confirm": "Posar la contrasenya actual per confirmar la teva identitat",
     "security": "Seguretat",
-    "app_name": "Nom de l'aplicació"
+    "app_name": "Nom de l'aplicació",
+    "subject_line_mastodon": "Com a mastodon: copiar com és",
+    "mute_export_button": "Exportar silenciats a un fitxer csv",
+    "mute_import_error": "Error al importar silenciats",
+    "mutes_imported": "Silenciats importats! Processar-los portarà una estona.",
+    "import_mutes_from_a_csv_file": "Importar silenciats des d'un fitxer csv",
+    "word_filter": "Filtre de paraules",
+    "hide_media_previews": "Ocultar les vistes prèvies multimèdia",
+    "hide_filtered_statuses": "Amagar estats filtrats",
+    "play_videos_in_modal": "Reproduir vídeos en un marc emergent",
+    "file_export_import": {
+      "errors": {
+        "invalid_file": "El fitxer seleccionat no és vàlid com a còpia de seguretat de la configuració. No s'ha realitzat cap canvi."
+      },
+      "backup_settings": "Còpia de seguretat de la configuració a un fitxer",
+      "backup_settings_theme": "Còpia de seguretat de la configuració i tema a un fitxer",
+      "restore_settings": "Restaurar configuració des d'un fitxer",
+      "backup_restore": "Còpia de seguretat de la configuració"
+    },
+    "user_mutes": "Usuaris",
+    "subject_line_email": "Com a l'email: \"re: tema\"",
+    "search_user_to_block": "Busca a qui vols bloquejar",
+    "save": "Guardar els canvis",
+    "use_contain_fit": "No retallar els adjunts en miniatures",
+    "reset_profile_background": "Restablir fons del perfil",
+    "reset_profile_banner": "Restablir banner del perfil",
+    "emoji_reactions_on_timeline": "Mostrar reaccions emoji al flux",
+    "max_thumbnails": "Quantitat màxima de miniatures per publicació",
+    "hide_user_stats": "Amagar les estadístiques de l'usuari (p. ex. el nombre de seguidors)",
+    "reset_banner_confirm": "Realment vols restablir el banner?",
+    "reset_background_confirm": "Realment vols restablir el fons del perfil?",
+    "subject_input_always_show": "Sempre mostrar el camp del tema",
+    "subject_line_noop": "No copiar",
+    "subject_line_behavior": "Copiar el tema a les respostes",
+    "search_user_to_mute": "Busca a qui vols silenciar",
+    "mute_export": "Exportar silenciats",
+    "scope_copy": "Copiar visibilitat quan contestes (En els missatges directes sempre es copia)",
+    "reset_avatar": "Restablir avatar",
+    "right_sidebar": "Mostrar barra lateral a la dreta",
+    "no_blocks": "No hi han bloquejats",
+    "no_mutes": "No hi han silenciats",
+    "hide_follows_count_description": "No mostrar el nombre de comptes que segueixo",
+    "mute_import": "Importar silenciats",
+    "hide_all_muted_posts": "Ocultar publicacions silenciades",
+    "hide_wallpaper": "Amagar el fons de la instància",
+    "notification_visibility_moves": "Usuari Migrat",
+    "reply_visibility_following_short": "Mostrar respostes als meus seguidors",
+    "reply_visibility_self_short": "Mostrar respostes només a un mateix",
+    "autohide_floating_post_button": "Ocultar automàticament el botó 'Nova Publicació' (mòbil)",
+    "minimal_scopes_mode": "Minimitzar les opcions de visibilitat de la publicació",
+    "sensitive_by_default": "Marcar publicacions com a sensibles per defecte",
+    "useStreamingApi": "Rebre publicacions i notificacions en temps real",
+    "hide_isp": "Ocultar el panell especific de la instància",
+    "preload_images": "Precarregar les imatges",
+    "setting_changed": "La configuració és diferent a la predeterminada",
+    "hide_followers_count_description": "No mostrar el nombre de seguidors",
+    "reset_avatar_confirm": "Realment vols restablir l'avatar?",
+    "accent": "Accent",
+    "useStreamingApiWarning": "(No recomanat, experimental, pot ometre publicacions)"
   },
   "time": {
     "day": "{0} dia",
@@ -361,10 +437,19 @@
   },
   "interactions": {
     "load_older": "Carrega antigues interaccions",
-    "favs_repeats": "Repeticions i favorits"
+    "favs_repeats": "Repeticions i favorits",
+    "follows": "Nous seguidors"
   },
   "emoji": {
-    "stickers": "Adhesius"
+    "stickers": "Adhesius",
+    "keep_open": "Mantindre el selector obert",
+    "custom": "Emojis personalitzats",
+    "unicode": "Emojis unicode",
+    "load_all_hint": "Carregat el primer emoji {saneAmount}, carregar tots els emoji pot causar problemes de rendiment.",
+    "emoji": "Emoji",
+    "search_emoji": "Buscar un emoji",
+    "add_emoji": "Inserir un emoji",
+    "load_all": "Carregant tots els {emojiAmount} emoji"
   },
   "polls": {
     "expired": "L'enquesta va acabar fa {0}",
@@ -376,7 +461,10 @@
     "votes": "vots",
     "option": "Opció",
     "add_option": "Afegeix opció",
-    "add_poll": "Afegeix enquesta"
+    "add_poll": "Afegeix enquesta",
+    "expiry": "Temps de vida de l'enquesta",
+    "people_voted_count": "{count} persona ha votat | {count} persones han votat",
+    "votes_count": "{count} vot | {count} vots"
   },
   "media_modal": {
     "next": "Següent",

From 791293c70921cde17ee0b0f772bdcd4440f104a0 Mon Sep 17 00:00:00 2001
From: ZEN <xinit.info@gmail.com>
Date: Wed, 4 Aug 2021 20:02:34 +0000
Subject: [PATCH 131/169] Translated using Weblate (Ukrainian)

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/uk/
---
 src/i18n/uk.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/i18n/uk.json b/src/i18n/uk.json
index 942252f2..10a7375f 100644
--- a/src/i18n/uk.json
+++ b/src/i18n/uk.json
@@ -804,7 +804,8 @@
       "solid": "Суцільний фон",
       "disabled": "Не виділяти"
     },
-    "bot": "Бот"
+    "bot": "Бот",
+    "edit_profile": "Редагувати профіль"
   },
   "status": {
     "copy_link": "Скопіювати посилання на допис",

From c38ab7234d8738810abc0c1c4ec33c10624c417e Mon Sep 17 00:00:00 2001
From: titizen <rita@gamifi.cat>
Date: Sun, 8 Aug 2021 10:28:04 +0000
Subject: [PATCH 132/169] Translated using Weblate (Catalan)

Currently translated at 92.0% (659 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ca/
---
 src/i18n/ca.json | 172 ++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 162 insertions(+), 10 deletions(-)

diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index f6f4b78f..3d479e60 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -119,7 +119,8 @@
     },
     "scope_notice": {
       "private": "Aquesta entrada serà visible només per a qui et segueixi",
-      "public": "Aquesta entrada serà visible per a tothom"
+      "public": "Aquesta entrada serà visible per a tothom",
+      "unlisted": "Aquesta entrada no es veurà ni a la Línia de temps local ni a la Línia de temps federada"
     },
     "preview_empty": "Buida",
     "preview": "Vista prèvia",
@@ -128,7 +129,8 @@
     "media_description": "Descripció multimèdia",
     "direct_warning_to_all": "Aquesta publicació serà visible per a tots els usuaris mencionats.",
     "new_status": "Publicar un nou estat",
-    "post": "Publicació"
+    "post": "Publicació",
+    "media_description_error": "Ha fallat la pujada del contingut. Prova de nou"
   },
   "registration": {
     "bio": "Presentació",
@@ -149,7 +151,10 @@
     "username_placeholder": "p. ex. lain",
     "captcha": "CAPTCHA",
     "register": "Registrar-se",
-    "reason": "Raó per a registrar-se"
+    "reason": "Raó per a registrar-se",
+    "bio_placeholder": "p.e.\nHola, sóc la Lain.\nSóc una noia anime que viu a un suburbi de Japó. Potser em coneixes per Wired.",
+    "reason_placeholder": "Aquesta instància aprova els registres manualment.\nExplica a l'administració per què vols registrar-te.",
+    "new_captcha": "Clica a la imatge per obtenir un nou captcha"
   },
   "settings": {
     "attachmentRadius": "Adjunts",
@@ -286,7 +291,8 @@
     "mfa": {
       "scan": {
         "secret_code": "Clau",
-        "title": "Escanejar"
+        "title": "Escanejar",
+        "desc": "S'està usant l'aplicació two-factor, escaneja aquest codi QR o introdueix la clau de text:"
       },
       "authentication_methods": "Mètodes d'autenticació",
       "waiting_a_recovery_codes": "Rebent còpies de seguretat dels codis…",
@@ -297,7 +303,11 @@
       "confirm_and_enable": "Confirmar i habilitar OTP",
       "recovery_codes_warning": "Anote els codis o guarda'ls en un lloc segur, o no els veuràs una altra volta. Si perds l'accés a la teua aplicació 2FA i els codis de recuperació, no podràs accedir al compte.",
       "title": "Autenticació de dos factors",
-      "setup_otp": "Configurar OTP"
+      "setup_otp": "Configurar OTP",
+      "wait_pre_setup_otp": "preconfiguració OTP",
+      "verify": {
+        "desc": "Per habilitar l'autenticació two-factor, introdueix el codi des de la teva aplicació two-factor:"
+      }
     },
     "enter_current_password_to_confirm": "Posar la contrasenya actual per confirmar la teva identitat",
     "security": "Seguretat",
@@ -359,7 +369,34 @@
     "hide_followers_count_description": "No mostrar el nombre de seguidors",
     "reset_avatar_confirm": "Realment vols restablir l'avatar?",
     "accent": "Accent",
-    "useStreamingApiWarning": "(No recomanat, experimental, pot ometre publicacions)"
+    "useStreamingApiWarning": "(No recomanat, experimental, pot ometre publicacions)",
+    "style": {
+      "fonts": {
+        "family": "Nom de la font",
+        "size": "Mida (en píxels)",
+        "custom": "Personalitza"
+      },
+      "preview": {
+        "input": "Acabo d'aterrar a Los Angeles.",
+        "button": "Botó",
+        "mono": "contingut",
+        "content": "Contingut",
+        "header": "Previsualització",
+        "header_faint": "Això està bé",
+        "error": "Exemple d'error",
+        "faint_link": "Manual d'ajuda",
+        "checkbox": "He llegit els termes i condicions"
+      },
+      "shadows": {
+        "spread": "Difon"
+      }
+    },
+    "version": {
+      "frontend_version": "Versió \"Frontend\"",
+      "backend_version": "Versió \"backend\"",
+      "title": "Versió"
+    },
+    "theme_help_v2_1": "També pots anular alguns components de color i opacitat activant la casella. Usa el botó \"Esborrar tot\" per esborrar totes les anulacions."
   },
   "time": {
     "day": "{0} dia",
@@ -403,7 +440,13 @@
     "no_retweet_hint": "L'entrada és només per a seguidores o és \"directa\", i per tant no es pot republicar",
     "repeated": "republicat",
     "show_new": "Mostra els nous",
-    "up_to_date": "Actualitzat"
+    "up_to_date": "Actualitzat",
+    "socket_reconnected": "Connexió a temps real establerta",
+    "socket_broke": "Connexió a temps real perduda: codi CloseEvent {0}",
+    "error": "Error de càrrega de la línia de temps: {0}",
+    "no_statuses": "No hi ha entrades",
+    "reload": "Recarrega",
+    "no_more_statuses": "No hi ha més entrades"
   },
   "user_card": {
     "approve": "Aprova",
@@ -419,10 +462,49 @@
     "muted": "Silenciat",
     "per_day": "per dia",
     "remote_follow": "Seguiment remot",
-    "statuses": "Estats"
+    "statuses": "Estats",
+    "unblock_progress": "Desbloquejant…",
+    "unmute": "Deixa de silenciar",
+    "follow_progress": "Sol·licitant…",
+    "admin_menu": {
+      "force_nsfw": "Marca totes les entrades amb \"No segur per a entorns laborals\"",
+      "strip_media": "Esborra els audiovisuals de les entrades",
+      "disable_any_subscription": "Deshabilita completament seguir algú",
+      "quarantine": "Deshabilita la federació a les entrades de les usuàries",
+      "moderation": "Moderació",
+      "delete_user_confirmation": "Estàs completament segur/a? Aquesta acció no es pot desfer.",
+      "revoke_admin": "Revoca l'Admin",
+      "activate_account": "Activa el compte",
+      "deactivate_account": "Desactiva el compte",
+      "revoke_moderator": "Revoca Moderació",
+      "delete_account": "Esborra el compte",
+      "disable_remote_subscription": "Deshabilita seguir algú des d'una instància remota",
+      "delete_user": "Esborra la usuària"
+    },
+    "edit_profile": "Edita el perfil",
+    "follow_again": "Envia de nou la petició?",
+    "hidden": "Amagat",
+    "follow_sent": "Petició enviada!",
+    "unmute_progress": "Deixant de silenciar…",
+    "bot": "Bot",
+    "mute_progress": "Silenciant…",
+    "favorites": "Favorits",
+    "mention": "Menció",
+    "follow_unfollow": "Deixa de seguir",
+    "subscribe": "Subscriu-te",
+    "show_repeats": "Mostra les repeticions",
+    "report": "Report",
+    "its_you": "Ets tu!",
+    "unblock": "Desbloqueja",
+    "block_progress": "Bloquejant…",
+    "message": "Missatge",
+    "unsubscribe": "Anul·la la subscripció",
+    "hide_repeats": "Amaga les repeticions"
   },
   "user_profile": {
-    "timeline_title": "Flux personal"
+    "timeline_title": "Flux personal",
+    "profile_loading_error": "Disculpes, hi ha hagut un error carregant aquest perfil.",
+    "profile_does_not_exist": "Disculpes, aquest perfil no existeix."
   },
   "who_to_follow": {
     "more": "More",
@@ -464,7 +546,8 @@
     "add_poll": "Afegeix enquesta",
     "expiry": "Temps de vida de l'enquesta",
     "people_voted_count": "{count} persona ha votat | {count} persones han votat",
-    "votes_count": "{count} vot | {count} vots"
+    "votes_count": "{count} vot | {count} vots",
+    "not_enough_options": "Massa poques opcions úniques a l'enquesta"
   },
   "media_modal": {
     "next": "Següent",
@@ -523,5 +606,74 @@
   },
   "shoutbox": {
     "title": "Gàbia de Grills"
+  },
+  "status": {
+    "delete": "Esborra l'entrada",
+    "delete_confirm": "Segur que vols esborrar aquesta entrada?",
+    "thread_muted_and_words": ", té les paraules:",
+    "show_full_subject": "Mostra tot el tema",
+    "show_content": "Mostra el contingut",
+    "repeats": "Repeticions",
+    "bookmark": "Marcadors",
+    "status_unavailable": "Entrada no disponible",
+    "expand": "Expandeix",
+    "copy_link": "Copia l'enllaç a l'entrada",
+    "hide_full_subject": "Amaga tot el tema",
+    "favorites": "Favorits",
+    "replies_list": "Contestacions:",
+    "mute_conversation": "Silencia la conversa",
+    "thread_muted": "Fil silenciat",
+    "hide_content": "Amaga el contingut",
+    "status_deleted": "S'ha esborrat aquesta entrada",
+    "nsfw": "No segur per a entorns laborals",
+    "unbookmark": "Desmarca",
+    "external_source": "Font externa",
+    "unpin": "Deixa de destacar al perfil",
+    "pinned": "Destacat",
+    "reply_to": "Contesta a",
+    "pin": "Destaca al perfil",
+    "unmute_conversation": "Deixa de silenciar la conversa"
+  },
+  "user_reporting": {
+    "additional_comments": "Comentaris addicionals",
+    "forward_description": "Aquest compte és d'un altre servidor. Vols enviar una còpia del report allà també?",
+    "forward_to": "Endavant a {0}",
+    "generic_error": "Hi ha hagut un error mentre s'estava processant la teva sol·licitud.",
+    "title": "Reportant {0}",
+    "add_comment_description": "Aquest report serà enviat a la moderació a la instància. Pots donar una explicació de per què estàs reportant aquest compte:",
+    "submit": "Envia"
+  },
+  "tool_tip": {
+    "add_reaction": "Afegeix una Reacció",
+    "accept_follow_request": "Accepta la sol·licitud de seguir",
+    "repeat": "Repeteix",
+    "reply": "Respon",
+    "favorite": "Favorit",
+    "user_settings": "Configuració d'usuària",
+    "reject_follow_request": "Rebutja la sol·licitud de seguir",
+    "bookmark": "Marcador"
+  },
+  "search": {
+    "no_results": "No hi ha resultats",
+    "people": "Persones",
+    "hashtags": "Etiquetes"
+  },
+  "upload": {
+    "file_size_units": {
+      "B": "B"
+    },
+    "error": {
+      "base": "La pujada ha fallat.",
+      "file_too_big": "Fitxer massa gran [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+      "default": "Prova de nou d'aquí una estona",
+      "message": "La pujada ha fallat: {0}"
+    }
+  },
+  "errors": {
+    "storage_unavailable": "Pleroma no ha pogut accedir a l'emmagatzematge del navegador. El teu inici de sessió o configuració no es desaran i et pots trobar algun altre problema. Prova a habilitar les galetes."
+  },
+  "password_reset": {
+    "password_reset": "Reinicia la contrasenya",
+    "forgot_password": "Has oblidat la contrasenya?"
   }
 }

From 1ef2bb93fe77e75b04b182f83228164febd7b75c Mon Sep 17 00:00:00 2001
From: "M. Strange" <strangealt@protonmail.com>
Date: Sat, 7 Aug 2021 14:09:00 +0000
Subject: [PATCH 133/169] Translated using Weblate (Catalan)

Currently translated at 92.0% (659 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ca/
---
 src/i18n/ca.json | 185 ++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 160 insertions(+), 25 deletions(-)

diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index 3d479e60..fe9b3b9d 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -15,7 +15,7 @@
   },
   "finder": {
     "error_fetching_user": "No s'ha pogut carregar l'usuari/a",
-    "find_user": "Find user"
+    "find_user": "Trobar usuari"
   },
   "general": {
     "apply": "Aplica",
@@ -89,7 +89,7 @@
     "followed_you": "ha començat a seguir-te",
     "load_older": "Carrega més notificacions",
     "notifications": "Notificacions",
-    "read": "Read!",
+    "read": "Llegit!",
     "repeated_you": "ha repetit el teu estat",
     "migrated_to": "migrat a",
     "no_more_notifications": "No més notificacions",
@@ -108,14 +108,14 @@
       "text/bbcode": "BBCode"
     },
     "content_warning": "Assumpte (opcional)",
-    "default": "Em sento…",
+    "default": "Acabe d'aterrar a L.A.",
     "direct_warning": "Aquesta entrada només serà visible per les usuràries que etiquetis",
     "posting": "Publicació",
     "scope": {
-      "direct": "Directa - Publica només per les usuàries etiquetades",
-      "private": "Només seguidors/es - Publica només per comptes que et segueixin",
-      "public": "Pública - Publica als fluxos públics",
-      "unlisted": "Silenciosa - No la mostris en fluxos públics"
+      "direct": "Directa - publica només per als usuaris etiquetats",
+      "private": "Només seguidors/es - publica només per comptes que et segueixin",
+      "public": "Pública - publica als fluxos públics",
+      "unlisted": "Silenciosa - no la mostris en fluxos públics"
     },
     "scope_notice": {
       "private": "Aquesta entrada serà visible només per a qui et segueixi",
@@ -160,7 +160,7 @@
     "attachmentRadius": "Adjunts",
     "attachments": "Adjunts",
     "avatar": "Avatar",
-    "avatarAltRadius": "Avatars en les notificacions",
+    "avatarAltRadius": "Avatars (notificacions)",
     "avatarRadius": "Avatars",
     "background": "Fons de pantalla",
     "bio": "Presentació",
@@ -170,8 +170,8 @@
     "cOrange": "Taronja (marca com a preferit)",
     "cRed": "Vermell (canceŀla)",
     "change_password": "Canvia la contrasenya",
-    "change_password_error": "No s'ha pogut canviar la contrasenya",
-    "changed_password": "S'ha canviat la contrasenya",
+    "change_password_error": "No s'ha pogut canviar la contrasenya.",
+    "changed_password": "S'ha canviat la contrasenya correctament!",
     "collapse_subject": "Replega les entrades amb títol",
     "confirm_new_password": "Confirma la nova contrasenya",
     "current_avatar": "L'avatar actual",
@@ -212,7 +212,7 @@
     "new_password": "Contrasenya nova",
     "notification_visibility": "Notifica'm quan algú",
     "notification_visibility_follows": "Comença a seguir-me",
-    "notification_visibility_likes": "Marca com a preferida una entrada meva",
+    "notification_visibility_likes": "Favorits",
     "notification_visibility_mentions": "Em menciona",
     "notification_visibility_repeats": "Republica una entrada meva",
     "no_rich_text_description": "Neteja el formatat de text de totes les entrades",
@@ -229,7 +229,7 @@
     "profile_banner": "Fons de perfil",
     "profile_tab": "Perfil",
     "radii_help": "Configura l'arrodoniment de les vores (en píxels)",
-    "replies_in_timeline": "Replies in timeline",
+    "replies_in_timeline": "Respostes al flux",
     "reply_visibility_all": "Mostra totes les respostes",
     "reply_visibility_following": "Mostra només les respostes a entrades meves o d'usuàries que jo segueixo",
     "reply_visibility_self": "Mostra només les respostes a entrades meves",
@@ -252,7 +252,7 @@
       "true": "sí"
     },
     "show_moderator_badge": "Mostra una insígnia de Moderació en el meu perfil",
-    "show_admin_badge": "Mostra una insígnia d'Administració en el meu perfil",
+    "show_admin_badge": "Mostra una insígnia \"d'Administració\" en el meu perfil",
     "hide_followers_description": "No mostris qui m'està seguint",
     "hide_follows_description": "No mostris a qui segueixo",
     "notification_visibility_emoji_reactions": "Reaccions",
@@ -374,7 +374,15 @@
       "fonts": {
         "family": "Nom de la font",
         "size": "Mida (en píxels)",
-        "custom": "Personalitza"
+        "custom": "Personalitza",
+        "_tab_label": "Fonts",
+        "help": "Selecciona la font per als elements de la interfície. Per a \"personalitzat\" deus escriure el nom de la font exactament com apareix al sistema.",
+        "components": {
+          "post": "Text de les publicacions",
+          "postCode": "Text monoespai en publicació (text enriquit)",
+          "input": "Camps d'entrada",
+          "interface": "Interfície"
+        }
       },
       "preview": {
         "input": "Acabo d'aterrar a Los Angeles.",
@@ -388,7 +396,119 @@
         "checkbox": "He llegit els termes i condicions"
       },
       "shadows": {
-        "spread": "Difon"
+        "spread": "Difon",
+        "filter_hint": {
+          "drop_shadow_syntax": "{0} no suporta el paràmetre {1} i la paraula clau {2}.",
+          "avatar_inset": "Tingues en compte que combinar ombres interiors i no interiors als avatars podria donar resultats inesperats amb avatars transparents.",
+          "inset_classic": "Les ombres interiors estaran usant {0}",
+          "always_drop_shadow": "Advertència, aquesta ombra sempre utilitza {0} quan el navegador ho suporta.",
+          "spread_zero": "Ombres amb propagació > 0 apareixeran com si estigueren posades a zero"
+        },
+        "components": {
+          "popup": "Texts i finestres emergents (popups & tooltips)",
+          "panel": "Panell",
+          "panelHeader": "Capçalera del panell",
+          "avatar": "Avatar de l'usuari (en vista de perfil)",
+          "input": "Camp d'entrada",
+          "buttonHover": "Botó (surant)",
+          "buttonPressed": "Botó (pressionat)",
+          "topBar": "Barra superior",
+          "buttonPressedHover": "Botó (surant i pressionat)",
+          "avatarStatus": "Avatar de l'usuari (en vista de publicació)",
+          "button": "Botó"
+        },
+        "hintV3": "per a les ombres també pots usar la notació {0} per a utilitzar un altre espai de color.",
+        "blur": "Difuminat",
+        "component": "Component",
+        "override": "Sobreescriure",
+        "shadow_id": "Ombra #{value}",
+        "_tab_label": "Ombra i il·luminació",
+        "inset": "Ombra interior"
+      },
+      "switcher": {
+        "use_snapshot": "Versió antiga",
+        "help": {
+          "future_version_imported": "El fitxer importat es va crear per a una versió del front-end més recent.",
+          "migration_snapshot_ok": "Per a estar segurs, s'ha carregat la instantània del tema. Pots intentar carregar les dades del tema.",
+          "migration_napshot_gone": "Per alguna raó, faltava la instantània, algunes coses podrien veure's diferents del que recordes.",
+          "snapshot_source_mismatch": "Conflicte de versions: probablement el front-end s'ha revertit i actualitzat una altra volta, si has canviat el tema en una versió anterior, segurament vols utilitzar la versió antiga; d'altra banda utilitza la nova versió.",
+          "v2_imported": "El fitxer que has importat va ser creat per a un front-end més antic. Intentem maximitzar la compatibilitat, però podrien haver inconsistències.",
+          "fe_upgraded": "El motor de temes de PleromaFE es va actualitzar després de l'actualització de la versió.",
+          "snapshot_missing": "No hi havia cap instantània del tema al fitxer, per tant podria veure's diferent del previst originalment.",
+          "upgraded_from_v2": "PleromaFE s'ha actualitzat, el tema pot veure's un poc diferent de com recordes.",
+          "fe_downgraded": "Versió de PleromaFE revertida.",
+          "older_version_imported": "El fitxer que has importat va ser creat en una versió del front-end més antiga."
+        },
+        "keep_as_is": "Mantindre com està",
+        "save_load_hint": "Les opcions \"Mantindre\" conserven les opcions configurades actualment al seleccionar o carregar temes, també emmagatzema aquestes opcions quan s'exporta un tema. Quan es desactiven totes les caselles de verificació, el tema exportat ho guardarà tot.",
+        "keep_color": "Mantindre colors",
+        "keep_opacity": "Mantindre opacitat",
+        "keep_shadows": "Mantindre ombres",
+        "keep_fonts": "Mantindre fonts",
+        "keep_roundness": "Mantindre rodoneses",
+        "clear_all": "Netejar tot",
+        "reset": "Reinciar",
+        "load_theme": "Carregar tema",
+        "use_source": "Nova versió",
+        "clear_opacity": "Netejar opacitat"
+      },
+      "common": {
+        "contrast": {
+          "hint": "El ràtio de contrast és {ratio}. {level} {context}",
+          "level": {
+            "bad": "no compleix amb cap pauta d'accecibilitat",
+            "aaa": "Compleix amb el nivell AA (recomanat)",
+            "aa": "Compleix amb el nivell AA (mínim)"
+          },
+          "context": {
+            "18pt": "per a textos grans (+18pt)",
+            "text": "per a textos"
+          }
+        },
+        "opacity": "Opacitat",
+        "color": "Color"
+      },
+      "advanced_colors": {
+        "badge": "Fons de insígnies",
+        "inputs": "Camps d'entrada",
+        "wallpaper": "Fons de pantalla",
+        "pressed": "Pressionat",
+        "chat": {
+          "outgoing": "Eixint",
+          "border": "Borde",
+          "incoming": "Entrants"
+        },
+        "borders": "Bordes",
+        "panel_header": "Capçalera del panell",
+        "buttons": "Botons",
+        "faint_text": "Text esvaït",
+        "poll": "Gràfica de l'enquesta",
+        "toggled": "Commutat",
+        "alert": "Fons d'alertes",
+        "alert_error": "Error",
+        "alert_warning": "Precaució",
+        "post": "Publicacions/Biografies d'usuaris",
+        "badge_notification": "Notificacions",
+        "selectedMenu": "Element del menú seleccionat",
+        "tabs": "Pestanyes",
+        "_tab_label": "Avançat",
+        "alert_neutral": "Neutral",
+        "popover": "Suggeriments, menús, superposicions",
+        "top_bar": "Barra superior",
+        "highlight": "Elements destacats",
+        "disabled": "Deshabilitat",
+        "icons": "Icones",
+        "selectedPost": "Publicació seleccionada",
+        "underlay": "Subratllat"
+      },
+      "common_colors": {
+        "main": "Colors comuns",
+        "rgbo": "Icones, accents, insígnies",
+        "foreground_hint": "mira la pestanya \"Avançat\" per a un control més detallat",
+        "_tab_label": "Comú"
+      },
+      "radii": {
+        "_tab_label": "Rodonesa"
       }
     },
     "version": {
@@ -396,15 +516,30 @@
       "backend_version": "Versió \"backend\"",
       "title": "Versió"
     },
-    "theme_help_v2_1": "També pots anular alguns components de color i opacitat activant la casella. Usa el botó \"Esborrar tot\" per esborrar totes les anulacions."
+    "theme_help_v2_1": "També pots anular alguns components de color i opacitat activant la casella. Usa el botó \"Esborrar tot\" per esborrar totes les anulacions.",
+    "type_domains_to_mute": "Buscar dominis per a silenciar",
+    "greentext": "Text verd (meme arrows)",
+    "fun": "Divertit",
+    "notification_setting_filters": "Filtres",
+    "virtual_scrolling": "Optimitzar la representació del flux",
+    "notification_setting_block_from_strangers": "Bloqueja les notificacions dels usuaris que no segueixes",
+    "enable_web_push_notifications": "Habilitar notificacions del navegador",
+    "notification_blocks": "Bloquejar a un usuari para totes les notificacions i també les cancel·la.",
+    "more_settings": "Més opcions",
+    "notification_setting_privacy": "Privacitat",
+    "upload_a_photo": "Pujar una foto",
+    "notification_setting_hide_notification_contents": "Amagar el remitent i els continguts de les notificacions push",
+    "notifications": "Notificacions",
+    "notification_mutes": "Per a deixar de rebre notificacions d'un usuari en concret, silencia'l-ho.",
+    "theme_help_v2_2": "Les icones per baix d'algunes entrades són indicadors del contrast del fons/text, desplaça el ratolí per a més informació. Tingues en compte que quan s'utilitzen indicadors de contrast de transparència es mostra el pitjor cas possible."
   },
   "time": {
     "day": "{0} dia",
     "days": "{0} dies",
     "day_short": "{0} dia",
     "days_short": "{0} dies",
-    "hour": "{0} hour",
-    "hours": "{0} hours",
+    "hour": "{0} hora",
+    "hours": "{0} hores",
     "hour_short": "{0}h",
     "hours_short": "{0}h",
     "in_future": "in {0}",
@@ -419,12 +554,12 @@
     "months_short": "{0} mesos",
     "now": "ara mateix",
     "now_short": "ara mateix",
-    "second": "{0} second",
-    "seconds": "{0} seconds",
+    "second": "{0} segon",
+    "seconds": "{0} segons",
     "second_short": "{0}s",
     "seconds_short": "{0}s",
-    "week": "{0} setm.",
-    "weeks": "{0} setm.",
+    "week": "{0} setmana",
+    "weeks": "{0} setmanes",
     "week_short": "{0} setm.",
     "weeks_short": "{0} setm.",
     "year": "{0} any",
@@ -507,7 +642,7 @@
     "profile_does_not_exist": "Disculpes, aquest perfil no existeix."
   },
   "who_to_follow": {
-    "more": "More",
+    "more": "Més",
     "who_to_follow": "A qui seguir"
   },
   "selectable_list": {
@@ -571,7 +706,7 @@
   "domain_mute_card": {
     "mute_progress": "Silenciant…",
     "mute": "Silencia",
-    "unmute": "Deixar de  silenciar",
+    "unmute": "Deixar de silenciar",
     "unmute_progress": "Deixant de silenciar…"
   },
   "about": {
@@ -585,7 +720,7 @@
         "accept_desc": "Aquesta instància només accepta missatges de les següents instàncies:",
         "accept": "Accepta",
         "simple_policies": "Polítiques específiques de la instància",
-        "ftl_removal_desc": "Aquesta instància elimina aquestes instàncies de la línia de temps coneguda:",
+        "ftl_removal_desc": "Aquesta instància elimina les següents instàncies del flux de la xarxa coneguda:",
         "ftl_removal": "Eliminació de la línia de temps coneguda",
         "media_nsfw_desc": "Aquesta instància obliga el contingut multimèdia a establir-se com a sensible dins de les publicacions en les següents instàncies:",
         "media_removal": "Eliminació de la multimèdia",

From a3bfa63d05b5bff8ccb7e643161b529cad911718 Mon Sep 17 00:00:00 2001
From: titizen <rita@gamifi.cat>
Date: Sun, 8 Aug 2021 11:03:11 +0000
Subject: [PATCH 134/169] Translated using Weblate (Catalan)

Currently translated at 95.3% (683 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ca/
---
 src/i18n/ca.json | 32 +++++++++++++++++++++++++++++++-
 1 file changed, 31 insertions(+), 1 deletion(-)

diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index fe9b3b9d..6e9bf5d6 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -809,6 +809,36 @@
   },
   "password_reset": {
     "password_reset": "Reinicia la contrasenya",
-    "forgot_password": "Has oblidat la contrasenya?"
+    "forgot_password": "Has oblidat la contrasenya?",
+    "too_many_requests": "Has arribat al límit d'intents. Prova de nou d'aquí una estona.",
+    "password_reset_required_but_mailer_is_disabled": "Has de reiniciar la teva contrasenya però el reinici de la contrasenya està deshabilitat. Si us plau, contacta l'administració de la teva instància.",
+    "placeholder": "El teu correu electrònic o nom d'usuària",
+    "instruction": "Introdueix la teva adreça de correu electrònic o nom d'usuària. T'enviarem un enllaç per reiniciar la teva contrasenya.",
+    "return_home": "Torna a la pàgina principal",
+    "password_reset_required": "Has de reiniciar la teva contrasenya per iniciar la sessió.",
+    "password_reset_disabled": "El reinici de la contrasenya està deshabilitat. Si us plau, contacta l'administració de la teva instància.",
+    "check_email": "Comprova que has rebut al correu electrònic un enllaç per reiniciar la teva contrasenya."
+  },
+  "file_type": {
+    "image": "Imatge",
+    "file": "Fitxer",
+    "video": "Vídeo",
+    "audio": "Àudio"
+  },
+  "chats": {
+    "chats": "Xats",
+    "new": "Nou xat",
+    "delete_confirm": "Realment vols esborrar aquest missatge?",
+    "error_sending_message": "Alguna cosa ha fallat quan s'enviava el missatge.",
+    "more": "Més",
+    "delete": "Esborra",
+    "empty_message_error": "No es pot publicar un missatge buit",
+    "you": "Tu:",
+    "message_user": "Missatge {nickname}",
+    "error_loading_chat": "Alguna cosa ha fallat quan es carregava el xat.",
+    "empty_chat_list_placeholder": "Encara no tens cap xat. Crea un nou xat!"
+  },
+  "display_date": {
+    "today": "Avui"
   }
 }

From fa2e5deae2fd3a4c543cf2bf64692bc34f0bf733 Mon Sep 17 00:00:00 2001
From: "M. Strange" <strangealt@protonmail.com>
Date: Sun, 8 Aug 2021 13:35:36 +0000
Subject: [PATCH 135/169] Translated using Weblate (Catalan)

Currently translated at 97.4% (698 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ca/
---
 src/i18n/ca.json | 29 ++++++++++++++++++++++-------
 1 file changed, 22 insertions(+), 7 deletions(-)

diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index 6e9bf5d6..2536656f 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -393,7 +393,8 @@
         "header_faint": "Això està bé",
         "error": "Exemple d'error",
         "faint_link": "Manual d'ajuda",
-        "checkbox": "He llegit els termes i condicions"
+        "checkbox": "He llegit els termes i condicions",
+        "link": "un bonic enllaç"
       },
       "shadows": {
         "spread": "Difon",
@@ -614,7 +615,9 @@
       "revoke_moderator": "Revoca Moderació",
       "delete_account": "Esborra el compte",
       "disable_remote_subscription": "Deshabilita seguir algú des d'una instància remota",
-      "delete_user": "Esborra la usuària"
+      "delete_user": "Esborra la usuària",
+      "grant_admin": "Concedir permisos d'Administració",
+      "grant_moderator": "Concedir permisos de Moderació"
     },
     "edit_profile": "Edita el perfil",
     "follow_again": "Envia de nou la petició?",
@@ -634,7 +637,13 @@
     "block_progress": "Bloquejant…",
     "message": "Missatge",
     "unsubscribe": "Anul·la la subscripció",
-    "hide_repeats": "Amaga les repeticions"
+    "hide_repeats": "Amaga les repeticions",
+    "highlight": {
+      "disabled": "Sense ressaltat",
+      "solid": "Fons sòlid",
+      "striped": "Fons a ratlles",
+      "side": "Ratlla lateral"
+    }
   },
   "user_profile": {
     "timeline_title": "Flux personal",
@@ -682,7 +691,7 @@
     "expiry": "Temps de vida de l'enquesta",
     "people_voted_count": "{count} persona ha votat | {count} persones han votat",
     "votes_count": "{count} vot | {count} vots",
-    "not_enough_options": "Massa poques opcions úniques a l'enquesta"
+    "not_enough_options": "L'enquesta no té suficients opcions úniques"
   },
   "media_modal": {
     "next": "Següent",
@@ -786,16 +795,22 @@
     "favorite": "Favorit",
     "user_settings": "Configuració d'usuària",
     "reject_follow_request": "Rebutja la sol·licitud de seguir",
-    "bookmark": "Marcador"
+    "bookmark": "Marcador",
+    "media_upload": "Pujar multimèdia"
   },
   "search": {
     "no_results": "No hi ha resultats",
     "people": "Persones",
-    "hashtags": "Etiquetes"
+    "hashtags": "Etiquetes",
+    "people_talking": "{count} persones parlant"
   },
   "upload": {
     "file_size_units": {
-      "B": "B"
+      "B": "B",
+      "KiB": "KiB",
+      "GiB": "GiB",
+      "TiB": "TiB",
+      "MiB": "MiB"
     },
     "error": {
       "base": "La pujada ha fallat.",

From 5cbb71e588dfac252b489c38c928476417acbcec Mon Sep 17 00:00:00 2001
From: tarteka <info@tarteka.net>
Date: Sun, 8 Aug 2021 23:54:50 +0000
Subject: [PATCH 136/169] Translated using Weblate (Spanish)

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/es/
---
 src/i18n/es.json | 21 +++++++++++++++------
 1 file changed, 15 insertions(+), 6 deletions(-)

diff --git a/src/i18n/es.json b/src/i18n/es.json
index b8a87ec7..ea663174 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -43,7 +43,10 @@
     "role": {
       "admin": "Administrador/a",
       "moderator": "Moderador/a"
-    }
+    },
+    "flash_content": "Haga clic para mostrar contenido Flash usando Ruffle (experimental, puede que no funcione).",
+    "flash_security": "Tenga en cuenta que esto puede ser potencialmente peligroso ya que el contenido Flash sigue siendo código arbitrario.",
+    "flash_fail": "No se pudo cargar el contenido flash, consulte la consola para obtener más detalles."
   },
   "image_cropper": {
     "crop_picture": "Recortar la foto",
@@ -585,13 +588,18 @@
     "save": "Guardar los cambios",
     "file_export_import": {
       "errors": {
-        "invalid_file": "El archivo seleccionado no es válido como copia de seguridad de Pleroma. No se han realizado cambios."
+        "invalid_file": "El archivo seleccionado no es válido como copia de seguridad de Pleroma. No se han realizado cambios.",
+        "file_too_new": "Versión principal incompatible: {fileMajor}, este \"FrontEnd\" de Pleroma (versión de configuración {feMajor}) es demasiado antiguo para manejarlo",
+        "file_too_old": "Versión principal incompatible: {fileMajor}, la versión del archivo es demasiado antigua y no es compatible (versión mínima {FeMajor})",
+        "file_slightly_new": "La versión secundaria del archivo es diferente, es posible que algunas configuraciones no se carguen"
       },
       "restore_settings": "Restaurar ajustes desde archivo",
-      "backup_settings_theme": "Copia de seguridad de la configuración y tema a archivo",
-      "backup_settings": "Copia de seguridad de la configuración a archivo",
+      "backup_settings_theme": "Descargar la copia de seguridad de la configuración y del tema",
+      "backup_settings": "Descargar la copia de seguridad de la configuración",
       "backup_restore": "Copia de seguridad de la configuración"
-    }
+    },
+    "hide_shoutbox": "Ocultar cuadro de diálogo de la instancia",
+    "right_sidebar": "Mostrar la barra lateral a la derecha"
   },
   "time": {
     "day": "{0} día",
@@ -735,7 +743,8 @@
       "solid": "Fondo sólido",
       "disabled": "Sin resaltado"
     },
-    "bot": "Bot"
+    "bot": "Bot",
+    "edit_profile": "Edita el perfil"
   },
   "user_profile": {
     "timeline_title": "Línea temporal del usuario",

From a7a736c7b8c5722e6f4004a94f273e825b4fe0a2 Mon Sep 17 00:00:00 2001
From: "@liimee" <alt3753.7@gmail.com>
Date: Fri, 13 Aug 2021 01:36:48 +0000
Subject: [PATCH 137/169] Added translation using Weblate (Indonesian)

---
 src/i18n/id.json | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 src/i18n/id.json

diff --git a/src/i18n/id.json b/src/i18n/id.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/src/i18n/id.json
@@ -0,0 +1 @@
+{}

From 32ed71501a951ad3a35fb60baf984bb3c4472b50 Mon Sep 17 00:00:00 2001
From: tarteka <info@tarteka.net>
Date: Sat, 14 Aug 2021 11:45:49 +0000
Subject: [PATCH 138/169] Translated using Weblate (Spanish)

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/es/
---
 src/i18n/es.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/i18n/es.json b/src/i18n/es.json
index ea663174..5271e1bb 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -184,7 +184,7 @@
     "preview_empty": "Vacío",
     "preview": "Vista previa",
     "media_description": "Descripción multimedia",
-    "post": "Publicación"
+    "post": "Publicar"
   },
   "registration": {
     "bio": "Biografía",

From 5049ee575f57a2dcc7102594d7d581f788f5d641 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= <me@mkljczk.pl>
Date: Fri, 13 Aug 2021 15:01:01 +0000
Subject: [PATCH 139/169] Translated using Weblate (Polish)

Currently translated at 98.7% (707 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/pl/
---
 src/i18n/pl.json | 77 ++++++++++++++++++++++++++++++++++++------------
 1 file changed, 58 insertions(+), 19 deletions(-)

diff --git a/src/i18n/pl.json b/src/i18n/pl.json
index 7cf06796..745e39c4 100644
--- a/src/i18n/pl.json
+++ b/src/i18n/pl.json
@@ -19,8 +19,8 @@
         "reject_desc": "Ta instancja odrzuca posty z wymienionych instancji:",
         "quarantine": "Kwarantanna",
         "quarantine_desc": "Ta instancja wysyła tylko publiczne posty do wymienionych instancji:",
-        "ftl_removal": "Usunięcie z \"Całej znanej sieci\"",
-        "ftl_removal_desc": "Ta instancja usuwa wymienionych instancje z \"Całej znanej sieci\":",
+        "ftl_removal": "Usunięcie z „Całej znanej sieci”",
+        "ftl_removal_desc": "Ta instancja usuwa wymienionych instancje z „Całej znanej sieci”:",
         "media_removal": "Usuwanie multimediów",
         "media_removal_desc": "Ta instancja usuwa multimedia z postów od wymienionych instancji:",
         "media_nsfw": "Multimedia ustawione jako wrażliwe",
@@ -75,7 +75,13 @@
     "loading": "Ładowanie…",
     "retry": "Spróbuj ponownie",
     "peek": "Spójrz",
-    "error_retry": "Spróbuj ponownie"
+    "error_retry": "Spróbuj ponownie",
+    "flash_content": "Naciśnij, aby wyświetlić zawartości Flash z użyciem Ruffle (eksperymentalnie, może nie działać).",
+    "flash_fail": "Nie udało się załadować treści flash, zajrzyj do konsoli, aby odnaleźć szczegóły.",
+    "role": {
+      "moderator": "Moderator",
+      "admin": "Administrator"
+    }
   },
   "image_cropper": {
     "crop_picture": "Przytnij obrazek",
@@ -118,7 +124,7 @@
     "friend_requests": "Prośby o możliwość obserwacji",
     "mentions": "Wzmianki",
     "interactions": "Interakcje",
-    "dms": "Wiadomości prywatne",
+    "dms": "Wiadomości bezpośrednie",
     "public_tl": "Publiczna oś czasu",
     "timeline": "Oś czasu",
     "twkn": "Znana sieć",
@@ -128,7 +134,8 @@
     "preferences": "Preferencje",
     "bookmarks": "Zakładki",
     "chats": "Czaty",
-    "timelines": "Osie czasu"
+    "timelines": "Osie czasu",
+    "home_timeline": "Główna oś czasu"
   },
   "notifications": {
     "broken_favorite": "Nieznany status, szukam go…",
@@ -156,7 +163,9 @@
     "expiry": "Czas trwania ankiety",
     "expires_in": "Ankieta kończy się za {0}",
     "expired": "Ankieta skończyła się {0} temu",
-    "not_enough_options": "Zbyt mało unikalnych opcji w ankiecie"
+    "not_enough_options": "Zbyt mało unikalnych opcji w ankiecie",
+    "people_voted_count": "{count} osoba zagłosowała | {count} osoby zagłosowały | {count} osób zagłosowało",
+    "votes_count": "{count} głos | {count} głosy | {count} głosów"
   },
   "emoji": {
     "stickers": "Naklejki",
@@ -197,16 +206,17 @@
       "unlisted": "Ten post nie będzie widoczny na publicznej osi czasu i całej znanej sieci"
     },
     "scope": {
-      "direct": "Bezpośredni – Tylko dla wspomnianych użytkowników",
-      "private": "Tylko dla obserwujących – Umieść dla osób, które cię obserwują",
-      "public": "Publiczny – Umieść na publicznych osiach czasu",
-      "unlisted": "Niewidoczny – Nie umieszczaj na publicznych osiach czasu"
+      "direct": "Bezpośredni – tylko dla wspomnianych użytkowników",
+      "private": "Tylko dla obserwujących – umieść dla osób, które cię obserwują",
+      "public": "Publiczny – umieść na publicznych osiach czasu",
+      "unlisted": "Niewidoczny – nie umieszczaj na publicznych osiach czasu"
     },
     "preview_empty": "Pusty",
     "preview": "Podgląd",
     "empty_status_error": "Nie można wysłać pustego wpisu bez plików",
     "media_description_error": "Nie udało się zaktualizować mediów, spróbuj ponownie",
-    "media_description": "Opis mediów"
+    "media_description": "Opis mediów",
+    "post": "Opublikuj"
   },
   "registration": {
     "bio": "Bio",
@@ -227,7 +237,10 @@
       "password_required": "nie może być puste",
       "password_confirmation_required": "nie może być puste",
       "password_confirmation_match": "musi być takie jak hasło"
-    }
+    },
+    "reason": "Powód rejestracji",
+    "reason_placeholder": "Ta instancja ręcznie zatwierdza rejestracje.\nPoinformuj administratora, dlaczego chcesz się zarejestrować.",
+    "register": "Zarejestruj się"
   },
   "remote_user_resolver": {
     "remote_user_resolver": "Wyszukiwarka użytkowników nietutejszych",
@@ -281,7 +294,7 @@
     "cGreen": "Zielony (powtórzenia)",
     "cOrange": "Pomarańczowy (ulubione)",
     "cRed": "Czerwony (anuluj)",
-    "change_email": "Zmień email",
+    "change_email": "Zmień e-mail",
     "change_email_error": "Wystąpił problem podczas zmiany emaila.",
     "changed_email": "Pomyślnie zmieniono email!",
     "change_password": "Zmień hasło",
@@ -345,7 +358,7 @@
     "use_contain_fit": "Nie przycinaj załączników na miniaturach",
     "name": "Imię",
     "name_bio": "Imię i bio",
-    "new_email": "Nowy email",
+    "new_email": "Nowy e-mail",
     "new_password": "Nowe hasło",
     "notification_visibility": "Rodzaje powiadomień do wyświetlania",
     "notification_visibility_follows": "Obserwacje",
@@ -361,8 +374,8 @@
     "hide_followers_description": "Nie pokazuj kto mnie obserwuje",
     "hide_follows_count_description": "Nie pokazuj licznika obserwowanych",
     "hide_followers_count_description": "Nie pokazuj licznika obserwujących",
-    "show_admin_badge": "Pokazuj odznakę Administrator na moim profilu",
-    "show_moderator_badge": "Pokazuj odznakę Moderator na moim profilu",
+    "show_admin_badge": "Pokazuj odznakę „Administrator” na moim profilu",
+    "show_moderator_badge": "Pokazuj odznakę „Moderator” na moim profilu",
     "nsfw_clickthrough": "Włącz domyślne ukrywanie załączników o treści nieprzyzwoitej (NSFW)",
     "oauth_tokens": "Tokeny OAuth",
     "token": "Token",
@@ -600,7 +613,26 @@
     "mute_import": "Import wyciszeń",
     "mute_export_button": "Wyeksportuj swoje wyciszenia do pliku .csv",
     "mute_export": "Eksport wyciszeń",
-    "hide_wallpaper": "Ukryj tło instancji"
+    "hide_wallpaper": "Ukryj tło instancji",
+    "save": "Zapisz zmiany",
+    "setting_changed": "Opcja różni się od domyślnej",
+    "right_sidebar": "Pokaż pasek boczny po prawej",
+    "file_export_import": {
+      "errors": {
+        "invalid_file": "Wybrany plik nie jest obsługiwaną kopią zapasową ustawień Pleromy. Nie dokonano żadnych zmian."
+      },
+      "backup_restore": "Kopia zapasowa ustawień",
+      "backup_settings": "Kopia zapasowa ustawień do pliku",
+      "backup_settings_theme": "Kopia zapasowa ustawień i motywu do pliku",
+      "restore_settings": "Przywróć ustawienia z pliku"
+    },
+    "more_settings": "Więcej ustawień",
+    "word_filter": "Filtr słów",
+    "hide_media_previews": "Ukryj podgląd mediów",
+    "hide_all_muted_posts": "Ukryj wyciszone słowa",
+    "reply_visibility_following_short": "Pokazuj odpowiedzi obserwującym",
+    "reply_visibility_self_short": "Pokazuj odpowiedzi tylko do mnie",
+    "sensitive_by_default": "Domyślnie oznaczaj wpisy jako wrażliwe"
   },
   "time": {
     "day": "{0} dzień",
@@ -648,7 +680,9 @@
     "no_more_statuses": "Brak kolejnych statusów",
     "no_statuses": "Brak statusów",
     "reload": "Odśwież",
-    "error": "Błąd pobierania osi czasu: {0}"
+    "error": "Błąd pobierania osi czasu: {0}",
+    "socket_broke": "Utracono połączenie w czasie rzeczywistym: kod CloseEvent {0}",
+    "socket_reconnected": "Osiągnięto połączenie w czasie rzeczywistym"
   },
   "status": {
     "favorites": "Ulubione",
@@ -731,7 +765,12 @@
       "delete_user": "Usuń użytkownika",
       "delete_user_confirmation": "Czy jesteś absolutnie pewny(-a)? Ta operacja nie może być cofnięta."
     },
-    "message": "Napisz"
+    "message": "Napisz",
+    "edit_profile": "Edytuj profil",
+    "highlight": {
+      "disabled": "Bez wyróżnienia"
+    },
+    "bot": "Bot"
   },
   "user_profile": {
     "timeline_title": "Oś czasu użytkownika",

From 29f229daade8083a30e095ef02a7a6934b935810 Mon Sep 17 00:00:00 2001
From: "@liimee" <alt3753.7@gmail.com>
Date: Mon, 16 Aug 2021 00:48:28 +0000
Subject: [PATCH 140/169] Translated using Weblate (Indonesian)

Currently translated at 50.1% (359 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/id/
---
 src/i18n/id.json | 484 ++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 483 insertions(+), 1 deletion(-)

diff --git a/src/i18n/id.json b/src/i18n/id.json
index 0967ef42..9d3256f7 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -1 +1,483 @@
-{}
+{
+  "settings": {
+    "style": {
+      "preview": {
+        "link": "sebuah tautan yang kecil nan bagus",
+        "header": "Pratinjau",
+        "error": "Contoh kesalahan",
+        "button": "Tombol",
+        "input": "Baru saja mendarat di L.A.",
+        "faint_link": "manual berguna",
+        "fine_print": "Baca {0} kami untuk belajar sesuatu yang tak ada gunanya!",
+        "header_faint": "Ini baik-baik saja",
+        "checkbox": "Saya telah membaca sekilas syarat dan ketentuan"
+      },
+      "advanced_colors": {
+        "alert_neutral": "Neutral",
+        "alert_warning": "Peringatan",
+        "alert_error": "Kesalahan"
+      },
+      "common_colors": {
+        "main": "Warna umum",
+        "_tab_label": "Umum"
+      },
+      "common": {
+        "contrast": {
+          "context": {
+            "text": "untuk teks",
+            "18pt": "Untuk teks besar (18pt+)"
+          }
+        },
+        "color": "Warna"
+      },
+      "switcher": {
+        "help": {
+          "upgraded_from_v2": "PleromaFE telah diperbarui, tema dapat terlihat sedikit berbeda dari apa yang Anda ingat."
+        },
+        "use_source": "Versi baru",
+        "use_snapshot": "Versi lama",
+        "load_theme": "Muat tema"
+      },
+      "fonts": {
+        "_tab_label": "Font",
+        "components": {
+          "interface": "Antarmuka",
+          "post": "Teks postingan"
+        },
+        "family": "Nama font",
+        "size": "Ukuran (dalam px)",
+        "weight": "Berat (ketebalan)"
+      }
+    },
+    "notification_setting_privacy": "Privasi",
+    "notifications": "Notifikasi",
+    "values": {
+      "true": "ya",
+      "false": "tidak"
+    },
+    "user_settings": "Pengaturan Pengguna",
+    "upload_a_photo": "Unggah foto",
+    "theme": "Tema",
+    "text": "Teks",
+    "settings": "Pengaturan",
+    "security_tab": "Keamanan",
+    "saving_ok": "Pengaturan disimpan",
+    "profile_tab": "Profil",
+    "profile_background": "Latar belakang profil",
+    "token": "Token",
+    "oauth_tokens": "Token OAuth",
+    "show_moderator_badge": "Tampilkan lencana \"Moderator\" di profil saya",
+    "show_admin_badge": "Tampilkan lencana \"Admin\" di profil saya",
+    "new_password": "Kata sandi baru",
+    "new_email": "Surel baru",
+    "name_bio": "Nama & bio",
+    "name": "Nama",
+    "profile_fields": {
+      "value": "Isi",
+      "name": "Label"
+    },
+    "limited_availability": "Tidak tersedia di browser Anda",
+    "invalid_theme_imported": "Berkas yang dipilih bukan sebuah tema yang didukung Pleroma. Tidak ada perbuahan yang dibuat pada tema Anda.",
+    "interfaceLanguage": "Bahasa antarmuka",
+    "interface": "Antarmuka",
+    "instance_default_simple": "(bawaan)",
+    "instance_default": "(bawaan: {value})",
+    "general": "Umum",
+    "delete_account_error": "Ada masalah ketika menghapus akun Anda. Jika ini terus terjadi harap hubungi adminstrator instansi Anda.",
+    "delete_account_description": "Hapus data Anda secara permanen dan menonaktifkan akun Anda.",
+    "delete_account": "Hapus akun",
+    "data_import_export_tab": "Impor / ekspor data",
+    "current_password": "Kata sandi saat ini",
+    "confirm_new_password": "Konfirmasi kata sandi baru",
+    "version": {
+      "title": "Versi",
+      "backend_version": "Versi backend",
+      "frontend_version": "Versi frontend"
+    },
+    "security": "Keamanan",
+    "changed_password": "Kata sandi berhasil diubah!",
+    "change_password_error": "Ada masalah ketika mengubah kata sandi Anda.",
+    "change_password": "Ubah kata sandi",
+    "changed_email": "Surel berhasil diubah!",
+    "change_email_error": "Ada masalah ketika mengubah surel Anda.",
+    "change_email": "Ubah surel",
+    "cRed": "Merah (Batal)",
+    "cBlue": "Biru (Balas, ikuti)",
+    "btnRadius": "Tombol",
+    "bot": "Ini adalah akun bot",
+    "block_export": "",
+    "bio": "Bio",
+    "background": "Latar belakang",
+    "avatarRadius": "Avatar",
+    "avatar": "Avatar",
+    "attachments": "Lampiran",
+    "mfa": {
+      "scan": {
+        "title": "Pindai"
+      },
+      "confirm_and_enable": "Konfirmasi & aktifkan OTP",
+      "setup_otp": "Siapkan OTP",
+      "otp": "OTP"
+    },
+    "app_name": "Nama aplikasi",
+    "save": "Simpan perubahan"
+  },
+  "about": {
+    "mrf": {
+      "keyword": {
+        "reject": "Tolak"
+      }
+    }
+  },
+  "time": {
+    "day": "{0} hari",
+    "days": "{0} hari",
+    "day_short": "{0}h",
+    "days_short": "{0}h",
+    "hour": "{0} jam",
+    "hours": "{0} jam",
+    "hour_short": "{0}j",
+    "hours_short": "{0}j",
+    "in_future": "dalam {0}",
+    "in_past": "{0} yang lalu",
+    "minute": "{0} menit",
+    "minutes": "{0} menit",
+    "minute_short": "{0}m",
+    "minutes_short": "{0}m",
+    "month": "{0} bulan",
+    "months": "{0} bulan",
+    "month_short": "{0}b",
+    "months_short": "{0}b",
+    "now": "baru saja",
+    "now_short": "sekarang",
+    "second": "{0} detik",
+    "seconds": "{0} detik",
+    "second_short": "{0}d",
+    "seconds_short": "{0}d",
+    "week": "{0} pekan",
+    "weeks": "{0} pekan",
+    "week_short": "{0}p",
+    "weeks_short": "{0}p",
+    "year": "{0} tahun",
+    "years": "{0} tahun",
+    "year_short": "{0}t",
+    "years_short": "{0}t"
+  },
+  "timeline": {
+    "conversation": "Percakapan",
+    "error": "Terjadi kesalahan memuat linimasa: {0}",
+    "no_retweet_hint": "Postingan ditandai sebagai hanya-pengikut atau langsung dan tidak dapat diulang",
+    "repeated": "diulangi",
+    "reload": "Muat ulang",
+    "no_more_statuses": "Tidak ada status lagi",
+    "no_statuses": "Tidak ada status"
+  },
+  "status": {
+    "favorites": "Favorit",
+    "repeats": "Ulangan",
+    "delete": "Hapus status",
+    "pin": "Sematkan di profil",
+    "unpin": "Berhenti menyematkan dari profil",
+    "pinned": "Disematkan",
+    "delete_confirm": "Apakah Anda benar-benar ingin menghapus status ini?",
+    "reply_to": "Balas ke",
+    "replies_list": "Balasan:",
+    "mute_conversation": "Bisukan percakapan",
+    "unmute_conversation": "Berhenti membisikan percakapan",
+    "status_unavailable": "Status tidak tersedia",
+    "thread_muted_and_words": ", memiliki kata:",
+    "hide_content": "",
+    "show_content": "",
+    "status_deleted": "Postingan ini telah dihapus",
+    "nsfw": "NSFW"
+  },
+  "user_card": {
+    "block": "Blokir",
+    "blocked": "Diblokir!",
+    "deny": "Tolak",
+    "edit_profile": "Sunting profil",
+    "favorites": "Favorit",
+    "follow": "Ikuti",
+    "follow_sent": "Permintaan dikirim!",
+    "follow_progress": "Meminta…",
+    "mute": "Bisukan",
+    "muted": "Dibisukan",
+    "per_day": "per hari",
+    "report": "Laporkan",
+    "statuses": "Status",
+    "unblock": "Berhenti memblokir",
+    "block_progress": "Memblokir…",
+    "unmute": "Berhenti membisukan",
+    "mute_progress": "Membisukan…",
+    "hide_repeats": "Sembunyikan ulangan",
+    "show_repeats": "Tampilkan ulangan",
+    "bot": "Bot",
+    "admin_menu": {
+      "moderation": "Moderasi",
+      "activate_account": "Aktifkan akun",
+      "deactivate_account": "Nonaktifkan akun",
+      "delete_account": "Hapus akun",
+      "force_nsfw": "Tandai semua postingan sebagai NSFW",
+      "strip_media": "Hapus media dari postingan-postingan",
+      "delete_user": "Hapus pengguna",
+      "delete_user_confirmation": "Apakah Anda benar-benar yakin? Tindakan ini tidak dapat dibatalkan."
+    },
+    "follow_again": "Kirim permintaan lagi?",
+    "follow_unfollow": "Berhenti mengikuti",
+    "followees": "Mengikuti",
+    "followers": "Pengikut",
+    "following": "Diikuti!",
+    "follows_you": "Mengikuti Anda!",
+    "hidden": "Disembunyikan",
+    "its_you": "Ini Anda!",
+    "media": "Media",
+    "mention": "Sebut",
+    "message": "Kirimkan pesan"
+  },
+  "user_profile": {
+    "timeline_title": "Linimasa pengguna"
+  },
+  "user_reporting": {
+    "title": "Melaporkan {0}",
+    "add_comment_description": "Laporan ini akan dikirim ke moderator instansi Anda. Anda dapat menyediakan penjelasan mengapa Anda melaporkan akun ini di bawah:",
+    "additional_comments": "Komentar tambahan",
+    "forward_description": "Akun ini berada di server lain. Kirim salinan dari laporannya juga?",
+    "submit": "Kirim",
+    "generic_error": "Sebuah kesalahan terjadi ketika memproses permintaan Anda."
+  },
+  "notifications": {
+    "favorited_you": "memfavoritkan status Anda",
+    "reacted_with": "bereaksi dengan {0}",
+    "no_more_notifications": "Tidak ada notifikasi lagi",
+    "repeated_you": "mengulangi status Anda",
+    "read": "Dibaca!",
+    "notifications": "Notifikasi",
+    "follow_request": "ingin mengikuti Anda",
+    "followed_you": "mengikuti Anda",
+    "error": "Terjadi kesalahan ketika memuat notifikasi: {0}"
+  },
+  "who_to_follow": {
+    "more": "Lebih banyak"
+  },
+  "tool_tip": {
+    "media_upload": "Unggah media",
+    "repeat": "Ulangi",
+    "reply": "Balas",
+    "favorite": "Favorit",
+    "add_reaction": "Tambahkan Reaksi",
+    "user_settings": "Pengaturan Pengguna"
+  },
+  "upload": {
+    "error": {
+      "base": "Pengunggahan gagal.",
+      "message": "Pengunggahan gagal: {0}",
+      "file_too_big": "Berkas terlalu besar [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+      "default": "Coba lagi nanti"
+    },
+    "file_size_units": {
+      "B": "B",
+      "KiB": "KiB",
+      "MiB": "MiB",
+      "GiB": "GiB",
+      "TiB": "TiB"
+    }
+  },
+  "search": {
+    "people": "Orang",
+    "hashtags": "Tagar",
+    "person_talking": "{count} orang berbicara",
+    "people_talking": "{count} orang berbicara",
+    "no_results": "Tidak ada hasil"
+  },
+  "password_reset": {
+    "forgot_password": "Lupa kata sandi?",
+    "placeholder": "Surel atau nama pengguna Anda",
+    "return_home": "Kembali ke halaman beranda",
+    "too_many_requests": "Anda telah mencapai batas percobaan, coba lagi nanti."
+  },
+  "chats": {
+    "you": "Anda:",
+    "message_user": "Kirim Pesan ke {nickname}",
+    "delete": "Hapus",
+    "chats": "Obrolan",
+    "new": "Obrolan Baru",
+    "empty_message_error": "Tidak dapat memposting pesan yang kosong",
+    "more": "Lebih banyak",
+    "delete_confirm": "Apakah Anda benar-benar ingin menghapus pesan ini?",
+    "error_loading_chat": "Sesuatu yang salah terjadi ketika memuat obrolan.",
+    "error_sending_message": "Sesuatu yang salah terjadi ketika mengirim pesan.",
+    "empty_chat_list_placeholder": "Anda belum memiliki obrolan. Buat sbeuah obrolan baru!"
+  },
+  "file_type": {
+    "audio": "Audio",
+    "video": "Video",
+    "image": "Gambar",
+    "file": "Berkas"
+  },
+  "registration": {
+    "bio_placeholder": "contoh.\nHai, aku Lain.\nAku seorang putri anime yang tinggal di pinggiran kota Jepang. Kamu mungkin mengenal aku dari Wired.",
+    "validations": {
+      "password_confirmation_required": "tidak boleh kosong",
+      "password_required": "tidak boleh kosong",
+      "email_required": "tidak boleh kosong",
+      "fullname_required": "tidak boleh kosong",
+      "username_required": "tidak boleh kosong"
+    },
+    "register": "Daftar",
+    "fullname_placeholder": "contoh. Lain Iwakura",
+    "username_placeholder": "contoh. lain",
+    "new_captcha": "Klik gambarnya untuk mendapatkan captcha baru",
+    "captcha": "CAPTCHA",
+    "token": "Token undangan",
+    "password_confirm": "Konfirmasi kata sandi",
+    "email": "Surel",
+    "bio": "Bio"
+  },
+  "post_status": {
+    "preview_empty": "Kosong",
+    "default": "Baru saja mendarat di L.A.",
+    "content_warning": "Subyek (opsional)",
+    "content_type": {
+      "text/bbcode": "BBCode",
+      "text/markdown": "Markdown",
+      "text/html": "HTML",
+      "text/plain": "Teks biasa"
+    },
+    "media_description": "Keterangan media",
+    "attachments_sensitive": "Tandai lampiran sebagai sensitif",
+    "scope": {
+      "public": "Publik - posting ke linimasa publik",
+      "private": "Hanya-pengikut - posting hanya kepada pengikut",
+      "direct": "Langsung - posting hanya kepada pengguna yang disebut"
+    },
+    "preview": "Pratinjau",
+    "post": "Posting",
+    "posting": "Memposting",
+    "direct_warning_to_first_only": "Postingan ini akan terlihat oleh pengguna yang disebutkan di awal pesan.",
+    "direct_warning_to_all": "Postingan ini akan terlihat oleh pengguna yang disebutkan.",
+    "scope_notice": {
+      "private": "Postingan ini akan terlihat hanya oleh pengikut Anda",
+      "public": "Postingan ini akan terlihat oleh siapa saja"
+    },
+    "media_description_error": "Gagal memperbarui media, coba lagi",
+    "empty_status_error": "Tidak dapat memposting status kosong tanpa berkas"
+  },
+  "general": {
+    "apply": "Terapkan",
+    "flash_fail": "Gagal memuat konten flash, lihat console untuk keterangan.",
+    "flash_security": "Harap ingat ini dapat menjadi berbahaya karena konten Flash masih termasuk arbitrary code.",
+    "flash_content": "Klik untuk menampilkan konten Flash menggunakan Ruffle (Eksperimental, mungkin tidak bekerja).",
+    "role": {
+      "moderator": "Moderator",
+      "admin": "Admin"
+    },
+    "peek": "Intip",
+    "close": "Tutup",
+    "verify": "Verifikasi",
+    "confirm": "Konfirmasi",
+    "enable": "Aktifkan",
+    "disable": "Nonaktifkan",
+    "cancel": "Batal",
+    "show_less": "Tampilkan lebih sedikit",
+    "show_more": "Tampilkan lebih banyak",
+    "optional": "opsional",
+    "retry": "Coba lagi",
+    "error_retry": "Harap coba lagi",
+    "generic_error": "Terjadi kesalahan",
+    "loading": "Memuat…",
+    "more": "Lebih banyak",
+    "submit": "Kirim"
+  },
+  "remote_user_resolver": {
+    "error": "Tidak ditemukan."
+  },
+  "emoji": {
+    "load_all": "Memuat semua {emojiAmount} emoji",
+    "load_all_hint": "Memuat {saneAmount} emoji pertama, memuat semua emoji dapat menyebabkan masalah performa.",
+    "unicode": "Emoji unicode",
+    "add_emoji": "Sisipkan emoji",
+    "search_emoji": "Cari emoji",
+    "emoji": "Emoji",
+    "stickers": "Stiker"
+  },
+  "polls": {
+    "expired": "Japat berakhir {0} yang lalu",
+    "expires_in": "Japat berakhir dalam {0}",
+    "expiry": "Usia japat",
+    "type": "Jenis japat",
+    "vote": "Pilih",
+    "votes_count": "{count} suara | {count} suara",
+    "people_voted_count": "{count} orang memilih | {count} orang memilih",
+    "votes": "suara",
+    "option": "Opsi",
+    "add_option": "Tambahkan opsi",
+    "add_poll": "Tambahkan japat"
+  },
+  "nav": {
+    "preferences": "Preferensi",
+    "search": "Cari",
+    "user_search": "Pencarian Pengguna",
+    "home_timeline": "Linimasa beranda",
+    "timeline": "Linimasa",
+    "public_tl": "Linimasa publik",
+    "interactions": "Interaksi",
+    "mentions": "Sebutan",
+    "back": "Kembali",
+    "administration": "Administrasi",
+    "about": "Tentang"
+  },
+  "media_modal": {
+    "next": "Selanjutnya",
+    "previous": "Sebelum"
+  },
+  "login": {
+    "recovery_code": "Kode pemulihan",
+    "enter_recovery_code": "Masukkan kode pemulihan",
+    "authentication_code": "Kode otentikasi",
+    "hint": "Masuk untuk ikut berdiskusi",
+    "username": "Nama pengguna",
+    "register": "Daftar",
+    "placeholder": "contoh: lain",
+    "password": "Kata sandi",
+    "logout": "Keluar",
+    "description": "Masuk dengan OAuth",
+    "login": "Masuk"
+  },
+  "importer": {
+    "error": "Terjadi kesalahan ketika mnengimpor berkas ini.",
+    "success": "Berhasil mengimpor.",
+    "submit": "Kirim"
+  },
+  "image_cropper": {
+    "cancel": "Batal",
+    "save_without_cropping": "Simpan tanpa memotong",
+    "save": "Simpan",
+    "crop_picture": "Potong gambar"
+  },
+  "finder": {
+    "find_user": "Cari pengguna",
+    "error_fetching_user": "Terjadi kesalahan ketika memuat pengguna"
+  },
+  "features_panel": {
+    "title": "Fitur-fitur",
+    "text_limit": "Batas teks",
+    "gopher": "Gopher",
+    "pleroma_chat_messages": "Pleroma Obrolan",
+    "chat": "Obrolan"
+  },
+  "exporter": {
+    "processing": "Memproses, Anda akan segera diminta untuk mengunduh berkas Anda",
+    "export": "Ekspor"
+  },
+  "domain_mute_card": {
+    "unmute": "Berhenti membisukan",
+    "mute_progress": "Membisukan…",
+    "mute": "Bisukan"
+  },
+  "display_date": {
+    "today": "Hari Ini"
+  },
+  "selectable_list": {
+    "select_all": "Pilih semua"
+  }
+}

From 78ba8be969ee1926f533e424133509b0485d602c Mon Sep 17 00:00:00 2001
From: tarteka <info@tarteka.net>
Date: Tue, 17 Aug 2021 07:21:05 +0000
Subject: [PATCH 141/169] Translated using Weblate (Spanish)

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/es/
---
 src/i18n/es.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/i18n/es.json b/src/i18n/es.json
index 5271e1bb..0d343e8c 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -150,7 +150,7 @@
     "favs_repeats": "Favoritos y repetidos",
     "follows": "Nuevos seguidores",
     "load_older": "Cargar interacciones más antiguas",
-    "moves": "Usuario Migrado"
+    "moves": "Usuario migrado"
   },
   "post_status": {
     "new_status": "Publicar un nuevo estado",

From 21af736fe1606e283f505c501b9d41fd1fdd6f33 Mon Sep 17 00:00:00 2001
From: tarteka <info@tarteka.net>
Date: Tue, 17 Aug 2021 07:12:33 +0000
Subject: [PATCH 142/169] Translated using Weblate (Basque)

Currently translated at 79.8% (572 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/eu/
---
 src/i18n/eu.json | 32 +++++++++++++++++++++++++-------
 1 file changed, 25 insertions(+), 7 deletions(-)

diff --git a/src/i18n/eu.json b/src/i18n/eu.json
index e543fda0..080caaaf 100644
--- a/src/i18n/eu.json
+++ b/src/i18n/eu.json
@@ -43,7 +43,10 @@
     "role": {
       "moderator": "Moderatzailea",
       "admin": "Administratzailea"
-    }
+    },
+    "flash_content": "Klik egin Flash edukia erakusteko Ruffle erabilita (esperimentala, baliteke ez ibiltzea).",
+    "flash_security": "Kontuan izan arriskutsua izan daitekeela, Flash edukia kode arbitrarioa baita.",
+    "flash_fail": "Ezin izan da Flash edukia kargatu. Ikusi kontsola xehetasunetarako."
   },
   "image_cropper": {
     "crop_picture": "Moztu argazkia",
@@ -96,7 +99,8 @@
     "preferences": "Hobespenak",
     "chats": "Txatak",
     "timelines": "Denbora-lerroak",
-    "bookmarks": "Laster-markak"
+    "bookmarks": "Laster-markak",
+    "home_timeline": "Denbora-lerro pertsonala"
   },
   "notifications": {
     "broken_favorite": "Egoera ezezaguna, bilatzen…",
@@ -136,7 +140,8 @@
     "add_emoji": "Emoji bat gehitu",
     "custom": "Ohiko emojiak",
     "unicode": "Unicode emojiak",
-    "load_all": "{emojiAmount} emoji guztiak kargatzen"
+    "load_all": "{emojiAmount} emoji guztiak kargatzen",
+    "load_all_hint": "Lehenengo {saneAmount} emojia kargatuta, emoji guztiak kargatzeak errendimendu arazoak sor ditzake."
   },
   "stickers": {
     "add_sticker": "Pegatina gehitu"
@@ -144,7 +149,8 @@
   "interactions": {
     "favs_repeats": "Errepikapen eta gogokoak",
     "follows": "Jarraitzaile berriak",
-    "load_older": "Kargatu elkarrekintza zaharragoak"
+    "load_older": "Kargatu elkarrekintza zaharragoak",
+    "moves": "Erabiltzailea migratuta"
   },
   "post_status": {
     "new_status": "Mezu berri bat idatzi",
@@ -172,14 +178,20 @@
       "private": "Jarraitzaileentzako bakarrik: bidali jarraitzaileentzat bakarrik",
       "public": "Publikoa: bistaratu denbora-lerro publikoetan",
       "unlisted": "Zerrendatu gabea: ez bidali denbora-lerro publikoetara"
-    }
+    },
+    "media_description_error": "Ezin izan da artxiboa eguneratu, saiatu berriro",
+    "preview": "Aurrebista",
+    "media_description": "Media deskribapena",
+    "preview_empty": "Hutsik",
+    "post": "Bidali",
+    "empty_status_error": "Ezin da argitaratu ezer idatzi gabe edo eranskinik gabe"
   },
   "registration": {
     "bio": "Biografia",
     "email": "E-posta",
     "fullname": "Erakutsi izena",
     "password_confirm": "Pasahitza berretsi",
-    "registration": "Izena ematea",
+    "registration": "Sortu kontua",
     "token": "Gonbidapen txartela",
     "captcha": "CAPTCHA",
     "new_captcha": "Klikatu irudia captcha berri bat lortzeko",
@@ -193,7 +205,10 @@
       "password_required": "Ezin da hutsik utzi",
       "password_confirmation_required": "Ezin da hutsik utzi",
       "password_confirmation_match": "Pasahitzaren berdina izan behar du"
-    }
+    },
+    "reason": "Kontua sortzeko arrazoia",
+    "reason_placeholder": "Instantzia honek kontu berriak eskuz onartzen ditu.\nJakinarazi administrazioari zergatik erregistratu nahi duzun.",
+    "register": "Erregistratu"
   },
   "selectable_list": {
     "select_all": "Hautatu denak"
@@ -691,5 +706,8 @@
   },
   "shoutbox": {
     "title": "Oihu-kutxa"
+  },
+  "errors": {
+    "storage_unavailable": "Pleromak ezin izan du nabigatzailearen biltegira sartu. Hasiera-saioa edo tokiko ezarpenak ez dira gordeko eta ustekabeko arazoak sor ditzake. Saiatu cookie-ak gaitzen."
   }
 }

From ca9652b30bf69a303b70808f983443601e3d9ddb Mon Sep 17 00:00:00 2001
From: "@liimee" <alt3753.7@gmail.com>
Date: Thu, 19 Aug 2021 07:49:40 +0000
Subject: [PATCH 143/169] Translated using Weblate (Indonesian)

Currently translated at 56.9% (408 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/id/
---
 src/i18n/id.json | 75 ++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 66 insertions(+), 9 deletions(-)

diff --git a/src/i18n/id.json b/src/i18n/id.json
index 9d3256f7..d2b43656 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -74,7 +74,8 @@
     "name": "Nama",
     "profile_fields": {
       "value": "Isi",
-      "name": "Label"
+      "name": "Label",
+      "label": "Metadata profil"
     },
     "limited_availability": "Tidak tersedia di browser Anda",
     "invalid_theme_imported": "Berkas yang dipilih bukan sebuah tema yang didukung Pleroma. Tidak ada perbuahan yang dibuat pada tema Anda.",
@@ -120,14 +121,47 @@
       "otp": "OTP"
     },
     "app_name": "Nama aplikasi",
-    "save": "Simpan perubahan"
+    "save": "Simpan perubahan",
+    "valid_until": "Valid hingga",
+    "follow_import_error": "Terjadi kesalahan ketika mengimpor pengikut",
+    "emoji_reactions_on_timeline": "Tampilkan reaksi emoji pada linimasa",
+    "chatMessageRadius": "Pesan obrolan",
+    "cOrange": "Jingga (Favorit)",
+    "avatarAltRadius": "Avatar (notifikasi)",
+    "hide_shoutbox": "Sembunyikan kotak suara instansi",
+    "hide_followers_count_description": "Jangan tampilkan jumlah pengikut",
+    "hide_follows_count_description": "Jangan tampilkan jumlah mengikuti",
+    "hide_followers_description": "Jangan tampilkan siapa yang mengikuti saya",
+    "hide_follows_description": "Jangan tampilkan siapa yang saya ikuti",
+    "notification_visibility_emoji_reactions": "Reaksi",
+    "notification_visibility_follows": "Diikuti",
+    "notification_visibility_moves": "Pengguna Bermigrasi",
+    "notification_visibility_repeats": "Ulangan",
+    "notification_visibility_mentions": "Sebutan",
+    "notification_visibility_likes": "Favorit",
+    "notification_visibility": "Jenis notifikasi yang perlu ditampilkan",
+    "links": "Tautan",
+    "hide_user_stats": "Sembunyikan statistik pengguna (contoh. jumlah pengikut)",
+    "hide_post_stats": "Sembunyikan statistik postingan (contoh. jumlah favorit)",
+    "use_one_click_nsfw": "Buka lampiran NSFW hanya dengan satu klik",
+    "hide_wallpaper": "Sembunyikan latar belakang instansi"
   },
   "about": {
     "mrf": {
       "keyword": {
         "reject": "Tolak"
-      }
-    }
+      },
+      "simple": {
+        "quarantine_desc": "Instansi ini hanya akan mengirim postingan publik ke instansi-instansi berikut:",
+        "quarantine": "Karantina",
+        "reject_desc": "Instansi ini tidak akan menerima pesan dari instansi-instansi berikut:",
+        "reject": "Tolak",
+        "accept_desc": "Instansi ini hanya menerima pesan dari instansi-instansi berikut:",
+        "accept": "Terima"
+      },
+      "federation": "Federasi"
+    },
+    "staff": "Staf"
   },
   "time": {
     "day": "{0} hari",
@@ -254,7 +288,9 @@
     "notifications": "Notifikasi",
     "follow_request": "ingin mengikuti Anda",
     "followed_you": "mengikuti Anda",
-    "error": "Terjadi kesalahan ketika memuat notifikasi: {0}"
+    "error": "Terjadi kesalahan ketika memuat notifikasi: {0}",
+    "migrated_to": "bermigrasi ke",
+    "load_older": "Muat notifikasi yang lebih lama"
   },
   "who_to_follow": {
     "more": "Lebih banyak"
@@ -331,7 +367,10 @@
     "token": "Token undangan",
     "password_confirm": "Konfirmasi kata sandi",
     "email": "Surel",
-    "bio": "Bio"
+    "bio": "Bio",
+    "reason_placeholder": "Instansi ini menerima pendaftaran secara manual.\nBeritahu administrasinya mengapa Anda ingin mendaftar.",
+    "reason": "Alasan mendaftar",
+    "registration": "Pendaftaran"
   },
   "post_status": {
     "preview_empty": "Kosong",
@@ -360,7 +399,10 @@
       "public": "Postingan ini akan terlihat oleh siapa saja"
     },
     "media_description_error": "Gagal memperbarui media, coba lagi",
-    "empty_status_error": "Tidak dapat memposting status kosong tanpa berkas"
+    "empty_status_error": "Tidak dapat memposting status kosong tanpa berkas",
+    "account_not_locked_warning_link": "terkunci",
+    "account_not_locked_warning": "Akun Anda tidak {0}. Siapapun dapat mengikuti Anda untuk melihat postingan hanya-pengikut Anda.",
+    "new_status": "Posting status baru"
   },
   "general": {
     "apply": "Terapkan",
@@ -424,7 +466,10 @@
     "mentions": "Sebutan",
     "back": "Kembali",
     "administration": "Administrasi",
-    "about": "Tentang"
+    "about": "Tentang",
+    "timelines": "Linimasa",
+    "chats": "Obrolan",
+    "dms": "Pesan langsung"
   },
   "media_modal": {
     "next": "Selanjutnya",
@@ -463,7 +508,8 @@
     "text_limit": "Batas teks",
     "gopher": "Gopher",
     "pleroma_chat_messages": "Pleroma Obrolan",
-    "chat": "Obrolan"
+    "chat": "Obrolan",
+    "upload_limit": "Batas unggahan"
   },
   "exporter": {
     "processing": "Memproses, Anda akan segera diminta untuk mengunduh berkas Anda",
@@ -479,5 +525,16 @@
   },
   "selectable_list": {
     "select_all": "Pilih semua"
+  },
+  "interactions": {
+    "moves": "Pengguna yang bermigrasi",
+    "follows": "Pengikut baru",
+    "favs_repeats": "Ulangan dan favorit"
+  },
+  "errors": {
+    "storage_unavailable": "Pleroma tidak dapat mengakses penyimpanan browser. Login Anda atau pengaturan lokal Anda tidak akan tersimpan dan masalah yang tidak terduga dapat terjadi. Coba mengaktifkan kuki."
+  },
+  "shoutbox": {
+    "title": "Kotak Suara"
   }
 }

From 9319666f04e9c428b2d39605f172dc05057c4366 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= <me@mkljczk.pl>
Date: Sat, 21 Aug 2021 15:55:12 +0000
Subject: [PATCH 144/169] Translated using Weblate (Polish)

Currently translated at 98.8% (708 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/pl/
---
 src/i18n/pl.json | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/i18n/pl.json b/src/i18n/pl.json
index 745e39c4..11409169 100644
--- a/src/i18n/pl.json
+++ b/src/i18n/pl.json
@@ -632,7 +632,8 @@
     "hide_all_muted_posts": "Ukryj wyciszone słowa",
     "reply_visibility_following_short": "Pokazuj odpowiedzi obserwującym",
     "reply_visibility_self_short": "Pokazuj odpowiedzi tylko do mnie",
-    "sensitive_by_default": "Domyślnie oznaczaj wpisy jako wrażliwe"
+    "sensitive_by_default": "Domyślnie oznaczaj wpisy jako wrażliwe",
+    "hide_shoutbox": "Ukryj shoutbox instancji"
   },
   "time": {
     "day": "{0} dzień",

From 0543c8d536374317127b602671df116a9e3c3d57 Mon Sep 17 00:00:00 2001
From: tarteka <info@tarteka.net>
Date: Mon, 23 Aug 2021 15:10:27 +0000
Subject: [PATCH 145/169] Translated using Weblate (Basque)

Currently translated at 80.4% (576 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/eu/
---
 src/i18n/eu.json | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/src/i18n/eu.json b/src/i18n/eu.json
index 080caaaf..55fb8d55 100644
--- a/src/i18n/eu.json
+++ b/src/i18n/eu.json
@@ -495,7 +495,9 @@
       "title": "Bertsioa",
       "backend_version": "Backend bertsioa",
       "frontend_version": "Frontend bertsioa"
-    }
+    },
+    "save": "Aldaketak gorde",
+    "setting_changed": "Ezarpena lehenetsitakoaren desberdina da"
   },
   "time": {
     "day": "{0} egun",
@@ -709,5 +711,9 @@
   },
   "errors": {
     "storage_unavailable": "Pleromak ezin izan du nabigatzailearen biltegira sartu. Hasiera-saioa edo tokiko ezarpenak ez dira gordeko eta ustekabeko arazoak sor ditzake. Saiatu cookie-ak gaitzen."
+  },
+  "remote_user_resolver": {
+    "searching_for": "Bilatzen",
+    "error": "Ez da aurkitu."
   }
 }

From 144cee6d34d8adb9795f21bc2acefde98637d723 Mon Sep 17 00:00:00 2001
From: "@liimee" <alt3753.7@gmail.com>
Date: Wed, 25 Aug 2021 00:30:06 +0000
Subject: [PATCH 146/169] Translated using Weblate (Indonesian)

Currently translated at 58.6% (420 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/id/
---
 src/i18n/id.json | 21 ++++++++++++++++-----
 1 file changed, 16 insertions(+), 5 deletions(-)

diff --git a/src/i18n/id.json b/src/i18n/id.json
index d2b43656..7ff07435 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -106,7 +106,7 @@
     "cBlue": "Biru (Balas, ikuti)",
     "btnRadius": "Tombol",
     "bot": "Ini adalah akun bot",
-    "block_export": "",
+    "block_export": "Ekspor blokiran",
     "bio": "Bio",
     "background": "Latar belakang",
     "avatarRadius": "Avatar",
@@ -144,12 +144,21 @@
     "hide_user_stats": "Sembunyikan statistik pengguna (contoh. jumlah pengikut)",
     "hide_post_stats": "Sembunyikan statistik postingan (contoh. jumlah favorit)",
     "use_one_click_nsfw": "Buka lampiran NSFW hanya dengan satu klik",
-    "hide_wallpaper": "Sembunyikan latar belakang instansi"
+    "hide_wallpaper": "Sembunyikan latar belakang instansi",
+    "blocks_imported": "Blokiran diimpor! Pemrosesannya mungkin memakan sedikit waktu.",
+    "block_import_error": "Terjadi kesalahan ketika mengimpor blokiran",
+    "block_import": "Impor blokiran",
+    "block_export_button": "Ekspor blokiran Anda menjadi berkas csv",
+    "blocks_tab": "Blokiran",
+    "delete_account_instructions": "Ketik kata sandi Anda pada input di bawah untuk mengkonfirmasi penghapusan akun.",
+    "mutes_and_blocks": "Bisuan dan Blokiran",
+    "enter_current_password_to_confirm": "Masukkan kata sandi Anda saat ini untuk mengkonfirmasi identitas Anda"
   },
   "about": {
     "mrf": {
       "keyword": {
-        "reject": "Tolak"
+        "reject": "Tolak",
+        "is_replaced_by": "→"
       },
       "simple": {
         "quarantine_desc": "Instansi ini hanya akan mengirim postingan publik ke instansi-instansi berikut:",
@@ -440,7 +449,8 @@
     "add_emoji": "Sisipkan emoji",
     "search_emoji": "Cari emoji",
     "emoji": "Emoji",
-    "stickers": "Stiker"
+    "stickers": "Stiker",
+    "keep_open": "Tetap buka pemilih"
   },
   "polls": {
     "expired": "Japat berakhir {0} yang lalu",
@@ -518,7 +528,8 @@
   "domain_mute_card": {
     "unmute": "Berhenti membisukan",
     "mute_progress": "Membisukan…",
-    "mute": "Bisukan"
+    "mute": "Bisukan",
+    "unmute_progress": "Memberhentikan pembisuan…"
   },
   "display_date": {
     "today": "Hari Ini"

From 98da3ad124aebb69a8f29634f561276848e5e4b8 Mon Sep 17 00:00:00 2001
From: "@liimee" <alt3753.7@gmail.com>
Date: Mon, 30 Aug 2021 00:40:02 +0000
Subject: [PATCH 147/169] Translated using Weblate (Indonesian)

Currently translated at 60.6% (434 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/id/
---
 src/i18n/id.json | 28 ++++++++++++++++++++++------
 1 file changed, 22 insertions(+), 6 deletions(-)

diff --git a/src/i18n/id.json b/src/i18n/id.json
index 7ff07435..ae8428f6 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -118,7 +118,13 @@
       },
       "confirm_and_enable": "Konfirmasi & aktifkan OTP",
       "setup_otp": "Siapkan OTP",
-      "otp": "OTP"
+      "otp": "OTP",
+      "recovery_codes_warning": "Tulis kode-kode nya atau simpan mereka di tempat yang aman - jika tidak Anda tidak akan melihat mereka lagi. Jika Anda tidak dapat mengakses aplikasi 2FA Anda dan kode pemulihan Anda hilang Anda tidak akan bisa mengakses akun Anda.",
+      "authentication_methods": "Metode otentikasi",
+      "recovery_codes": "Kode pemulihan.",
+      "warning_of_generate_new_codes": "Ketika Anda menghasilkan kode pemulihan baru, kode lama Anda berhenti bekerja.",
+      "generate_new_recovery_codes": "Hasilkan kode pemulihan baru",
+      "title": "Otentikasi Dua-faktor"
     },
     "app_name": "Nama aplikasi",
     "save": "Simpan perubahan",
@@ -152,7 +158,11 @@
     "blocks_tab": "Blokiran",
     "delete_account_instructions": "Ketik kata sandi Anda pada input di bawah untuk mengkonfirmasi penghapusan akun.",
     "mutes_and_blocks": "Bisuan dan Blokiran",
-    "enter_current_password_to_confirm": "Masukkan kata sandi Anda saat ini untuk mengkonfirmasi identitas Anda"
+    "enter_current_password_to_confirm": "Masukkan kata sandi Anda saat ini untuk mengkonfirmasi identitas Anda",
+    "filtering": "Penyaringan",
+    "word_filter": "Penyaring kata",
+    "avatar_size_instruction": "Ukuran minimum gambar avatar yang disarankan adalah 150x150 piksel.",
+    "attachmentRadius": "Lampiran"
   },
   "about": {
     "mrf": {
@@ -299,7 +309,8 @@
     "followed_you": "mengikuti Anda",
     "error": "Terjadi kesalahan ketika memuat notifikasi: {0}",
     "migrated_to": "bermigrasi ke",
-    "load_older": "Muat notifikasi yang lebih lama"
+    "load_older": "Muat notifikasi yang lebih lama",
+    "broken_favorite": "Status tak diketahui, mencarinya…"
   },
   "who_to_follow": {
     "more": "Lebih banyak"
@@ -450,7 +461,8 @@
     "search_emoji": "Cari emoji",
     "emoji": "Emoji",
     "stickers": "Stiker",
-    "keep_open": "Tetap buka pemilih"
+    "keep_open": "Tetap buka pemilih",
+    "custom": "Emoji kustom"
   },
   "polls": {
     "expired": "Japat berakhir {0} yang lalu",
@@ -479,7 +491,8 @@
     "about": "Tentang",
     "timelines": "Linimasa",
     "chats": "Obrolan",
-    "dms": "Pesan langsung"
+    "dms": "Pesan langsung",
+    "friend_requests": "Ingin mengikuti"
   },
   "media_modal": {
     "next": "Selanjutnya",
@@ -496,7 +509,10 @@
     "password": "Kata sandi",
     "logout": "Keluar",
     "description": "Masuk dengan OAuth",
-    "login": "Masuk"
+    "login": "Masuk",
+    "heading": {
+      "totp": "Otentikasi dua-faktor"
+    }
   },
   "importer": {
     "error": "Terjadi kesalahan ketika mnengimpor berkas ini.",

From 6e571706261db821d191299ba1cd81fcdf980c10 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= <kantcer@gmail.com>
Date: Thu, 2 Sep 2021 14:47:32 +0000
Subject: [PATCH 148/169] Added translation using Weblate (Vietnamese)

---
 src/i18n/vi.json | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 src/i18n/vi.json

diff --git a/src/i18n/vi.json b/src/i18n/vi.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/src/i18n/vi.json
@@ -0,0 +1 @@
+{}

From fa75a3a6157f02134e1f1365685ea9e37838db61 Mon Sep 17 00:00:00 2001
From: tarteka <info@tarteka.net>
Date: Wed, 1 Sep 2021 10:19:26 +0000
Subject: [PATCH 149/169] Translated using Weblate (Basque)

Currently translated at 80.7% (578 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/eu/
---
 src/i18n/eu.json | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/i18n/eu.json b/src/i18n/eu.json
index 55fb8d55..29eb7c50 100644
--- a/src/i18n/eu.json
+++ b/src/i18n/eu.json
@@ -225,7 +225,7 @@
       "title": "Bi-faktore autentifikazioa",
       "generate_new_recovery_codes": "Sortu berreskuratze kode berriak",
       "warning_of_generate_new_codes": "Berreskuratze kode berriak sortzean, zure berreskuratze kode zaharrak ez dute balioko.",
-      "recovery_codes": "Berreskuratze kodea",
+      "recovery_codes": "Berreskuratze kodea.",
       "waiting_a_recovery_codes": "Babes-kopia kodeak jasotzen…",
       "recovery_codes_warning": "Idatzi edo gorde kodeak leku seguruan - bestela ez dituzu berriro ikusiko. Zure 2FA aplikaziorako sarbidea eta berreskuratze kodeak galduz gero, zure kontutik blokeatuta egongo zara.",
       "authentication_methods": "Autentifikazio metodoa",
@@ -483,7 +483,7 @@
         "button": "Botoia",
         "text": "Hamaika {0} eta {1}",
         "mono": "edukia",
-        "input": "Jadanik Los Angeles-en",
+        "input": "Jadanik Los Angeles-en.",
         "faint_link": "laguntza",
         "fine_print": "Irakurri gure {0} ezer erabilgarria ikasteko!",
         "header_faint": "Ondo dago",
@@ -497,7 +497,9 @@
       "frontend_version": "Frontend bertsioa"
     },
     "save": "Aldaketak gorde",
-    "setting_changed": "Ezarpena lehenetsitakoaren desberdina da"
+    "setting_changed": "Ezarpena lehenetsitakoaren desberdina da",
+    "allow_following_move": "Baimendu jarraipen automatikoa, jarraitzen duzun kontua beste instantzia batera eramaten denean",
+    "new_email": "E-posta berria"
   },
   "time": {
     "day": "{0} egun",

From ff5ed29ec167448d705f9e9457babe95a27310d8 Mon Sep 17 00:00:00 2001
From: Kana <gudzpoz@live.com>
Date: Fri, 3 Sep 2021 14:07:01 +0000
Subject: [PATCH 150/169] Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/zh_Hans/
---
 src/i18n/zh.json | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/i18n/zh.json b/src/i18n/zh.json
index f1a78c0c..9f91ef1a 100644
--- a/src/i18n/zh.json
+++ b/src/i18n/zh.json
@@ -588,7 +588,8 @@
       "backup_settings": "备份设置到文件",
       "backup_restore": "设置备份"
     },
-    "right_sidebar": "在右侧显示侧边栏"
+    "right_sidebar": "在右侧显示侧边栏",
+    "hide_shoutbox": "隐藏实例留言板"
   },
   "time": {
     "day": "{0} 天",
@@ -728,7 +729,8 @@
       "striped": "条纹背景",
       "solid": "单一颜色背景",
       "disabled": "不突出显示"
-    }
+    },
+    "edit_profile": "编辑个人资料"
   },
   "user_profile": {
     "timeline_title": "用户时间线",

From 5bb471a68e4778d8d53d61b8f4a7842f5b4ea491 Mon Sep 17 00:00:00 2001
From: "@liimee" <alt3753.7@gmail.com>
Date: Thu, 2 Sep 2021 05:52:50 +0000
Subject: [PATCH 151/169] Translated using Weblate (Indonesian)

Currently translated at 67.3% (482 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/id/
---
 src/i18n/id.json | 73 ++++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 64 insertions(+), 9 deletions(-)

diff --git a/src/i18n/id.json b/src/i18n/id.json
index ae8428f6..a2e7df0c 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -15,7 +15,18 @@
       "advanced_colors": {
         "alert_neutral": "Neutral",
         "alert_warning": "Peringatan",
-        "alert_error": "Kesalahan"
+        "alert_error": "Kesalahan",
+        "_tab_label": "Lanjutan",
+        "post": "Postingan/Bio pengguna",
+        "popover": "Tooltip, menu, popover",
+        "badge_notification": "Notifikasi",
+        "top_bar": "Bar atas",
+        "borders": "",
+        "buttons": "Tombol",
+        "wallpaper": "Latar belakang",
+        "panel_header": "Header panel",
+        "icons": "Ikon-ikon",
+        "disabled": "Dinonaktifkan"
       },
       "common_colors": {
         "main": "Warna umum",
@@ -32,7 +43,10 @@
       },
       "switcher": {
         "help": {
-          "upgraded_from_v2": "PleromaFE telah diperbarui, tema dapat terlihat sedikit berbeda dari apa yang Anda ingat."
+          "upgraded_from_v2": "PleromaFE telah diperbarui, tema dapat terlihat sedikit berbeda dari apa yang Anda ingat.",
+          "future_version_imported": "Berkas yang Anda impor dibuat pada versi FE yang lebih baru.",
+          "older_version_imported": "Berkas yang Anda impor dibuat pada versi FE yang lebih lama.",
+          "fe_upgraded": "Mesin tema PleromaFE diperbarui setelah pembaruan versi."
         },
         "use_source": "Versi baru",
         "use_snapshot": "Versi lama",
@@ -47,6 +61,12 @@
         "family": "Nama font",
         "size": "Ukuran (dalam px)",
         "weight": "Berat (ketebalan)"
+      },
+      "shadows": {
+        "components": {
+          "panel": "Panel",
+          "panelHeader": "Header panel"
+        }
       }
     },
     "notification_setting_privacy": "Privasi",
@@ -124,7 +144,11 @@
       "recovery_codes": "Kode pemulihan.",
       "warning_of_generate_new_codes": "Ketika Anda menghasilkan kode pemulihan baru, kode lama Anda berhenti bekerja.",
       "generate_new_recovery_codes": "Hasilkan kode pemulihan baru",
-      "title": "Otentikasi Dua-faktor"
+      "title": "Otentikasi Dua-faktor",
+      "waiting_a_recovery_codes": "Menerima kode cadangan…",
+      "verify": {
+        "desc": "Untuk mengaktifkan otentikasi dua-faktor, masukkan kode dari aplikasi dua-faktor Anda:"
+      }
     },
     "app_name": "Nama aplikasi",
     "save": "Simpan perubahan",
@@ -162,7 +186,29 @@
     "filtering": "Penyaringan",
     "word_filter": "Penyaring kata",
     "avatar_size_instruction": "Ukuran minimum gambar avatar yang disarankan adalah 150x150 piksel.",
-    "attachmentRadius": "Lampiran"
+    "attachmentRadius": "Lampiran",
+    "cGreen": "Hijau (Retweet)",
+    "max_thumbnails": "Jumlah thumbnail maksimum per postingan",
+    "loop_video": "Ulang-ulang video",
+    "loop_video_silent_only": "Ulang-ulang video tanpa suara (seperti \"gif\" Mastodon)",
+    "pause_on_unfocused": "Jeda aliran ketika tab di dalam fokus",
+    "reply_visibility_following": "Hanya tampilkan balasan yang ditujukan kepada saya atau orang yang saya ikuti",
+    "reply_visibility_following_short": "Tampilkan balasan ke orang yang saya ikuti",
+    "saving_err": "Terjadi kesalahan ketika menyimpan pengaturan",
+    "search_user_to_block": "Cari siapa yang Anda ingin blokir",
+    "search_user_to_mute": "Cari siapa yang ingin Anda bisukan",
+    "set_new_avatar": "Tetapkan avatar baru",
+    "set_new_profile_background": "Tetapkan latar belakang profil baru",
+    "subject_line_behavior": "Salin subyek ketika membalas",
+    "subject_line_email": "Seperti surel: \"re: subyek\"",
+    "subject_line_mastodon": "Seperti mastodon: salin saja",
+    "subject_line_noop": "Jangan salin",
+    "useStreamingApiWarning": "(Tidak disarankan, eksperimental, diketahui dapat melewati postingan-postingan)",
+    "fun": "Seru",
+    "enable_web_push_notifications": "Aktifkan notifikasi push web",
+    "more_settings": "Lebih banyak pengaturan",
+    "reply_visibility_all": "Tampilkan semua balasan",
+    "reply_visibility_self": "Hanya tampilkan balasan yang ditujukan kepada saya"
   },
   "about": {
     "mrf": {
@@ -178,7 +224,8 @@
         "accept_desc": "Instansi ini hanya menerima pesan dari instansi-instansi berikut:",
         "accept": "Terima"
       },
-      "federation": "Federasi"
+      "federation": "Federasi",
+      "mrf_policies": "Kebijakan MRF yang diaktifkan"
     },
     "staff": "Staf"
   },
@@ -349,7 +396,12 @@
     "forgot_password": "Lupa kata sandi?",
     "placeholder": "Surel atau nama pengguna Anda",
     "return_home": "Kembali ke halaman beranda",
-    "too_many_requests": "Anda telah mencapai batas percobaan, coba lagi nanti."
+    "too_many_requests": "Anda telah mencapai batas percobaan, coba lagi nanti.",
+    "instruction": "Masukkan surel atau nama pengguna Anda. Kami akan mengirimkan Anda tautan untuk mengatur ulang kata sandi.",
+    "password_reset": "Pengatur-ulangan kata sandi",
+    "password_reset_disabled": "Pengatur-ulangan kata sandi dinonaktifkan. Hubungi administrator instansi Anda.",
+    "password_reset_required": "Anda harus mengatur ulang kata sandi Anda untuk masuk.",
+    "password_reset_required_but_mailer_is_disabled": "Anda harus mengatur ulang kata sandi, tetapi pengatur-ulangan kata sandi dinonaktifkan. Silakan hubungi administrator instansi Anda."
   },
   "chats": {
     "you": "Anda:",
@@ -475,7 +527,8 @@
     "votes": "suara",
     "option": "Opsi",
     "add_option": "Tambahkan opsi",
-    "add_poll": "Tambahkan japat"
+    "add_poll": "Tambahkan japat",
+    "not_enough_options": "Terlalu sedikit opsi yang unik pada japat"
   },
   "nav": {
     "preferences": "Preferensi",
@@ -512,7 +565,8 @@
     "login": "Masuk",
     "heading": {
       "totp": "Otentikasi dua-faktor"
-    }
+    },
+    "enter_two_factor_code": "Masukkan kode dua-faktor"
   },
   "importer": {
     "error": "Terjadi kesalahan ketika mnengimpor berkas ini.",
@@ -556,7 +610,8 @@
   "interactions": {
     "moves": "Pengguna yang bermigrasi",
     "follows": "Pengikut baru",
-    "favs_repeats": "Ulangan dan favorit"
+    "favs_repeats": "Ulangan dan favorit",
+    "load_older": "Muat interaksi yang lebih tua"
   },
   "errors": {
     "storage_unavailable": "Pleroma tidak dapat mengakses penyimpanan browser. Login Anda atau pengaturan lokal Anda tidak akan tersimpan dan masalah yang tidak terduga dapat terjadi. Coba mengaktifkan kuki."

From 13468f2a893eb05837851e5eb2a45915dede5988 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= <kantcer@gmail.com>
Date: Thu, 2 Sep 2021 14:48:29 +0000
Subject: [PATCH 152/169] Translated using Weblate (Vietnamese)

Currently translated at 50.0% (358 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/vi/
---
 src/i18n/vi.json | 436 ++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 435 insertions(+), 1 deletion(-)

diff --git a/src/i18n/vi.json b/src/i18n/vi.json
index 0967ef42..088d73cc 100644
--- a/src/i18n/vi.json
+++ b/src/i18n/vi.json
@@ -1 +1,435 @@
-{}
+{
+  "about": {
+    "mrf": {
+      "federation": "Liên hợp",
+      "keyword": {
+        "keyword_policies": "Chính sách quan trọng",
+        "reject": "Từ chối",
+        "replace": "Thay thế",
+        "is_replaced_by": "→",
+        "ftl_removal": "Giới hạn chung"
+      },
+      "mrf_policies": "Kích hoạt chính sách MRF",
+      "simple": {
+        "simple_policies": "Quy tắc máy chủ",
+        "accept": "Đồng ý",
+        "accept_desc": "Máy chủ này chỉ chấp nhận tin nhắn từ những máy chủ:",
+        "reject": "Từ chối",
+        "quarantine": "Bảo hành",
+        "quarantine_desc": "Máy chủ này sẽ gửi tút công khai đến những máy chủ:",
+        "ftl_removal": "Giới hạn chung",
+        "media_removal": "Ẩn Media",
+        "media_removal_desc": "Media từ những máy chủ sau sẽ bị ẩn:",
+        "media_nsfw": "Áp đặt nhạy cảm",
+        "media_nsfw_desc": "Nội dung từ những máy chủ sau sẽ bị tự động gắn nhãn nhạy cảm:",
+        "reject_desc": "Máy chủ này không chấp nhận tin nhắn từ những máy chủ:",
+        "ftl_removal_desc": "Nội dung từ những máy chủ sau sẽ bị ẩn:"
+      },
+      "mrf_policies_desc": "Các chính sách MRF kiểm soát sự liên hợp của máy chủ. Các chính sách sau được bật:"
+    },
+    "staff": "Nhân viên"
+  },
+  "domain_mute_card": {
+    "mute": "Ẩn",
+    "mute_progress": "Đang ẩn…",
+    "unmute": "Ngưng ẩn",
+    "unmute_progress": "Đang ngưng ẩn…"
+  },
+  "exporter": {
+    "export": "Xuất dữ liệu",
+    "processing": "Đang chuẩn bị tập tin cho bạn tải về"
+  },
+  "features_panel": {
+    "chat": "Chat",
+    "pleroma_chat_messages": "Pleroma Chat",
+    "gopher": "Gopher",
+    "media_proxy": "Proxy media",
+    "text_limit": "Giới hạn ký tự",
+    "title": "Tính năng",
+    "who_to_follow": "Đề xuất theo dõi",
+    "upload_limit": "Giới hạn tải lên",
+    "scope_options": "Đa dạng kiểu đăng"
+  },
+  "finder": {
+    "error_fetching_user": "Lỗi người dùng",
+    "find_user": "Tìm người dùng"
+  },
+  "shoutbox": {
+    "title": "Chat cùng nhau"
+  },
+  "general": {
+    "apply": "Áp dụng",
+    "submit": "Gửi tặng",
+    "more": "Nhiều hơn",
+    "loading": "Đang tải…",
+    "generic_error": "Đã có lỗi xảy ra",
+    "error_retry": "Xin hãy thử lại",
+    "retry": "Thử lại",
+    "optional": "tùy chọn",
+    "show_more": "Xem thêm",
+    "show_less": "Thu gọn",
+    "dismiss": "Bỏ qua",
+    "cancel": "Hủy bỏ",
+    "disable": "Tắt",
+    "enable": "Bật",
+    "confirm": "Xác nhận",
+    "verify": "Xác thực",
+    "close": "Đóng",
+    "peek": "Thu gọn",
+    "role": {
+      "admin": "Quản trị viên",
+      "moderator": "Kiểm duyệt viên"
+    },
+    "flash_security": "Lưu ý rằng điều này có thể tiềm ẩn nguy hiểm vì nội dung Flash là mã lập trình tùy ý.",
+    "flash_fail": "Tải nội dung Flash thất bại, tham khảo chi tiết trong console.",
+    "flash_content": "Nhấn để hiện nội dung Flash bằng Ruffle (Thử nghiệm, có thể không dùng được)."
+  },
+  "image_cropper": {
+    "crop_picture": "Cắt hình ảnh",
+    "save": "Lưu",
+    "save_without_cropping": "Bỏ qua cắt",
+    "cancel": "Hủy bỏ"
+  },
+  "importer": {
+    "submit": "Gửi đi",
+    "success": "Đã nhập dữ liệu thành công.",
+    "error": "Có lỗi xảy ra khi nhập dữ liệu từ tập tin này."
+  },
+  "login": {
+    "login": "Đăng nhập",
+    "description": "Đăng nhập bằng OAuth",
+    "logout": "Đăng xuất",
+    "password": "Mật khẩu",
+    "placeholder": "vd: cobetronxinh",
+    "register": "Đăng ký",
+    "username": "Tên người dùng",
+    "hint": "Đăng nhập để cùng trò chuyện",
+    "authentication_code": "Mã truy cập",
+    "enter_recovery_code": "Nhập mã khôi phục",
+    "recovery_code": "Mã khôi phục",
+    "heading": {
+      "totp": "Xác thực hai bước",
+      "recovery": "Khôi phục hai bước"
+    },
+    "enter_two_factor_code": "Nhập mã xác thực hai bước"
+  },
+  "media_modal": {
+    "previous": "Trước đó",
+    "next": "Kế tiếp"
+  },
+  "nav": {
+    "about": "Về máy chủ này",
+    "administration": "Vận hành bởi",
+    "back": "Quay lại",
+    "friend_requests": "Yêu cầu theo dõi",
+    "mentions": "Lượt nhắc đến",
+    "interactions": "Giao tiếp",
+    "dms": "Nhắn tin",
+    "public_tl": "Bảng tin máy chủ",
+    "timeline": "Bảng tin",
+    "home_timeline": "Bảng tin của bạn",
+    "twkn": "Thế giới",
+    "bookmarks": "Đã lưu",
+    "user_search": "Tìm kiếm người dùng",
+    "search": "Tìm kiếm",
+    "who_to_follow": "Đề xuất theo dõi",
+    "preferences": "Thiết lập",
+    "timelines": "Bảng tin",
+    "chats": "Chat"
+  },
+  "notifications": {
+    "broken_favorite": "Trạng thái chưa rõ, đang tìm kiếm…",
+    "favorited_you": "thích tút của bạn",
+    "followed_you": "theo dõi bạn",
+    "follow_request": "yêu cầu theo dõi bạn",
+    "load_older": "Xem những thông báo cũ hơn",
+    "notifications": "Thông báo",
+    "read": "Đọc!",
+    "repeated_you": "chia sẻ tút của bạn",
+    "no_more_notifications": "Không còn thông báo nào",
+    "migrated_to": "chuyển sang",
+    "reacted_with": "chạm tới {0}",
+    "error": "Lỗi xử lý thông báo: {0}"
+  },
+  "polls": {
+    "add_poll": "Tạo bình chọn",
+    "option": "Lựa chọn",
+    "votes": "người bình chọn",
+    "people_voted_count": "{count} người bình chọn | {count} người bình chọn",
+    "vote": "Bình chọn",
+    "type": "Kiểu bình chọn",
+    "single_choice": "Chỉ được chọn một lựa chọn",
+    "multiple_choices": "Cho phép chọn nhiều lựa chọn",
+    "expiry": "Thời hạn bình chọn",
+    "expires_in": "Bình chọn kết thúc sau {0}",
+    "not_enough_options": "Không đủ lựa chọn tối thiểu",
+    "add_option": "Thêm lựa chọn",
+    "votes_count": "{count} bình chọn | {count} bình chọn",
+    "expired": "Bình chọn đã kết thúc {0} trước"
+  },
+  "emoji": {
+    "stickers": "Sticker",
+    "emoji": "Emoji",
+    "keep_open": "Mở khung lựa chọn",
+    "search_emoji": "Tìm emoji",
+    "add_emoji": "Nhập emoji",
+    "custom": "Tùy chỉnh emoji",
+    "unicode": "Unicode emoji",
+    "load_all_hint": "Tải trước {saneAmount} emoji, tải toàn bộ emoji có thể gây xử lí chậm.",
+    "load_all": "Đang tải {emojiAmount} emoji"
+  },
+  "interactions": {
+    "favs_repeats": "Tương tác",
+    "follows": "Lượt theo dõi mới",
+    "moves": "Người dùng chuyển đi",
+    "load_older": "Xem tương tác cũ hơn"
+  },
+  "post_status": {
+    "new_status": "Đăng tút",
+    "account_not_locked_warning": "Tài khoản của bạn chưa {0}. Bất kỳ ai cũng có thể xem những tút dành cho người theo dõi của bạn.",
+    "account_not_locked_warning_link": "đã khóa",
+    "attachments_sensitive": "Đánh dấu media là nhạy cảm",
+    "media_description": "Mô tả media",
+    "content_type": {
+      "text/plain": "Văn bản",
+      "text/html": "HTML",
+      "text/markdown": "Markdown",
+      "text/bbcode": "BBCode"
+    },
+    "content_warning": "Tiêu đề (tùy chọn)",
+    "default": "Just landed in L.A.",
+    "direct_warning_to_first_only": "Người đầu tiên được nhắc đến mới có thể thấy tút này.",
+    "posting": "Đang đăng tút",
+    "post": "Đăng",
+    "preview": "Xem trước",
+    "preview_empty": "Trống",
+    "empty_status_error": "Không thể đăng một tút trống và không có media",
+    "media_description_error": "Cập nhật media thất bại, thử lại sau",
+    "scope_notice": {
+      "private": "Chỉ những người theo dõi bạn mới thấy tút này",
+      "unlisted": "Tút này sẽ không hiện trong bảng tin máy chủ và thế giới",
+      "public": "Mọi người đều có thể thấy tút này"
+    },
+    "scope": {
+      "public": "Công khai - hiện trên bảng tin máy chủ",
+      "private": "Riêng tư - Chỉ dành cho người theo dõi",
+      "unlisted": "Hạn chế - không hiện trên bảng tin",
+      "direct": "Tin nhắn - chỉ người được nhắc đến mới thấy"
+    },
+    "direct_warning_to_all": "Những ai được nhắc đến sẽ đều thấy tút này."
+  },
+  "registration": {
+    "bio": "Tiểu sử",
+    "email": "Email",
+    "fullname": "Tên hiển thị",
+    "password_confirm": "Xác nhận mật khẩu",
+    "registration": "Đăng ký",
+    "token": "Lời mời",
+    "captcha": "CAPTCHA",
+    "new_captcha": "Nhấn vào hình ảnh để đổi captcha mới",
+    "username_placeholder": "vd: cobetronxinh",
+    "fullname_placeholder": "vd: Cô Bé Tròn Xinh",
+    "bio_placeholder": "vd:\nHi, I'm Cô Bé Tròn Xinh.\nI’m an anime girl living in suburban Vietnam. You may know me from the school.",
+    "reason": "Lý do đăng ký",
+    "reason_placeholder": "Máy chủ này phê duyệt đăng ký thủ công.\nHãy cho quản trị viên biết lý do bạn muốn đăng ký.",
+    "register": "Đăng ký",
+    "validations": {
+      "username_required": "không được để trống",
+      "fullname_required": "không được để trống",
+      "email_required": "không được để trống",
+      "password_confirmation_required": "không được để trống",
+      "password_confirmation_match": "phải trùng khớp với mật khẩu",
+      "password_required": "không được để trống"
+    }
+  },
+  "remote_user_resolver": {
+    "remote_user_resolver": "Giải quyết người dùng từ xa",
+    "searching_for": "Tìm kiếm",
+    "error": "Không tìm thấy."
+  },
+  "selectable_list": {
+    "select_all": "Chọn tất cả"
+  },
+  "settings": {
+    "app_name": "Tên app",
+    "save": "Lưu thay đổi",
+    "security": "Bảo mật",
+    "enter_current_password_to_confirm": "Nhập mật khẩu để xác thực",
+    "mfa": {
+      "otp": "OTP",
+      "setup_otp": "Thiết lập OTP",
+      "wait_pre_setup_otp": "hậu thiết lập OTP",
+      "confirm_and_enable": "Xác nhận và kích hoạt OTP",
+      "title": "Xác thực hai bước",
+      "recovery_codes": "Những mã khôi phục.",
+      "waiting_a_recovery_codes": "Đang nhận mã khôi phục…",
+      "authentication_methods": "Phương pháp xác thực",
+      "scan": {
+        "title": "Quét",
+        "desc": "Sử dụng app xác thực hai bước để quét mã QR hoặc nhập mã khôi phục:",
+        "secret_code": "Mã"
+      },
+      "verify": {
+        "desc": "Để bật xác thực hai bước, nhập mã từ app của bạn:"
+      },
+      "generate_new_recovery_codes": "Tạo mã khôi phục mới",
+      "warning_of_generate_new_codes": "Khi tạo mã khôi phục mới, những mã khôi phục cũ sẽ không sử dụng được nữa.",
+      "recovery_codes_warning": "Hãy viết lại mã và cất ở một nơi an toàn - những mã này sẽ không xuất hiện lại nữa. Nếu mất quyền sử dụng app 2FA app và mã khôi phục, tài khoản của bạn sẽ không thể truy cập."
+    },
+    "allow_following_move": "Cho phép tự động theo dõi lại khi tài khoản đang theo dõi chuyển sang máy chủ khác",
+    "attachmentRadius": "Tập tin tải lên",
+    "attachments": "Tập tin tải lên",
+    "avatar": "Ảnh đại diện",
+    "avatarAltRadius": "Ảnh đại diện (thông báo)",
+    "avatarRadius": "Ảnh đại diện",
+    "background": "Ảnh nền",
+    "bio": "Tiểu sử",
+    "block_export": "Xuất danh sách chặn",
+    "block_import": "Nhập danh sách chặn",
+    "block_import_error": "Lỗi khi nhập danh sách chặn",
+    "mute_export": "Xuất danh sách ẩn",
+    "mute_export_button": "Xuất danh sách ẩn ra tập tin CSV",
+    "mute_import": "Nhập danh sách ẩn",
+    "mute_import_error": "Lỗi khi nhập danh sách ẩn",
+    "mutes_imported": "Đã nhập danh sách ẩn! Sẽ mất một lúc nữa để hoàn thành.",
+    "import_mutes_from_a_csv_file": "Nhập danh sách ẩn từ tập tin CSV",
+    "blocks_tab": "Danh sách chặn",
+    "bot": "Đây là tài khoản Bot",
+    "btnRadius": "Nút",
+    "cBlue": "Xanh (Trả lời, theo dõi)",
+    "cOrange": "Cam (Thích)",
+    "cRed": "Đỏ (Hủy bỏ)",
+    "change_email": "Đổi email",
+    "change_email_error": "Có lỗi xảy ra khi đổi email.",
+    "changed_email": "Đã đổi email thành công!",
+    "change_password": "Đổi mật khẩu",
+    "changed_password": "Đổi mật khẩu thành công!",
+    "chatMessageRadius": "Tin nhắn chat",
+    "follows_imported": "Đã nhập danh sách theo dõi! Sẽ mất một lúc nữa để hoàn thành.",
+    "collapse_subject": "Thu gọn những tút có tựa đề",
+    "composing": "Thu gọn",
+    "current_password": "Mật khẩu cũ",
+    "mutes_and_blocks": "Ẩn và Chặn",
+    "data_import_export_tab": "Nhập / Xuất dữ liệu",
+    "default_vis": "Kiểu đăng tút mặc định",
+    "delete_account": "Xóa tài khoản",
+    "delete_account_error": "Có lỗi khi xóa tài khoản. Xin liên hệ quản trị viên máy chủ để tìm hiểu.",
+    "delete_account_instructions": "Nhập mật khẩu bên dưới để xác nhận.",
+    "domain_mutes": "Máy chủ",
+    "avatar_size_instruction": "Kích cỡ tối thiểu 150x150 pixels.",
+    "pad_emoji": "Nhớ chừa khoảng cách khi chèn emoji",
+    "emoji_reactions_on_timeline": "Hiện tương tác emoji trên bảng tin",
+    "export_theme": "Lưu mẫu",
+    "filtering": "Bộ lọc",
+    "filtering_explanation": "Những tút chứa từ sau sẽ bị ẩn, mỗi chữ một hàng",
+    "word_filter": "Bộ lọc từ ngữ",
+    "follow_export": "Xuất danh sách theo dõi",
+    "follow_import": "Nhập danh sách theo dõi",
+    "follow_import_error": "Lỗi khi nhập danh sách theo dõi",
+    "accent": "Màu chủ đạo",
+    "foreground": "Màu phối",
+    "general": "Chung",
+    "hide_attachments_in_convo": "Ẩn tập tin đính kèm trong thảo luận",
+    "hide_media_previews": "Ẩn xem trước media",
+    "hide_all_muted_posts": "Ẩn những tút đã ẩn",
+    "hide_muted_posts": "Ẩn tút từ các người dùng đã ẩn",
+    "max_thumbnails": "Số ảnh xem trước tối đa cho mỗi tút",
+    "hide_isp": "Ẩn thanh bên của máy chủ",
+    "hide_shoutbox": "Ẩn thanh chat máy chủ",
+    "hide_wallpaper": "Ẩn ảnh nền máy chủ",
+    "preload_images": "Tải trước hình ảnh",
+    "use_one_click_nsfw": "Xem nội dung nhạy cảm bằng cách nhấn vào",
+    "hide_user_stats": "Ẩn số liệu người dùng (vd: số người theo dõi)",
+    "hide_filtered_statuses": "Ẩn những tút đã lọc",
+    "import_followers_from_a_csv_file": "Nhập danh sách theo dõi từ tập tin CSV",
+    "import_theme": "Tải mẫu có sẵn",
+    "inputRadius": "Chỗ nhập vào",
+    "checkboxRadius": "Hộp kiểm",
+    "instance_default": "(mặc định: {value})",
+    "instance_default_simple": "(mặc định)",
+    "interface": "Giao diện",
+    "interfaceLanguage": "Ngôn ngữ",
+    "limited_availability": "Trình duyệt không hỗ trợ",
+    "links": "Liên kết",
+    "lock_account_description": "Tự phê duyệt yêu cầu theo dõi",
+    "loop_video": "Lặp lại video",
+    "loop_video_silent_only": "Chỉ lặp lại những video không có âm thanh",
+    "mutes_tab": "Ẩn",
+    "play_videos_in_modal": "Phát video trong khung hình riêng",
+    "file_export_import": {
+      "backup_restore": "Sao lưu",
+      "backup_settings": "Thiết lập sao lưu",
+      "restore_settings": "Khôi phục thiết lập từ tập tin",
+      "errors": {
+        "invalid_file": "Tập tin đã chọn không hỗ trợ bởi Pleroma. Giữ nguyên mọi thay đổi.",
+        "file_too_old": "Phiên bản không tương thích: {fileMajor}, phiên bản tập tin quá cũ và không được hỗ trợ (min. set. ver. {feMajor})",
+        "file_slightly_new": "Phiên bản tập tin khác biệt, không thể áp dụng một vài thay đổi",
+        "file_too_new": "Phiên bản không tương thích: {fileMajor}, phiên bản PleromaFE(settings ver {feMajor}) của máy chủ này quá cũ để sử dụng"
+      },
+      "backup_settings_theme": "Thiết lập sao lưu dữ liệu và giao diện"
+    },
+    "profile_fields": {
+      "label": "Metadata",
+      "add_field": "Thêm mục",
+      "name": "Nhãn",
+      "value": "Nội dung"
+    },
+    "use_contain_fit": "Không cắt ảnh đính kèm trong bản xem trước",
+    "name": "Tên",
+    "name_bio": "Tên & tiểu sử",
+    "new_email": "Email mới",
+    "new_password": "Mật khẩu mới",
+    "notification_visibility_follows": "Theo dõi",
+    "notification_visibility_mentions": "Lượt nhắc",
+    "notification_visibility_repeats": "Chia sẻ",
+    "notification_visibility_moves": "Chuyển máy chủ",
+    "notification_visibility_emoji_reactions": "Tương tác",
+    "no_blocks": "Không có chặn",
+    "no_mutes": "Không có ẩn",
+    "hide_follows_description": "Ẩn danh sách những người tôi theo dõi",
+    "hide_followers_description": "Ẩn danh sách những người theo dõi tôi",
+    "hide_followers_count_description": "Ẩn số lượng người theo dõi tôi",
+    "show_admin_badge": "Hiện huy hiệu \"Quản trị viên\" trên trang của tôi",
+    "show_moderator_badge": "Hiện huy hiệu \"Kiểm duyệt viên\" trên trang của tôi",
+    "oauth_tokens": "OAuth tokens",
+    "token": "Token",
+    "refresh_token": "Làm tươi token",
+    "valid_until": "Có giá trị tới",
+    "revoke_token": "Gỡ",
+    "panelRadius": "Panels",
+    "pause_on_unfocused": "Dừng phát khi đang lướt các tút khác",
+    "presets": "Mẫu có sẵn",
+    "profile_background": "Ảnh nền trang cá nhân",
+    "profile_banner": "Ảnh bìa trang cá nhân",
+    "profile_tab": "Trang cá nhân",
+    "radii_help": "Thiết lập góc bo tròn (bằng pixels)",
+    "replies_in_timeline": "Trả lời trong bảng tin",
+    "reply_visibility_all": "Hiện toàn bộ trả lời",
+    "reply_visibility_self": "Chỉ hiện những trả lời có nhắc tới tôi",
+    "reply_visibility_following_short": "Hiện trả lời có những người tôi theo dõi",
+    "reply_visibility_self_short": "Hiện trả lời của bản thân",
+    "setting_changed": "Thiết lập khác với mặc định",
+    "block_export_button": "Xuất danh sách chặn ra tập tin CSV",
+    "blocks_imported": "Đã nhập danh sách chặn! Sẽ mất một lúc nữa để hoàn thành.",
+    "cGreen": "Green (Chia sẻ)",
+    "change_password_error": "Có lỗi xảy ra khi đổi mật khẩu.",
+    "confirm_new_password": "Xác nhận mật khẩu mới",
+    "delete_account_description": "Xóa vĩnh viễn mọi dữ liệu và vô hiệu hóa tài khoản của bạn.",
+    "discoverable": "Hiện tài khoản trong công cụ tìm kiếm và những tính năng khác",
+    "follow_export_button": "Xuất danh sách theo dõi ra tập tin CSV",
+    "hide_attachments_in_tl": "Ẩn tập tin đính kèm trong bảng tin",
+    "right_sidebar": "Hiện thanh bên bên phải",
+    "hide_post_stats": "Ẩn tương tác của tút (vd: số lượt thích)",
+    "import_blocks_from_a_csv_file": "Nhập danh sách chặn từ tập tin CSV",
+    "invalid_theme_imported": "Tập tin đã chọn không hỗ trợ bởi Pleroma. Giao diện của bạn sẽ giữ nguyên.",
+    "notification_visibility": "Những loại thông báo sẽ hiện",
+    "notification_visibility_likes": "Thích",
+    "no_rich_text_description": "Không hiện rich text trong các tút",
+    "hide_follows_count_description": "Ẩn số lượng người tôi theo dõi",
+    "nsfw_clickthrough": "Cho phép nhấn vào xem các tút nhạy cảm",
+    "reply_visibility_following": "Chỉ hiện những trả lời có nhắc tới tôi hoặc từ những người mà tôi theo dõi"
+  },
+  "errors": {
+    "storage_unavailable": "Pleroma không thể truy cập lưu trữ trình duyệt. Thông tin đăng nhập và những thiết lập tạm thời sẽ bị mất. Hãy cho phép cookies."
+  }
+}

From 50adf0ddf25257a0199f679a28bc42641eb6a3fb Mon Sep 17 00:00:00 2001
From: Ben Is <spambenis@fastwebnet.it>
Date: Sun, 5 Sep 2021 08:32:18 +0000
Subject: [PATCH 153/169] Translated using Weblate (Italian)

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/it/
---
 src/i18n/it.json | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/i18n/it.json b/src/i18n/it.json
index ee872328..51af6a82 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -775,8 +775,8 @@
     "second": "{0} secondo",
     "now_short": "adesso",
     "now": "adesso",
-    "months_short": "{0} ms",
-    "month_short": "{0} ms",
+    "months_short": "{0} mes",
+    "month_short": "{0} mes",
     "months": "{0} mesi",
     "month": "{0} mese",
     "minutes_short": "{0} min",

From 4988268f5f317638474daf06f9fd0d9d75761f61 Mon Sep 17 00:00:00 2001
From: "@liimee" <alt3753.7@gmail.com>
Date: Mon, 6 Sep 2021 01:28:21 +0000
Subject: [PATCH 154/169] Translated using Weblate (Indonesian)

Currently translated at 67.5% (484 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/id/
---
 src/i18n/id.json | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/i18n/id.json b/src/i18n/id.json
index a2e7df0c..fded663f 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -222,7 +222,9 @@
         "reject_desc": "Instansi ini tidak akan menerima pesan dari instansi-instansi berikut:",
         "reject": "Tolak",
         "accept_desc": "Instansi ini hanya menerima pesan dari instansi-instansi berikut:",
-        "accept": "Terima"
+        "accept": "Terima",
+        "media_removal": "Penghapusan Media",
+        "media_removal_desc": "Instansi ini menghapus media dari postingan yang berasal dari instansi-instansi berikut:"
       },
       "federation": "Federasi",
       "mrf_policies": "Kebijakan MRF yang diaktifkan"

From ad64b91d66b9b8a53d4cf6687a7b404b97bf2932 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= <kantcer@gmail.com>
Date: Sun, 5 Sep 2021 10:21:47 +0000
Subject: [PATCH 155/169] Translated using Weblate (Vietnamese)

Currently translated at 100.0% (716 of 716 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/vi/
---
 src/i18n/vi.json | 439 ++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 435 insertions(+), 4 deletions(-)

diff --git a/src/i18n/vi.json b/src/i18n/vi.json
index 088d73cc..0877d822 100644
--- a/src/i18n/vi.json
+++ b/src/i18n/vi.json
@@ -51,7 +51,7 @@
     "scope_options": "Đa dạng kiểu đăng"
   },
   "finder": {
-    "error_fetching_user": "Lỗi người dùng",
+    "error_fetching_user": "Lỗi khi nạp người dùng",
     "find_user": "Tìm người dùng"
   },
   "shoutbox": {
@@ -149,7 +149,7 @@
     "no_more_notifications": "Không còn thông báo nào",
     "migrated_to": "chuyển sang",
     "reacted_with": "chạm tới {0}",
-    "error": "Lỗi xử lý thông báo: {0}"
+    "error": "Lỗi khi nạp thông báo {0}"
   },
   "polls": {
     "add_poll": "Tạo bình chọn",
@@ -197,7 +197,7 @@
       "text/bbcode": "BBCode"
     },
     "content_warning": "Tiêu đề (tùy chọn)",
-    "default": "Just landed in L.A.",
+    "default": "Đời người con gái không muốn yêu ai được không?",
     "direct_warning_to_first_only": "Người đầu tiên được nhắc đến mới có thể thấy tút này.",
     "posting": "Đang đăng tút",
     "post": "Đăng",
@@ -427,9 +427,440 @@
     "no_rich_text_description": "Không hiện rich text trong các tút",
     "hide_follows_count_description": "Ẩn số lượng người tôi theo dõi",
     "nsfw_clickthrough": "Cho phép nhấn vào xem các tút nhạy cảm",
-    "reply_visibility_following": "Chỉ hiện những trả lời có nhắc tới tôi hoặc từ những người mà tôi theo dõi"
+    "reply_visibility_following": "Chỉ hiện những trả lời có nhắc tới tôi hoặc từ những người mà tôi theo dõi",
+    "autohide_floating_post_button": "Ẩn nút viết tút khi xem bảng tin (di động)",
+    "saving_err": "Thiết lập lỗi lưu",
+    "saving_ok": "Đã lưu các thay đổi",
+    "search_user_to_block": "Tìm người bạn muốn chặn",
+    "search_user_to_mute": "Tìm người bạn muốn ẩn",
+    "security_tab": "Bảo mật",
+    "scope_copy": "Chép phạm vi khi trả lời (tin nhắn luôn được chép sẵn)",
+    "minimal_scopes_mode": "Tùy chọn thu nhỏ phạm vi tút",
+    "set_new_avatar": "Đổi ảnh đại diện",
+    "set_new_profile_background": "Đổi ảnh nền",
+    "set_new_profile_banner": "Đổi ảnh bìa",
+    "reset_profile_background": "Đặt lại ảnh nền",
+    "reset_profile_banner": "Đặt lại ảnh bìa",
+    "reset_banner_confirm": "Bạn có chắc chắn muốn đặt lại ảnh bìa?",
+    "reset_background_confirm": "Bạn có chắc chắn muốn đặt lại ảnh nền?",
+    "settings": "Cài đặt",
+    "subject_input_always_show": "Luôn hiện vùng tiêu đề",
+    "subject_line_behavior": "Chép tiêu đề khi trả lời",
+    "subject_line_email": "Giống email: \"re: subject\"",
+    "subject_line_mastodon": "Giống Mastodon: copy as is",
+    "subject_line_noop": "Đừng chép",
+    "sensitive_by_default": "Mặc định tút là nhạy cảm",
+    "stop_gifs": "Chỉ phát GIF khi chạm vào",
+    "streaming": "Tự động tải tút mới khi cuộn lên trên",
+    "user_mutes": "Người dùng",
+    "useStreamingApiWarning": "(Tính năng thử nghiệm, không đề xuất sử dụng)",
+    "text": "Văn bản",
+    "theme": "Theme",
+    "theme_help": "Dùng mã màu hex (#rrggbb) để tự chế theme.",
+    "tooltipRadius": "Tooltips/alerts",
+    "type_domains_to_mute": "Tìm máy chủ để ẩn",
+    "upload_a_photo": "Tải ảnh lên",
+    "user_settings": "Thiết lập người dùng",
+    "values": {
+      "false": "không",
+      "true": "có"
+    },
+    "virtual_scrolling": "Render bảng tin",
+    "fun": "Vui nhộn",
+    "greentext": "Mũi tên meme",
+    "notifications": "Thông báo",
+    "notification_setting_filters": "Bộ lọc",
+    "notification_setting_block_from_strangers": "Chặn thông báo từ những người bạn không theo dõi",
+    "notification_setting_privacy": "Riêng tư",
+    "notification_setting_hide_notification_contents": "Ẩn người gửi và nội dung thông báo đẩy",
+    "notification_mutes": "Sử dụng ẩn nếu muốn dừng nhận thông báo từ một người cụ thể.",
+    "notification_blocks": "Chặn một người ngừng toàn bộ thông báo cũng giống như hủy đăng ký họ.",
+    "more_settings": "Cài đặt khác",
+    "style": {
+      "switcher": {
+        "keep_shadows": "Giữ bóng đổ",
+        "keep_color": "Giữ màu",
+        "keep_opacity": "Giữ trong suốt",
+        "keep_roundness": "Giữ bo tròn góc",
+        "reset": "Đặt lại",
+        "clear_all": "Xóa hết",
+        "clear_opacity": "Xóa trong suốt",
+        "load_theme": "Tải theme",
+        "keep_as_is": "Giữ như là",
+        "use_snapshot": "Bản cũ",
+        "use_source": "Bản mới",
+        "help": {
+          "upgraded_from_v2": "PleromaFE đã được nâng cấp, theme có thể khác hơn một chút so với bản cũ.",
+          "v2_imported": "Tập tin bạn nhập là từ phiên bản PleromaFE cũ. Chúng tôi sẽ cố làm nó tương thích nhưng có thể sẽ có xung đột.",
+          "older_version_imported": "Tập tin bạn vừa nhập được tạo ra từ phiên bản PleromaFE cũ.",
+          "snapshot_present": "Đã tải theme snapshot, mọi giá trị sẽ bị chép đè. Thay vào đó, bạn có thể tải dữ liệu chắc chắn của theme.",
+          "fe_upgraded": "Theme của PleromaFE được nâng cấp sau mỗi phiên bản.",
+          "fe_downgraded": "Theme của phiên bản PleromaFE đã được hạ cấp.",
+          "migration_snapshot_ok": "Theme snapshot đã tải xong. Bạn có thể thử tải dữ liệu theme.",
+          "migration_napshot_gone": "Nếu thiếu snapshot, một số thứ sẽ khác với ban đầu.",
+          "future_version_imported": "Tập tin bạn vừa nhập được tạo ra từ phiên bản PleromaFE mới.",
+          "snapshot_missing": "Không có theme snapshot trong tập tin cho nên có thể nó sẽ khác với bản gốc đôi chút.",
+          "snapshot_source_mismatch": "Xung đột phiên bản: hầu hết Pleroma FE đã hạ cấp và cập nhật lại, nếu bạn đổi theme sử dụng phiên bản cũ hơn của FE, bạn gần như muốn sử dụng phiên bản cũ, thay vào đó sử dụng phiên bản mới."
+        },
+        "keep_fonts": "Giữ phông chữ",
+        "save_load_hint": "Giúp giữ nguyên các tùy chọn hiện tại khi chọn hoặc tải theme khác, nó cũng lưu trữ các tùy chọn đã nói khi xuất một theme. Khi tất cả các hộp kiểm bị bỏ trống, việc xuất theme sẽ lưu mọi thứ."
+      },
+      "common": {
+        "color": "Màu sắc",
+        "opacity": "Trong suốt",
+        "contrast": {
+          "hint": "Tỉ lệ tương phản là {ratio}, nó {level} {context}",
+          "level": {
+            "aa": "đạt mức AA (tối thiểu)",
+            "aaa": "đạt mức AAA (đề xuất)",
+            "bad": "không đạt yêu cầu"
+          },
+          "context": {
+            "18pt": "cỡ chữ lớn (18pt+)",
+            "text": "cho chữ"
+          }
+        }
+      },
+      "common_colors": {
+        "_tab_label": "Chung",
+        "main": "Màu sắc chung",
+        "foreground_hint": "Mở tab \"Nâng cao\" để có nhiều tùy chọn hơn",
+        "rgbo": "Icons, accents, badges"
+      },
+      "advanced_colors": {
+        "_tab_label": "Nâng cao",
+        "alert": "Nền cảnh báo",
+        "alert_error": "Lỗi",
+        "alert_warning": "Cảnh báo",
+        "alert_neutral": "Neutral",
+        "post": "Tút/Tiểu sử",
+        "badge": "Nền huy hiệu",
+        "popover": "Tooltips, menus, popovers",
+        "badge_notification": "Thông báo",
+        "panel_header": "Tiêu đề panel",
+        "top_bar": "Thanh trên cùng",
+        "borders": "Đường biên",
+        "buttons": "Nút bấm",
+        "faint_text": "Chữ mờ",
+        "underlay": "Lớp dưới",
+        "wallpaper": "Wallpaper",
+        "poll": "Biểu đồ cuộc bình chọn",
+        "icons": "Biểu tượng",
+        "highlight": "Những thành phần nổi bật",
+        "pressed": "Khi nhấn xuống",
+        "selectedPost": "Chọn tút",
+        "selectedMenu": "Chọn menu",
+        "toggled": "Toggled",
+        "tabs": "Tab",
+        "chat": {
+          "incoming": "Tin nhắn đến",
+          "outgoing": "Tin nhắn đi",
+          "border": "Đường biên"
+        },
+        "inputs": "Khung soạn thảo",
+        "disabled": "Vô hiệu hóa"
+      },
+      "radii": {
+        "_tab_label": "Góc bo tròn"
+      },
+      "shadows": {
+        "component": "Thành phần",
+        "shadow_id": "Đổ bóng #{value}",
+        "blur": "Làm mờ",
+        "spread": "Mở rộng",
+        "inset": "Thu vào",
+        "filter_hint": {
+          "always_drop_shadow": "Chú ý, màu bóng đổ này luôn sử dụng {0} nếu trình duyệt hỗ trợ.",
+          "drop_shadow_syntax": "{0} không hỗ trợ {1} phần và từ khóa {2}.",
+          "spread_zero": "Bóng đổ > 0 sẽ xuất hiện nếu chọn nó thành không",
+          "inset_classic": "Bóng đổ inset sẽ sử dụng {0}",
+          "avatar_inset": "Nếu trộn lẫn bóng đổ inset và non-inset trên ảnh đại diện có thể khiến ảnh đại diện biến thành trong suốt."
+        },
+        "components": {
+          "panel": "Panel",
+          "panelHeader": "Panel ảnh bìa",
+          "topBar": "Thanh trên cùng",
+          "avatar": "Ảnh đại diện (ở trang cá nhân)",
+          "avatarStatus": "Ảnh đại diện (ở tút)",
+          "popup": "Popups và tooltips",
+          "button": "Nút bấm",
+          "buttonHover": "Nút bấm (khi rê chuột)",
+          "buttonPressed": "Nút bấm (khi nhấn chuột)",
+          "buttonPressedHover": "Nút bấm (khi nhấn+giữ)",
+          "input": "Khung soạn thảo"
+        },
+        "_tab_label": "Đổ bóng và tô sáng",
+        "override": "Chép đè",
+        "hintV3": "Với bóng đổ, bạn có thể sử dụng ký hiệu {0} để dùng slot màu khác."
+      },
+      "fonts": {
+        "_tab_label": "Phông chữ",
+        "components": {
+          "interface": "Giao diện chung",
+          "input": "Khung soạn thảo",
+          "post": "Tút",
+          "postCode": "Chữ monospaced (rich text)"
+        },
+        "family": "Tên phông",
+        "size": "Kích cỡ (px)",
+        "weight": "Độ đậm",
+        "custom": "Tùy chỉnh",
+        "help": "Chọn phông chữ hiển thị. Để \"tùy chọn\", bạn phải nhập chính xác tên phông chữ trên hệ thống."
+      },
+      "preview": {
+        "header": "Xem trước",
+        "content": "Nội dung",
+        "error": "Lỗi mẫu ví dụ",
+        "button": "Nút bấm",
+        "text": "Một đống {0} và {1}",
+        "mono": "nội dung",
+        "input": "Đời người con gái không muốn yêu ai được không?",
+        "faint_link": "tài liệu hướng dẫn",
+        "checkbox": "Tôi đã đọc lướt qua quy tắc và chính sách bảo mật",
+        "link": "Link đẹp đó em yêu",
+        "fine_print": "Đọc {0} để tìm hiểu thêm!",
+        "header_faint": "OK nè"
+      }
+    },
+    "version": {
+      "title": "Phiên bản",
+      "frontend_version": "Frontend",
+      "backend_version": "Backend"
+    },
+    "reset_avatar": "Đặt lại ảnh đại diện",
+    "reset_avatar_confirm": "Bạn có chắc chắn muốn đặt lại ảnh đại diện?",
+    "post_status_content_type": "Loại tút đăng",
+    "useStreamingApi": "Nhận tút và thông báo theo thời gian thực",
+    "theme_help_v2_1": "Bạn cũng có thể xóa hết màu thành phần và làm theme trong suốt, chọn nút \"Xóa hết\".",
+    "theme_help_v2_2": "Các biểu tượng bên dưới các mục có độ tương phản nền/văn bản, hãy rê chuột qua để biết thông tin chi tiết. Xin lưu ý rằng, khi sử dụng các độ tương phản trong suốt có thể khiến đọc chữ không ra.",
+    "enable_web_push_notifications": "Cho phép thông báo đẩy trên web"
   },
   "errors": {
     "storage_unavailable": "Pleroma không thể truy cập lưu trữ trình duyệt. Thông tin đăng nhập và những thiết lập tạm thời sẽ bị mất. Hãy cho phép cookies."
+  },
+  "time": {
+    "day": "{0} ngày",
+    "days": "{0} ngày",
+    "day_short": "{0} ngày",
+    "days_short": "{0} ngày",
+    "hour": "{0} giờ",
+    "hours": "{0} giờ",
+    "hour_short": "{0} giờ",
+    "hours_short": "{0} giờ",
+    "in_future": "lúc {0}",
+    "in_past": "{0} trước",
+    "minute": "{0} phút",
+    "minutes": "{0} phút",
+    "minute_short": "{0} phút",
+    "minutes_short": "{0} phút",
+    "month": "{0} tháng",
+    "months": "{0} tháng",
+    "month_short": "{0} tháng",
+    "months_short": "{0} tháng",
+    "now": "vừa xong",
+    "second": "{0} giây",
+    "seconds": "{0} giây",
+    "second_short": "{0}s",
+    "seconds_short": "{0}s",
+    "week": "{0} tuần",
+    "weeks": "{0} tuần",
+    "week_short": "{0} tuần",
+    "weeks_short": "{0} tuần",
+    "year": "{0} năm",
+    "years": "{0} năm",
+    "year_short": "{0} năm",
+    "years_short": "{0} năm",
+    "now_short": "vừa xong"
+  },
+  "timeline": {
+    "collapse": "Thu gọn",
+    "error": "Lỗi khi nạp bảng tin {0}",
+    "load_older": "Xem tút cũ hơn",
+    "repeated": "chia sẻ",
+    "show_new": "Hiện mới",
+    "reload": "Tải lại",
+    "up_to_date": "Đã tải những tút mới nhất",
+    "no_more_statuses": "Không còn tút nào",
+    "no_statuses": "Trống trơn!",
+    "socket_reconnected": "Thiết lập kết nối thời gian thực",
+    "conversation": "Thảo luận",
+    "no_retweet_hint": "Không thể chia sẻ tin nhắn và những tút riêng tư",
+    "socket_broke": "Mất kết nối thời gian thực: CloseEvent {0}"
+  },
+  "status": {
+    "repeats": "Chia sẻ",
+    "delete": "Xóa tút",
+    "unpin": "Bỏ ghim trên trang cá nhân",
+    "pin": "Ghim trên trang cá nhân",
+    "pinned": "Tút được ghim",
+    "bookmark": "Lưu",
+    "unbookmark": "Bỏ lưu",
+    "reply_to": "Trả lời",
+    "replies_list": "Những trả lời:",
+    "mute_conversation": "Không quan tâm nữa",
+    "unmute_conversation": "Quan tâm",
+    "status_unavailable": "Không tìm thấy tút",
+    "copy_link": "Sao chép URL",
+    "external_source": "Nguồn bên ngoài",
+    "thread_muted": "Đã ẩn chủ đề",
+    "thread_muted_and_words": ", có từ:",
+    "hide_full_subject": "Ẩn tiêu đề",
+    "show_content": "Hiện nội dung",
+    "hide_content": "Ẩn nội dung",
+    "status_deleted": "Tút này đã bị xóa",
+    "nsfw": "Nhạy cảm",
+    "expand": "Xem nguyên văn",
+    "favorites": "Thích",
+    "delete_confirm": "Bạn có chắc chắn muốn xóa tút này?",
+    "show_full_subject": "Hiện đầy đủ tiêu đề"
+  },
+  "user_card": {
+    "approve": "Chấp nhận",
+    "block": "Chặn",
+    "blocked": "Đã chặn!",
+    "deny": "Từ chối",
+    "edit_profile": "Chỉnh sửa trang cá nhân",
+    "favorites": "Thích",
+    "follow": "Theo dõi",
+    "follow_progress": "Đang yêu cầu…",
+    "follow_again": "Gửi lại yêu cầu?",
+    "follow_unfollow": "Ngưng theo dõi",
+    "followees": "Đang theo dõi",
+    "followers": "Người theo dõi",
+    "following": "Đang theo dõi!",
+    "follows_you": "Theo dõi bạn!",
+    "hidden": "Ẩn",
+    "media": "Media",
+    "mention": "Lượt nhắc",
+    "message": "Tin nhắn",
+    "mute": "Ẩn",
+    "muted": "Đã ẩn",
+    "per_day": "tút mỗi ngày",
+    "remote_follow": "Theo dõi từ xa",
+    "report": "Báo cáo",
+    "statuses": "Tút",
+    "subscribe": "Đăng ký",
+    "unsubscribe": "Hủy đăng ký",
+    "unblock": "Bỏ chặn",
+    "unblock_progress": "Đang bỏ chặn…",
+    "block_progress": "Đang chặn…",
+    "unmute": "Bỏ ẩn",
+    "unmute_progress": "Đang bỏ ẩn…",
+    "mute_progress": "Đang ẩn…",
+    "hide_repeats": "Ẩn lượt chia sẻ",
+    "show_repeats": "Hiện lượt chia sẻ",
+    "bot": "Bot",
+    "admin_menu": {
+      "moderation": "Kiểm duyệt",
+      "grant_admin": "Chỉ định Quản trị viên",
+      "revoke_admin": "Gỡ bỏ Quản trị viên",
+      "grant_moderator": "Chỉ định Kiểm duyệt viên",
+      "activate_account": "Xác thực người dùng",
+      "deactivate_account": "Vô hiệu hóa người dùng",
+      "delete_account": "Xóa người dùng",
+      "force_nsfw": "Đánh dấu tất cả tút là nhạy cảm",
+      "strip_media": "Gỡ bỏ media trong tút",
+      "sandbox": "Đánh dấu tất cả tút là riêng tư",
+      "disable_remote_subscription": "Không cho phép theo dõi từ máy chủ khác",
+      "disable_any_subscription": "Không cho phép theo dõi bất cứ ai",
+      "quarantine": "Không cho phép tút liên hợp",
+      "delete_user": "Xóa người dùng",
+      "revoke_moderator": "Gỡ bỏ Quản trị viên",
+      "force_unlisted": "Đánh dấu tất cả tút là hạn chế",
+      "delete_user_confirmation": "Bạn chắc chắn chưa? Hành động này không thể phục hồi."
+    },
+    "highlight": {
+      "disabled": "Không nổi bật",
+      "solid": "Nền 1 màu",
+      "striped": "Nền 2 màu",
+      "side": "Sọc bên"
+    },
+    "follow_sent": "Đã gửi yêu cầu!",
+    "its_you": "Đó là bạn!"
+  },
+  "user_profile": {
+    "timeline_title": "Bảng tin người dùng",
+    "profile_does_not_exist": "Xin lỗi, tài khoản này không tồn tại.",
+    "profile_loading_error": "Xin lỗi, có lỗi xảy ra khi xem trang cá nhân này."
+  },
+  "user_reporting": {
+    "title": "Báo cáo {0}",
+    "additional_comments": "Ghi chú",
+    "forward_description": "Người này thuộc máy chủ khác. Gửi một báo cáo ẩn danh tới máy chủ đó?",
+    "forward_to": "Chuyển cho {0}",
+    "submit": "Gửi",
+    "generic_error": "Có lỗi xảy ra khi xử lý yêu cầu của bạn.",
+    "add_comment_description": "Hãy cho quản trị viên biết lý do vì sao bạn báo cáo người này:"
+  },
+  "who_to_follow": {
+    "more": "Nhiều hơn nữa",
+    "who_to_follow": "Những người dùng nổi bật"
+  },
+  "tool_tip": {
+    "media_upload": "Tải lên media",
+    "repeat": "Chia sẻ",
+    "reply": "Trả lời",
+    "favorite": "Thích",
+    "add_reaction": "Thêm tương tác",
+    "accept_follow_request": "Phê duyệt yêu cầu theo dõi",
+    "reject_follow_request": "Từ chối yêu cầu theo dõi",
+    "bookmark": "Lưu",
+    "user_settings": "Thiết lập người dùng"
+  },
+  "upload": {
+    "error": {
+      "base": "Tải lên thất bại.",
+      "message": "Tải lên thất bại: {0}",
+      "file_too_big": "Tập tin quá lớn [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
+      "default": "Hãy thử lại sau"
+    },
+    "file_size_units": {
+      "KiB": "KB",
+      "MiB": "MB",
+      "GiB": "GB",
+      "B": "byte",
+      "TiB": "TB"
+    }
+  },
+  "search": {
+    "people": "Người",
+    "hashtags": "Hashtag",
+    "person_talking": "{count} người đang trò chuyện",
+    "people_talking": "{count} người đang trò chuyện",
+    "no_results": "Không tìm thấy"
+  },
+  "password_reset": {
+    "forgot_password": "Quên mật khẩu",
+    "password_reset": "Đổi mật khẩu",
+    "placeholder": "Email hoặc tên người dùng",
+    "check_email": "Kiểm tra email của bạn.",
+    "return_home": "Quay lại Pleroma",
+    "too_many_requests": "Bạn đã vượt giới hạn cho phép, hãy thử lại sau.",
+    "password_reset_disabled": "Reset mật khẩu bị tắt. Hãy liên hệ quản trị viên máy chủ.",
+    "password_reset_required": "Bạn phải đổi mật khẩu để đăng nhập.",
+    "instruction": "Nhập email hoặc tên người dùng. Chúng tôi sẽ gửi email reset mật khẩu cho bạn.",
+    "password_reset_required_but_mailer_is_disabled": "Bạn cần phải đổi mật khẩu, nhưng tính năng bị tắt. Hãy liên hệ quản trị viên máy chủ."
+  },
+  "chats": {
+    "you": "Bạn:",
+    "message_user": "Nhắn tin {nickname}",
+    "delete": "Xóa",
+    "chats": "Chat",
+    "new": "Chat mới",
+    "empty_message_error": "Không thể gửi tin nhắn trống",
+    "more": "Nhiều hơn",
+    "delete_confirm": "Bạn có chắc chắn muốn xóa tin nhắn này?",
+    "error_loading_chat": "Có vấn đề khi tải giao diện chat.",
+    "error_sending_message": "Có vấn đề khi gửi tin nhắn.",
+    "empty_chat_list_placeholder": "Bạn không có tin nhắn. Hãy bắt đầu nhắn cho ai đó!"
+  },
+  "file_type": {
+    "audio": "Âm thanh",
+    "video": "Video",
+    "image": "Hình ảnh",
+    "file": "Tập tin"
+  },
+  "display_date": {
+    "today": "Hôm nay"
   }
 }

From 80dd6b2500c59e894490eef1d85cfa28217ad66e Mon Sep 17 00:00:00 2001
From: Ben Is <spambenis@fastwebnet.it>
Date: Thu, 9 Sep 2021 15:11:40 +0000
Subject: [PATCH 156/169] Translated using Weblate (Italian)

Currently translated at 100.0% (722 of 722 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/it/
---
 src/i18n/it.json | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/src/i18n/it.json b/src/i18n/it.json
index 51af6a82..e70c522d 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -448,7 +448,10 @@
       "backup_restore": "Archiviazione impostazioni"
     },
     "right_sidebar": "Mostra barra laterale a destra",
-    "hide_shoutbox": "Nascondi muro dei graffiti"
+    "hide_shoutbox": "Nascondi muro dei graffiti",
+    "mentions_new_style": "Menzioni abbreviate",
+    "mentions_new_place": "Segrega le menzioni",
+    "always_show_post_button": "Non nascondere il pulsante di composizione"
   },
   "timeline": {
     "error_fetching": "Errore nell'aggiornamento",
@@ -758,7 +761,10 @@
     "status_deleted": "Questo messagio è stato cancellato",
     "nsfw": "DISDICEVOLE",
     "external_source": "Vai all'origine",
-    "expand": "Espandi"
+    "expand": "Espandi",
+    "mentions": "Menzioni",
+    "you": "(Tu)",
+    "plus_more": "+{number} altri"
   },
   "time": {
     "years_short": "{0} a",

From 3c8ced53b9fc397b12fb1d7be68337f849265669 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?H=E1=BB=93=20Nh=E1=BA=A5t=20Duy?= <kantcer@gmail.com>
Date: Thu, 9 Sep 2021 16:17:57 +0000
Subject: [PATCH 157/169] Translated using Weblate (Vietnamese)

Currently translated at 100.0% (722 of 722 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/vi/
---
 src/i18n/vi.json | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/src/i18n/vi.json b/src/i18n/vi.json
index 0877d822..c77ad4ca 100644
--- a/src/i18n/vi.json
+++ b/src/i18n/vi.json
@@ -633,7 +633,10 @@
     "useStreamingApi": "Nhận tút và thông báo theo thời gian thực",
     "theme_help_v2_1": "Bạn cũng có thể xóa hết màu thành phần và làm theme trong suốt, chọn nút \"Xóa hết\".",
     "theme_help_v2_2": "Các biểu tượng bên dưới các mục có độ tương phản nền/văn bản, hãy rê chuột qua để biết thông tin chi tiết. Xin lưu ý rằng, khi sử dụng các độ tương phản trong suốt có thể khiến đọc chữ không ra.",
-    "enable_web_push_notifications": "Cho phép thông báo đẩy trên web"
+    "enable_web_push_notifications": "Cho phép thông báo đẩy trên web",
+    "mentions_new_style": "Lượt nhắc màu mè",
+    "mentions_new_place": "Đặt lượt nhắc ở dòng riêng",
+    "always_show_post_button": "Luôn hiện nút viết tút mới"
   },
   "errors": {
     "storage_unavailable": "Pleroma không thể truy cập lưu trữ trình duyệt. Thông tin đăng nhập và những thiết lập tạm thời sẽ bị mất. Hãy cho phép cookies."
@@ -712,7 +715,10 @@
     "expand": "Xem nguyên văn",
     "favorites": "Thích",
     "delete_confirm": "Bạn có chắc chắn muốn xóa tút này?",
-    "show_full_subject": "Hiện đầy đủ tiêu đề"
+    "show_full_subject": "Hiện đầy đủ tiêu đề",
+    "you": "(Bạn)",
+    "mentions": "Lượt nhắc",
+    "plus_more": "+{number} nhiều hơn"
   },
   "user_card": {
     "approve": "Chấp nhận",

From 1d0e4bada86df44983e7936b098bef81c2dfcd47 Mon Sep 17 00:00:00 2001
From: Tirifto <tirifto@posteo.cz>
Date: Mon, 13 Sep 2021 14:48:32 +0000
Subject: [PATCH 158/169] Translated using Weblate (Esperanto)

Currently translated at 100.0% (722 of 722 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/eo/
---
 src/i18n/eo.json | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/src/i18n/eo.json b/src/i18n/eo.json
index 16a904b7..b1be2656 100644
--- a/src/i18n/eo.json
+++ b/src/i18n/eo.json
@@ -553,7 +553,10 @@
     },
     "right_sidebar": "Montri flankan breton dekstre",
     "save": "Konservi ŝanĝojn",
-    "hide_shoutbox": "Kaŝi kriujon de nodo"
+    "hide_shoutbox": "Kaŝi kriujon de nodo",
+    "always_show_post_button": "Ĉiam montri ŝvebantan butonon por nova afiŝo",
+    "mentions_new_style": "Pli mojosaj menciligiloj",
+    "mentions_new_place": "Meti menciojn sur apartan linion"
   },
   "timeline": {
     "collapse": "Maletendi",
@@ -632,7 +635,8 @@
       "striped": "Stria fono",
       "solid": "Unueca fono",
       "disabled": "Senemfaze"
-    }
+    },
+    "edit_profile": "Redakti profilon"
   },
   "user_profile": {
     "timeline_title": "Historio de uzanto",
@@ -783,7 +787,10 @@
     "status_deleted": "Ĉi tiu afiŝo foriĝis",
     "nsfw": "Konsterna",
     "expand": "Etendi",
-    "external_source": "Ekstera fonto"
+    "external_source": "Ekstera fonto",
+    "mentions": "Mencioj",
+    "you": "(Vi)",
+    "plus_more": "+{number} pli"
   },
   "time": {
     "years_short": "{0}j",

From 45eda03d1c64068f10f1a7f462df085d209c2a70 Mon Sep 17 00:00:00 2001
From: "@liimee" <alt3753.7@gmail.com>
Date: Wed, 15 Sep 2021 00:20:31 +0000
Subject: [PATCH 159/169] Translated using Weblate (Indonesian)

Currently translated at 68.1% (492 of 722 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/id/
---
 src/i18n/id.json | 12 ++++++++++--
 1 file changed, 10 insertions(+), 2 deletions(-)

diff --git a/src/i18n/id.json b/src/i18n/id.json
index fded663f..200891e3 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -208,7 +208,13 @@
     "enable_web_push_notifications": "Aktifkan notifikasi push web",
     "more_settings": "Lebih banyak pengaturan",
     "reply_visibility_all": "Tampilkan semua balasan",
-    "reply_visibility_self": "Hanya tampilkan balasan yang ditujukan kepada saya"
+    "reply_visibility_self": "Hanya tampilkan balasan yang ditujukan kepada saya",
+    "hide_muted_posts": "Sembunyikan postingan-postingan dari pengguna yang dibisukan",
+    "import_blocks_from_a_csv_file": "Impor blokiran dari berkas csv",
+    "domain_mutes": "Domain",
+    "composing": "Menulis",
+    "no_blocks": "Tidak ada yang diblokir",
+    "no_mutes": "Tidak ada yang dibisukan"
   },
   "about": {
     "mrf": {
@@ -337,7 +343,9 @@
     "message": "Kirimkan pesan"
   },
   "user_profile": {
-    "timeline_title": "Linimasa pengguna"
+    "timeline_title": "Linimasa pengguna",
+    "profile_does_not_exist": "Maaf, profil ini tidak ada.",
+    "profile_loading_error": "Maaf, terjadi kesalahan ketika memuat profil ini."
   },
   "user_reporting": {
     "title": "Melaporkan {0}",

From 241b4957e1b11dc6067231bb7c94afad2e2b9d98 Mon Sep 17 00:00:00 2001
From: Ryo Ueno <r.ueno.nfive@gmail.com>
Date: Sat, 18 Sep 2021 09:04:10 +0000
Subject: [PATCH 160/169] Translated using Weblate (Japanese (ja_PEDANTIC))

Currently translated at 99.4% (718 of 722 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ja_PEDANTIC/
---
 src/i18n/ja_pedantic.json | 25 +++++++++++++++++++------
 1 file changed, 19 insertions(+), 6 deletions(-)

diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json
index dccff5bb..e80af7f3 100644
--- a/src/i18n/ja_pedantic.json
+++ b/src/i18n/ja_pedantic.json
@@ -43,7 +43,10 @@
     "role": {
       "moderator": "モデレーター",
       "admin": "管理者"
-    }
+    },
+    "flash_security": "Flashコンテンツが任意の命令を実行させることにより、コンピューターが危険にさらされることがあります。",
+    "flash_fail": "Flashコンテンツの読み込みに失敗しました。コンソールで詳細を確認できます。",
+    "flash_content": "(試験的機能)クリックしてFlashコンテンツを再生します。"
   },
   "image_cropper": {
     "crop_picture": "画像を切り抜く",
@@ -586,14 +589,18 @@
     "word_filter": "単語フィルタ",
     "file_export_import": {
       "errors": {
-        "invalid_file": "これはPleromaの設定をバックアップしたファイルではありません。"
+        "invalid_file": "これはPleromaの設定をバックアップしたファイルではありません。",
+        "file_slightly_new": "ファイルのマイナーバージョンが異なり、一部の設定が読み込まれないことがあります"
       },
       "restore_settings": "設定をファイルから復元する",
       "backup_settings_theme": "テーマを含む設定をファイルにバックアップする",
       "backup_settings": "設定をファイルにバックアップする",
       "backup_restore": "設定をバックアップ"
     },
-    "save": "変更を保存"
+    "save": "変更を保存",
+    "hide_shoutbox": "Shoutboxを表示しない",
+    "always_show_post_button": "投稿ボタンを常に表示",
+    "right_sidebar": "サイドバーを右に表示"
   },
   "time": {
     "day": "{0}日",
@@ -641,7 +648,9 @@
     "no_more_statuses": "これで終わりです",
     "no_statuses": "ステータスはありません",
     "reload": "再読み込み",
-    "error": "タイムラインの読み込みに失敗しました: {0}"
+    "error": "タイムラインの読み込みに失敗しました: {0}",
+    "socket_reconnected": "リアルタイム接続が確立されました",
+    "socket_broke": "コード{0}によりリアルタイム接続が切断されました"
   },
   "status": {
     "favorites": "お気に入り",
@@ -668,7 +677,10 @@
     "copy_link": "リンクをコピー",
     "status_unavailable": "利用できません",
     "unbookmark": "ブックマーク解除",
-    "bookmark": "ブックマーク"
+    "bookmark": "ブックマーク",
+    "mentions": "メンション",
+    "you": "(あなた)",
+    "plus_more": "ほか{number}件"
   },
   "user_card": {
     "approve": "受け入れ",
@@ -735,7 +747,8 @@
       "striped": "背景を縞模様にする",
       "side": "端に線を付ける",
       "disabled": "強調しない"
-    }
+    },
+    "edit_profile": "プロフィールを編集"
   },
   "user_profile": {
     "timeline_title": "ユーザータイムライン",

From ef684dff613aa9aa1ada081b918a20b8045c2fe1 Mon Sep 17 00:00:00 2001
From: Ryo Ueno <r.ueno.nfive@gmail.com>
Date: Sat, 18 Sep 2021 09:35:05 +0000
Subject: [PATCH 161/169] Translated using Weblate (Korean)

Currently translated at 61.9% (447 of 722 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ko/
---
 src/i18n/ko.json | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index 1033dfa6..26e5768b 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -492,7 +492,9 @@
     "votes_count": "{count} 표 | {count} 표",
     "people_voted_count": "{count} 명 투표 | {count} 명 투표",
     "option": "선택지",
-    "add_option": "선택지 추가"
+    "add_option": "선택지 추가",
+    "expired": "투표는 {0} 전에 마감되었습니다",
+    "expires_in": "투표는 {0}에 마감됩니다"
   },
   "media_modal": {
     "next": "다음",

From 1c75c74c911bf648dacc8c070f51122dd6420d91 Mon Sep 17 00:00:00 2001
From: retiolus <retiolus@protonmail.com>
Date: Tue, 28 Sep 2021 00:13:56 +0000
Subject: [PATCH 162/169] Translated using Weblate (Catalan)

Currently translated at 99.7% (720 of 722 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/ca/
---
 src/i18n/ca.json | 44 +++++++++++++++++++++++++++++++++-----------
 1 file changed, 33 insertions(+), 11 deletions(-)

diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index 2536656f..7d82c90f 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -323,7 +323,10 @@
     "play_videos_in_modal": "Reproduir vídeos en un marc emergent",
     "file_export_import": {
       "errors": {
-        "invalid_file": "El fitxer seleccionat no és vàlid com a còpia de seguretat de la configuració. No s'ha realitzat cap canvi."
+        "invalid_file": "El fitxer seleccionat no és vàlid com a còpia de seguretat de la configuració. No s'ha realitzat cap canvi.",
+        "file_too_new": "Versió important incompatible: {fileMajor}, aquest PleromaFE (configuració versió {feMajor}) és massa antiga per gestionar-lo",
+        "file_too_old": "Versió important incompatible: {fileMajor}, la versió del fitxer és massa antiga i no està implementada (s'ha establert un mínim ver. {feMajor})",
+        "file_slightly_new": "La versió menor del fitxer és diferent, alguns paràmetres podrien no carregar-se"
       },
       "backup_settings": "Còpia de seguretat de la configuració a un fitxer",
       "backup_settings_theme": "Còpia de seguretat de la configuració i tema a un fitxer",
@@ -382,7 +385,8 @@
           "postCode": "Text monoespai en publicació (text enriquit)",
           "input": "Camps d'entrada",
           "interface": "Interfície"
-        }
+        },
+        "weight": "Pes (negreta)"
       },
       "preview": {
         "input": "Acabo d'aterrar a Los Angeles.",
@@ -394,7 +398,9 @@
         "error": "Exemple d'error",
         "faint_link": "Manual d'ajuda",
         "checkbox": "He llegit els termes i condicions",
-        "link": "un bonic enllaç"
+        "link": "un bonic enllaç",
+        "fine_print": "Llegiu el nostre {0} per no aprendre res útil!",
+        "text": "Un grapat més de {0} i {1}"
       },
       "shadows": {
         "spread": "Difon",
@@ -438,7 +444,8 @@
           "snapshot_missing": "No hi havia cap instantània del tema al fitxer, per tant podria veure's diferent del previst originalment.",
           "upgraded_from_v2": "PleromaFE s'ha actualitzat, el tema pot veure's un poc diferent de com recordes.",
           "fe_downgraded": "Versió de PleromaFE revertida.",
-          "older_version_imported": "El fitxer que has importat va ser creat en una versió del front-end més antiga."
+          "older_version_imported": "El fitxer que has importat va ser creat en una versió del front-end més antiga.",
+          "snapshot_present": "S'ha carregat la instantània del tema, de manera que tots els valors estan sobreescrits. En canvi, podeu carregar les dades reals del tema."
         },
         "keep_as_is": "Mantindre com està",
         "save_load_hint": "Les opcions \"Mantindre\" conserven les opcions configurades actualment al seleccionar o carregar temes, també emmagatzema aquestes opcions quan s'exporta un tema. Quan es desactiven totes les caselles de verificació, el tema exportat ho guardarà tot.",
@@ -532,7 +539,13 @@
     "notification_setting_hide_notification_contents": "Amagar el remitent i els continguts de les notificacions push",
     "notifications": "Notificacions",
     "notification_mutes": "Per a deixar de rebre notificacions d'un usuari en concret, silencia'l-ho.",
-    "theme_help_v2_2": "Les icones per baix d'algunes entrades són indicadors del contrast del fons/text, desplaça el ratolí per a més informació. Tingues en compte que quan s'utilitzen indicadors de contrast de transparència es mostra el pitjor cas possible."
+    "theme_help_v2_2": "Les icones per baix d'algunes entrades són indicadors del contrast del fons/text, desplaça el ratolí per a més informació. Tingues en compte que quan s'utilitzen indicadors de contrast de transparència es mostra el pitjor cas possible.",
+    "hide_shoutbox": "Oculta la casella de gàbia de grills",
+    "always_show_post_button": "Mostra sempre el botó flotant de publicació nova",
+    "pad_emoji": "Acompanya els emojis amb espais en afegir des del selector",
+    "mentions_new_style": "Enllaços d'esment més elegants",
+    "mentions_new_place": "Posa les mencions en una línia separada",
+    "post_status_content_type": "Format de publicació"
   },
   "time": {
     "day": "{0} dia",
@@ -617,7 +630,9 @@
       "disable_remote_subscription": "Deshabilita seguir algú des d'una instància remota",
       "delete_user": "Esborra la usuària",
       "grant_admin": "Concedir permisos d'Administració",
-      "grant_moderator": "Concedir permisos de Moderació"
+      "grant_moderator": "Concedir permisos de Moderació",
+      "force_unlisted": "Força que les publicacions no estiguin llistades",
+      "sandbox": "Força que els missatges siguin només seguidors"
     },
     "edit_profile": "Edita el perfil",
     "follow_again": "Envia de nou la petició?",
@@ -643,7 +658,8 @@
       "solid": "Fons sòlid",
       "striped": "Fons a ratlles",
       "side": "Ratlla lateral"
-    }
+    },
+    "media": "Media"
   },
   "user_profile": {
     "timeline_title": "Flux personal",
@@ -659,12 +675,14 @@
   },
   "remote_user_resolver": {
     "error": "No trobat.",
-    "searching_for": "Cercant per"
+    "searching_for": "Cercant per",
+    "remote_user_resolver": "Resolució d'usuari remot"
   },
   "interactions": {
     "load_older": "Carrega antigues interaccions",
     "favs_repeats": "Repeticions i favorits",
-    "follows": "Nous seguidors"
+    "follows": "Nous seguidors",
+    "moves": "Migració d'usuaris"
   },
   "emoji": {
     "stickers": "Adhesius",
@@ -776,7 +794,10 @@
     "pinned": "Destacat",
     "reply_to": "Contesta a",
     "pin": "Destaca al perfil",
-    "unmute_conversation": "Deixa de silenciar la conversa"
+    "unmute_conversation": "Deixa de silenciar la conversa",
+    "mentions": "Mencions",
+    "you": "(Tu)",
+    "plus_more": "+{number} més"
   },
   "user_reporting": {
     "additional_comments": "Comentaris addicionals",
@@ -802,7 +823,8 @@
     "no_results": "No hi ha resultats",
     "people": "Persones",
     "hashtags": "Etiquetes",
-    "people_talking": "{count} persones parlant"
+    "people_talking": "{count} persones parlant",
+    "person_talking": "{count} persones parlant"
   },
   "upload": {
     "file_size_units": {

From d2730d5badb843fd95eef8d070ae71289d6d02e1 Mon Sep 17 00:00:00 2001
From: tarteka <info@tarteka.net>
Date: Wed, 20 Oct 2021 21:29:23 +0000
Subject: [PATCH 163/169] Translated using Weblate (Spanish)

Currently translated at 100.0% (722 of 722 strings)

Translation: Pleroma/Pleroma-FE
Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-fe/es/
---
 src/i18n/es.json | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/src/i18n/es.json b/src/i18n/es.json
index 0d343e8c..62f8a6a1 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -599,7 +599,10 @@
       "backup_restore": "Copia de seguridad de la configuración"
     },
     "hide_shoutbox": "Ocultar cuadro de diálogo de la instancia",
-    "right_sidebar": "Mostrar la barra lateral a la derecha"
+    "right_sidebar": "Mostrar la barra lateral a la derecha",
+    "always_show_post_button": "Muestra siempre el botón flotante de Nueva Plubicación",
+    "mentions_new_style": "Enlaces de menciones más elegantes",
+    "mentions_new_place": "Situa las menciones en una línea separada"
   },
   "time": {
     "day": "{0} día",
@@ -676,7 +679,10 @@
     "status_deleted": "Esta publicación ha sido eliminada",
     "nsfw": "NSFW (No apropiado para el trabajo)",
     "expand": "Expandir",
-    "external_source": "Fuente externa"
+    "external_source": "Fuente externa",
+    "mentions": "Menciones",
+    "you": "(Tú)",
+    "plus_more": "+{number} más"
   },
   "user_card": {
     "approve": "Aprobar",

From d36b45ad436fbbb027bfe6af1093107c0bfed61f Mon Sep 17 00:00:00 2001
From: rinpatch <rin@patch.cx>
Date: Tue, 16 Nov 2021 19:49:01 +0300
Subject: [PATCH 164/169] entity_normalizer: Escape name when parsing user

In January 2020 Pleroma backend stopped escaping HTML in display names
and passed that responsibility on frontends, compliant with Mastodon's
version of Mastodon API [1]. Pleroma-FE was subsequently modified to
escape the display name [2], however only in the "name_html" field. This
was fine however, since that's what the code rendering display names used.

However, 2 months ago an MR [3] refactoring the way the frontend does emoji
and mention rendering was merged. One of the things it did was moving away
from doing emoji rendering in the entity normalizer and use the unescaped
'user.name' in the rendering code, resulting in HTML injection being
possible again.

This patch escapes 'user.name' as well, as far as I can tell there is no
actual use for an unescaped display name in frontend code, especially
when it comes from MastoAPI, where it is not supposed to be HTML.

[1]: https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1052
[2]: https://git.pleroma.social/pleroma/pleroma/-/merge_requests/2167
[3]: https://git.pleroma.social/pleroma/pleroma-fe/-/merge_requests/1392
---
 src/components/settings_modal/tabs/profile_tab.js           | 2 +-
 src/services/entity_normalizer/entity_normalizer.service.js | 5 +++--
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 9709424c..64079fcd 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -24,7 +24,7 @@ library.add(
 const ProfileTab = {
   data () {
     return {
-      newName: this.$store.state.users.currentUser.name,
+      newName: this.$store.state.users.currentUser.name_unescaped,
       newBio: unescape(this.$store.state.users.currentUser.description),
       newLocked: this.$store.state.users.currentUser.locked,
       newNoRichText: this.$store.state.users.currentUser.no_rich_text,
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 04bb45a4..7025d803 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -55,8 +55,9 @@ export const parseUser = (data) => {
     }
 
     output.emoji = data.emojis
-    output.name = data.display_name
-    output.name_html = escape(data.display_name)
+    output.name = escape(data.display_name)
+    output.name_html = output.name
+    output.name_unescaped = data.display_name
 
     output.description = data.note
     // TODO cleanup this shit, output.description is overriden with source data

From b4cfda4a20bdf2e5534de7f249d6a477043f75bf Mon Sep 17 00:00:00 2001
From: Ilja <pleroma@spectraltheorem.be>
Date: Fri, 3 Dec 2021 18:14:32 +0000
Subject: [PATCH 165/169] Simple policy reasons for instance specific policies

---
 .../mrf_transparency_panel.js                 |  51 +++++-
 .../mrf_transparency_panel.scss               |  21 +++
 .../mrf_transparency_panel.vue                | 155 +++++++++++++-----
 src/i18n/en.json                              |   3 +
 src/i18n/nl.json                              |   3 +
 5 files changed, 182 insertions(+), 51 deletions(-)
 create mode 100644 src/components/mrf_transparency_panel/mrf_transparency_panel.scss

diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
index a0b600d2..3fde8106 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.js
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
@@ -1,17 +1,56 @@
 import { mapState } from 'vuex'
 import { get } from 'lodash'
 
+/**
+ * This is for backwards compatibility. We originally didn't recieve
+ * extra info like a reason why an instance was rejected/quarantined/etc.
+ * Because we didn't want to break backwards compatibility it was decided
+ * to add an extra "info" key.
+ */
+const toInstanceReasonObject = (instances, info, key) => {
+  return instances.map(instance => {
+    if (info[key] && info[key][instance] && info[key][instance]['reason']) {
+      return { instance: instance, reason: info[key][instance]['reason'] }
+    }
+    return { instance: instance, reason: '' }
+  })
+}
+
 const MRFTransparencyPanel = {
   computed: {
     ...mapState({
       federationPolicy: state => get(state, 'instance.federationPolicy'),
       mrfPolicies: state => get(state, 'instance.federationPolicy.mrf_policies', []),
-      quarantineInstances: state => get(state, 'instance.federationPolicy.quarantined_instances', []),
-      acceptInstances: state => get(state, 'instance.federationPolicy.mrf_simple.accept', []),
-      rejectInstances: state => get(state, 'instance.federationPolicy.mrf_simple.reject', []),
-      ftlRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
-      mediaNsfwInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
-      mediaRemovalInstances: state => get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
+      quarantineInstances: state => toInstanceReasonObject(
+        get(state, 'instance.federationPolicy.quarantined_instances', []),
+        get(state, 'instance.federationPolicy.quarantined_instances_info', []),
+        'quarantined_instances'
+      ),
+      acceptInstances: state => toInstanceReasonObject(
+        get(state, 'instance.federationPolicy.mrf_simple.accept', []),
+        get(state, 'instance.federationPolicy.mrf_simple_info', []),
+        'accept'
+      ),
+      rejectInstances: state => toInstanceReasonObject(
+        get(state, 'instance.federationPolicy.mrf_simple.reject', []),
+        get(state, 'instance.federationPolicy.mrf_simple_info', []),
+        'reject'
+      ),
+      ftlRemovalInstances: state => toInstanceReasonObject(
+        get(state, 'instance.federationPolicy.mrf_simple.federated_timeline_removal', []),
+        get(state, 'instance.federationPolicy.mrf_simple_info', []),
+        'federated_timeline_removal'
+      ),
+      mediaNsfwInstances: state => toInstanceReasonObject(
+        get(state, 'instance.federationPolicy.mrf_simple.media_nsfw', []),
+        get(state, 'instance.federationPolicy.mrf_simple_info', []),
+        'media_nsfw'
+      ),
+      mediaRemovalInstances: state => toInstanceReasonObject(
+        get(state, 'instance.federationPolicy.mrf_simple.media_removal', []),
+        get(state, 'instance.federationPolicy.mrf_simple_info', []),
+        'media_removal'
+      ),
       keywordsFtlRemoval: state => get(state, 'instance.federationPolicy.mrf_keyword.federated_timeline_removal', []),
       keywordsReject: state => get(state, 'instance.federationPolicy.mrf_keyword.reject', []),
       keywordsReplace: state => get(state, 'instance.federationPolicy.mrf_keyword.replace', [])
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.scss b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss
new file mode 100644
index 00000000..80ea01d4
--- /dev/null
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.scss
@@ -0,0 +1,21 @@
+.mrf-section {
+  margin: 1em;
+
+  table {
+    width:100%;
+    text-align: left;
+    padding-left:10px;
+    padding-bottom:20px;
+
+    th, td {
+      width: 180px;
+      max-width: 360px;
+      overflow:  hidden;
+      vertical-align: text-top;
+    }
+
+    th+th, td+td {
+      width: auto;
+    }
+  }
+}
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
index acdf822e..1787fa07 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.vue
@@ -31,13 +31,24 @@
 
             <p>{{ $t("about.mrf.simple.accept_desc") }}</p>
 
-            <ul>
-              <li
-                v-for="instance in acceptInstances"
-                :key="instance"
-                v-text="instance"
-              />
-            </ul>
+            <table>
+              <tr>
+                <th>{{ $t("about.mrf.simple.instance") }}</th>
+                <th>{{ $t("about.mrf.simple.reason") }}</th>
+              </tr>
+              <tr
+                v-for="entry in acceptInstances"
+                :key="entry.instance + '_accept'"
+              >
+                <td>{{ entry.instance }}</td>
+                <td v-if="entry.reason === ''">
+                  {{ $t("about.mrf.simple.not_applicable") }}
+                </td>
+                <td v-else>
+                  {{ entry.reason }}
+                </td>
+              </tr>
+            </table>
           </div>
 
           <div v-if="rejectInstances.length">
@@ -45,13 +56,24 @@
 
             <p>{{ $t("about.mrf.simple.reject_desc") }}</p>
 
-            <ul>
-              <li
-                v-for="instance in rejectInstances"
-                :key="instance"
-                v-text="instance"
-              />
-            </ul>
+            <table>
+              <tr>
+                <th>{{ $t("about.mrf.simple.instance") }}</th>
+                <th>{{ $t("about.mrf.simple.reason") }}</th>
+              </tr>
+              <tr
+                v-for="entry in rejectInstances"
+                :key="entry.instance + '_reject'"
+              >
+                <td>{{ entry.instance }}</td>
+                <td v-if="entry.reason === ''">
+                  {{ $t("about.mrf.simple.not_applicable") }}
+                </td>
+                <td v-else>
+                  {{ entry.reason }}
+                </td>
+              </tr>
+            </table>
           </div>
 
           <div v-if="quarantineInstances.length">
@@ -59,13 +81,24 @@
 
             <p>{{ $t("about.mrf.simple.quarantine_desc") }}</p>
 
-            <ul>
-              <li
-                v-for="instance in quarantineInstances"
-                :key="instance"
-                v-text="instance"
-              />
-            </ul>
+            <table>
+              <tr>
+                <th>{{ $t("about.mrf.simple.instance") }}</th>
+                <th>{{ $t("about.mrf.simple.reason") }}</th>
+              </tr>
+              <tr
+                v-for="entry in quarantineInstances"
+                :key="entry.instance + '_quarantine'"
+              >
+                <td>{{ entry.instance }}</td>
+                <td v-if="entry.reason === ''">
+                  {{ $t("about.mrf.simple.not_applicable") }}
+                </td>
+                <td v-else>
+                  {{ entry.reason }}
+                </td>
+              </tr>
+            </table>
           </div>
 
           <div v-if="ftlRemovalInstances.length">
@@ -73,13 +106,24 @@
 
             <p>{{ $t("about.mrf.simple.ftl_removal_desc") }}</p>
 
-            <ul>
-              <li
-                v-for="instance in ftlRemovalInstances"
-                :key="instance"
-                v-text="instance"
-              />
-            </ul>
+            <table>
+              <tr>
+                <th>{{ $t("about.mrf.simple.instance") }}</th>
+                <th>{{ $t("about.mrf.simple.reason") }}</th>
+              </tr>
+              <tr
+                v-for="entry in ftlRemovalInstances"
+                :key="entry.instance + '_ftl_removal'"
+              >
+                <td>{{ entry.instance }}</td>
+                <td v-if="entry.reason === ''">
+                  {{ $t("about.mrf.simple.not_applicable") }}
+                </td>
+                <td v-else>
+                  {{ entry.reason }}
+                </td>
+              </tr>
+            </table>
           </div>
 
           <div v-if="mediaNsfwInstances.length">
@@ -87,13 +131,24 @@
 
             <p>{{ $t("about.mrf.simple.media_nsfw_desc") }}</p>
 
-            <ul>
-              <li
-                v-for="instance in mediaNsfwInstances"
-                :key="instance"
-                v-text="instance"
-              />
-            </ul>
+            <table>
+              <tr>
+                <th>{{ $t("about.mrf.simple.instance") }}</th>
+                <th>{{ $t("about.mrf.simple.reason") }}</th>
+              </tr>
+              <tr
+                v-for="entry in mediaNsfwInstances"
+                :key="entry.instance + '_media_nsfw'"
+              >
+                <td>{{ entry.instance }}</td>
+                <td v-if="entry.reason === ''">
+                  {{ $t("about.mrf.simple.not_applicable") }}
+                </td>
+                <td v-else>
+                  {{ entry.reason }}
+                </td>
+              </tr>
+            </table>
           </div>
 
           <div v-if="mediaRemovalInstances.length">
@@ -101,13 +156,24 @@
 
             <p>{{ $t("about.mrf.simple.media_removal_desc") }}</p>
 
-            <ul>
-              <li
-                v-for="instance in mediaRemovalInstances"
-                :key="instance"
-                v-text="instance"
-              />
-            </ul>
+            <table>
+              <tr>
+                <th>{{ $t("about.mrf.simple.instance") }}</th>
+                <th>{{ $t("about.mrf.simple.reason") }}</th>
+              </tr>
+              <tr
+                v-for="entry in mediaRemovalInstances"
+                :key="entry.instance + '_media_removal'"
+              >
+                <td>{{ entry.instance }}</td>
+                <td v-if="entry.reason === ''">
+                  {{ $t("about.mrf.simple.not_applicable") }}
+                </td>
+                <td v-else>
+                  {{ entry.reason }}
+                </td>
+              </tr>
+            </table>
           </div>
 
           <h2 v-if="hasKeywordPolicies">
@@ -161,7 +227,6 @@
 <script src="./mrf_transparency_panel.js"></script>
 
 <style lang="scss">
-.mrf-section {
-  margin: 1em;
-}
+@import '../../_variables.scss';
+@import './mrf_transparency_panel.scss';
 </style>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index b31e4880..23a059c5 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -13,6 +13,9 @@
       "mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance.  The following policies are enabled:",
       "simple": {
         "simple_policies": "Instance-specific policies",
+        "instance": "Instance",
+        "reason": "Reason",
+        "not_applicable": "N/A",
         "accept": "Accept",
         "accept_desc": "This instance only accepts messages from the following instances:",
         "reject": "Reject",
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index 85794fed..c8a35bcc 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -670,6 +670,9 @@
       "mrf_policies": "Ingeschakelde MRF-regels",
       "simple": {
         "simple_policies": "Instantiespecifieke regels",
+        "instance": "Instantie",
+        "reason": "Reden",
+        "not_applicable": "n.v.t.",
         "accept": "Accepteren",
         "accept_desc": "Deze instantie accepteert alleen berichten van de volgende instanties:",
         "reject": "Afwijzen",

From 4587f37dd72cfbb75e0fb008237ee3fe430caed1 Mon Sep 17 00:00:00 2001
From: Ilja <ilja@ilja.space>
Date: Sun, 12 Dec 2021 18:09:21 +0100
Subject: [PATCH 166/169] Allow canceling a follow request

When a follow request is sent, but not (yet) accepted, the behaviour is now to cancel the request instead of re sending.

The reason is double
* You couldn't cancel a follow request if you change your mind and the request wasn't answered yet
* Instances don't always correctly process a new follow request when the following is already happening. If something went wrong (e;g. the target server thinks you're following, but your instance thinks you're not yet), it's better to first sent an unfollow. This is the behaviour that Mastodon and most probably most other clients have. Therefore this flow is more tested and expected by other instances.
---
 src/components/follow_button/follow_button.js | 4 ++--
 src/i18n/ca.json                              | 1 -
 src/i18n/cs.json                              | 1 -
 src/i18n/de.json                              | 1 -
 src/i18n/en.json                              | 2 +-
 src/i18n/eo.json                              | 1 -
 src/i18n/es.json                              | 1 -
 src/i18n/eu.json                              | 1 -
 src/i18n/fi.json                              | 1 -
 src/i18n/fr.json                              | 1 -
 src/i18n/he.json                              | 1 -
 src/i18n/id.json                              | 1 -
 src/i18n/it.json                              | 1 -
 src/i18n/ja_easy.json                         | 1 -
 src/i18n/ja_pedantic.json                     | 1 -
 src/i18n/ko.json                              | 1 -
 src/i18n/nb.json                              | 1 -
 src/i18n/nl.json                              | 2 +-
 src/i18n/oc.json                              | 1 -
 src/i18n/pl.json                              | 1 -
 src/i18n/pt.json                              | 1 -
 src/i18n/ru.json                              | 1 -
 src/i18n/te.json                              | 1 -
 src/i18n/uk.json                              | 1 -
 src/i18n/zh.json                              | 1 -
 src/i18n/zh_Hant.json                         | 1 -
 26 files changed, 4 insertions(+), 27 deletions(-)

diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
index 95e7cb6b..df42692b 100644
--- a/src/components/follow_button/follow_button.js
+++ b/src/components/follow_button/follow_button.js
@@ -14,7 +14,7 @@ export default {
       if (this.inProgress || this.relationship.following) {
         return this.$t('user_card.follow_unfollow')
       } else if (this.relationship.requested) {
-        return this.$t('user_card.follow_again')
+        return this.$t('user_card.follow_cancel')
       } else {
         return this.$t('user_card.follow')
       }
@@ -33,7 +33,7 @@ export default {
   },
   methods: {
     onClick () {
-      this.relationship.following ? this.unfollow() : this.follow()
+      this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
     },
     follow () {
       this.inProgress = true
diff --git a/src/i18n/ca.json b/src/i18n/ca.json
index 2536656f..1f5392f3 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -620,7 +620,6 @@
       "grant_moderator": "Concedir permisos de Moderació"
     },
     "edit_profile": "Edita el perfil",
-    "follow_again": "Envia de nou la petició?",
     "hidden": "Amagat",
     "follow_sent": "Petició enviada!",
     "unmute_progress": "Deixant de silenciar…",
diff --git a/src/i18n/cs.json b/src/i18n/cs.json
index d9aed34a..ca87214e 100644
--- a/src/i18n/cs.json
+++ b/src/i18n/cs.json
@@ -407,7 +407,6 @@
     "follow": "Sledovat",
     "follow_sent": "Požadavek odeslán!",
     "follow_progress": "Odeslílám požadavek…",
-    "follow_again": "Odeslat požadavek znovu?",
     "follow_unfollow": "Přestat sledovat",
     "followees": "Sledovaní",
     "followers": "Sledující",
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 7439f494..b6599594 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -569,7 +569,6 @@
     "follow": "Folgen",
     "follow_sent": "Anfrage gesendet!",
     "follow_progress": "Anfragen…",
-    "follow_again": "Anfrage erneut senden?",
     "follow_unfollow": "Folgen beenden",
     "followees": "Folgt",
     "followers": "Folgende",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 0dac0fa3..eef8d701 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -731,9 +731,9 @@
     "edit_profile": "Edit profile",
     "favorites": "Favorites",
     "follow": "Follow",
+    "follow_cancel": "Cancel request",
     "follow_sent": "Request sent!",
     "follow_progress": "Requesting…",
-    "follow_again": "Send request again?",
     "follow_unfollow": "Unfollow",
     "followees": "Following",
     "followers": "Followers",
diff --git a/src/i18n/eo.json b/src/i18n/eo.json
index 16a904b7..169248bc 100644
--- a/src/i18n/eo.json
+++ b/src/i18n/eo.json
@@ -580,7 +580,6 @@
     "follow": "Aboni",
     "follow_sent": "Peto sendiĝis!",
     "follow_progress": "Petante…",
-    "follow_again": "Ĉu sendi peton ree?",
     "follow_unfollow": "Malaboni",
     "followees": "Abonatoj",
     "followers": "Abonantoj",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 0d343e8c..5f4db163 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -687,7 +687,6 @@
     "follow": "Seguir",
     "follow_sent": "¡Solicitud enviada!",
     "follow_progress": "Solicitando…",
-    "follow_again": "¿Enviar solicitud de nuevo?",
     "follow_unfollow": "Dejar de seguir",
     "followees": "Siguiendo",
     "followers": "Seguidores",
diff --git a/src/i18n/eu.json b/src/i18n/eu.json
index 29eb7c50..539ee1bd 100644
--- a/src/i18n/eu.json
+++ b/src/i18n/eu.json
@@ -569,7 +569,6 @@
     "follow": "Jarraitu",
     "follow_sent": "Eskaera bidalita!",
     "follow_progress": "Eskatzen…",
-    "follow_again": "Eskaera berriro bidali?",
     "follow_unfollow": "Jarraitzeari utzi",
     "followees": "Jarraitzen",
     "followers": "Jarraitzaileak",
diff --git a/src/i18n/fi.json b/src/i18n/fi.json
index ebcad804..7b5244cb 100644
--- a/src/i18n/fi.json
+++ b/src/i18n/fi.json
@@ -590,7 +590,6 @@
     "follow": "Seuraa",
     "follow_sent": "Pyyntö lähetetty!",
     "follow_progress": "Pyydetään…",
-    "follow_again": "Lähetä pyyntö uudestaan?",
     "follow_unfollow": "Älä seuraa",
     "followees": "Seuraa",
     "followers": "Seuraajat",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 41f54393..6d3c75d1 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -624,7 +624,6 @@
     "follow": "Suivre",
     "follow_sent": "Demande envoyée !",
     "follow_progress": "Demande en cours…",
-    "follow_again": "Renvoyer la demande ?",
     "follow_unfollow": "Désabonner",
     "followees": "Suivis",
     "followers": "Vous suivent",
diff --git a/src/i18n/he.json b/src/i18n/he.json
index 4b920536..b0c59a30 100644
--- a/src/i18n/he.json
+++ b/src/i18n/he.json
@@ -312,7 +312,6 @@
     "follow": "עקוב",
     "follow_sent": "בקשה נשלחה!",
     "follow_progress": "מבקש…",
-    "follow_again": "שלח בקשה שוב?",
     "follow_unfollow": "בטל עקיבה",
     "followees": "נעקבים",
     "followers": "עוקבים",
diff --git a/src/i18n/id.json b/src/i18n/id.json
index a2e7df0c..e6b5eb94 100644
--- a/src/i18n/id.json
+++ b/src/i18n/id.json
@@ -322,7 +322,6 @@
       "delete_user": "Hapus pengguna",
       "delete_user_confirmation": "Apakah Anda benar-benar yakin? Tindakan ini tidak dapat dibatalkan."
     },
-    "follow_again": "Kirim permintaan lagi?",
     "follow_unfollow": "Berhenti mengikuti",
     "followees": "Mengikuti",
     "followers": "Pengikut",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index ee872328..6fc1d05a 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -516,7 +516,6 @@
     "its_you": "Sei tu!",
     "hidden": "Nascosto",
     "follow_unfollow": "Disconosci",
-    "follow_again": "Reinvio richiesta?",
     "follow_progress": "Richiedo…",
     "follow_sent": "Richiesta inviata!",
     "favorites": "Preferiti",
diff --git a/src/i18n/ja_easy.json b/src/i18n/ja_easy.json
index 991f3762..f64943d9 100644
--- a/src/i18n/ja_easy.json
+++ b/src/i18n/ja_easy.json
@@ -567,7 +567,6 @@
     "follow": "フォロー",
     "follow_sent": "リクエストを、おくりました!",
     "follow_progress": "リクエストしています…",
-    "follow_again": "ふたたびリクエストをおくりますか?",
     "follow_unfollow": "フォローをやめる",
     "followees": "フォロー",
     "followers": "フォロワー",
diff --git a/src/i18n/ja_pedantic.json b/src/i18n/ja_pedantic.json
index dccff5bb..7241c9ac 100644
--- a/src/i18n/ja_pedantic.json
+++ b/src/i18n/ja_pedantic.json
@@ -679,7 +679,6 @@
     "follow": "フォロー",
     "follow_sent": "リクエストを送りました!",
     "follow_progress": "リクエストしています…",
-    "follow_again": "再びリクエストを送りますか?",
     "follow_unfollow": "フォローをやめる",
     "followees": "フォロー",
     "followers": "フォロワー",
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index 1033dfa6..6386438a 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -428,7 +428,6 @@
     "follow": "팔로우",
     "follow_sent": "요청 보내짐!",
     "follow_progress": "요청 중…",
-    "follow_again": "요청을 다시 보낼까요?",
     "follow_unfollow": "팔로우 중지",
     "followees": "팔로우 중",
     "followers": "팔로워",
diff --git a/src/i18n/nb.json b/src/i18n/nb.json
index e0dffe83..5e3e8ef3 100644
--- a/src/i18n/nb.json
+++ b/src/i18n/nb.json
@@ -516,7 +516,6 @@
     "follow": "Følg",
     "follow_sent": "Forespørsel sendt!",
     "follow_progress": "Forespør…",
-    "follow_again": "Gjenta forespørsel?",
     "follow_unfollow": "Avfølg",
     "followees": "Følger",
     "followers": "Følgere",
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index c8a35bcc..b113ffe4 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -565,9 +565,9 @@
     "deny": "Weigeren",
     "favorites": "Favorieten",
     "follow": "Volgen",
+    "follow_cancel": "Aanvraag annuleren",
     "follow_sent": "Aanvraag verzonden!",
     "follow_progress": "Aanvragen…",
-    "follow_again": "Aanvraag opnieuw zenden?",
     "follow_unfollow": "Stop volgen",
     "followees": "Aan het volgen",
     "followers": "Volgers",
diff --git a/src/i18n/oc.json b/src/i18n/oc.json
index 24001d4a..40f48149 100644
--- a/src/i18n/oc.json
+++ b/src/i18n/oc.json
@@ -465,7 +465,6 @@
     "follow": "Seguir",
     "follow_sent": "Demanda enviada !",
     "follow_progress": "Demanda…",
-    "follow_again": "Tornar enviar la demanda ?",
     "follow_unfollow": "Quitar de seguir",
     "followees": "Abonaments",
     "followers": "Seguidors",
diff --git a/src/i18n/pl.json b/src/i18n/pl.json
index 11409169..304a0349 100644
--- a/src/i18n/pl.json
+++ b/src/i18n/pl.json
@@ -721,7 +721,6 @@
     "follow": "Obserwuj",
     "follow_sent": "Wysłano prośbę!",
     "follow_progress": "Wysyłam prośbę…",
-    "follow_again": "Wysłać prośbę ponownie?",
     "follow_unfollow": "Przestań obserwować",
     "followees": "Obserwowani",
     "followers": "Obserwujący",
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index 841516c0..e32a95e4 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -575,7 +575,6 @@
     "follow": "Seguir",
     "follow_sent": "Pedido enviado!",
     "follow_progress": "Enviando…",
-    "follow_again": "Enviar solicitação novamente?",
     "follow_unfollow": "Deixar de seguir",
     "followees": "Seguindo",
     "followers": "Seguidores",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 7c20ad8b..ba0cec28 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -550,7 +550,6 @@
     "follow": "Читать",
     "follow_sent": "Запрос отправлен!",
     "follow_progress": "Запрашиваем…",
-    "follow_again": "Запросить еще раз?",
     "follow_unfollow": "Перестать читать",
     "followees": "Читаемые",
     "followers": "Читатели",
diff --git a/src/i18n/te.json b/src/i18n/te.json
index bb68d29e..1216de59 100644
--- a/src/i18n/te.json
+++ b/src/i18n/te.json
@@ -310,7 +310,6 @@
   "user_card.follow": "Follow",
   "user_card.follow_sent": "Request sent!",
   "user_card.follow_progress": "Requesting…",
-  "user_card.follow_again": "Send request again?",
   "user_card.follow_unfollow": "Unfollow",
   "user_card.followees": "Following",
   "user_card.followers": "Followers",
diff --git a/src/i18n/uk.json b/src/i18n/uk.json
index 10a7375f..d9833087 100644
--- a/src/i18n/uk.json
+++ b/src/i18n/uk.json
@@ -748,7 +748,6 @@
     "message": "Повідомлення",
     "follow": "Підписатись",
     "follow_unfollow": "Відписатись",
-    "follow_again": "Відправити запит знову?",
     "follow_sent": "Запит відправлено!",
     "blocked": "Заблоковано!",
     "admin_menu": {
diff --git a/src/i18n/zh.json b/src/i18n/zh.json
index 9f91ef1a..abba4be9 100644
--- a/src/i18n/zh.json
+++ b/src/i18n/zh.json
@@ -677,7 +677,6 @@
     "follow": "关注",
     "follow_sent": "请求已发送!",
     "follow_progress": "请求中…",
-    "follow_again": "再次发送请求?",
     "follow_unfollow": "取消关注",
     "followees": "正在关注",
     "followers": "关注者",
diff --git a/src/i18n/zh_Hant.json b/src/i18n/zh_Hant.json
index 7af2cf39..2c4dc3fb 100644
--- a/src/i18n/zh_Hant.json
+++ b/src/i18n/zh_Hant.json
@@ -771,7 +771,6 @@
     "follow": "關注",
     "follow_sent": "請求已發送!",
     "follow_progress": "請求中…",
-    "follow_again": "再次發送請求?",
     "follow_unfollow": "取消關注",
     "followees": "正在關注",
     "followers": "關注者",

From 056f5f547ac6a5dd8be577526192c9be9ca08ccc Mon Sep 17 00:00:00 2001
From: Shpuld Shpludson <shp@cock.li>
Date: Sun, 9 Jan 2022 18:16:44 +0000
Subject: [PATCH 167/169] Update CHANGELOG.md

---
 CHANGELOG.md | 17 +++++++++++++++--
 1 file changed, 15 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccbb27a4..07dbdba0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,20 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
+## Unreleased
+### Added 
+- Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
+- Implemented user option to always show floating New Post button (normally mobile-only)
+- Display reasons for instance specific policies 
+- Added functionality to cancel follow request
+
+
+### Fixed
+- Fixed link to external profile not working on user profiles
+- Fixed mobile shoutbox display 
+- Fixed favicon badge not working in Chrome
+- Escape html more properly in subject/display name 
+
 ## [2.4.0] - 2021-08-08
 ### Added
 - Added a quick settings to timeline header for easier access
@@ -11,8 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Implemented user option to change sidebar position to the right side
 - Implemented user option to hide floating shout panel
 - Implemented "edit profile" button if viewing own profile which opens profile settings
-- Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
-- Implemented user option to always show floating New Post button (normally mobile-only)
+
 
 ### Fixed
 - Fixed follow request count showing in the wrong location in mobile view

From 4cd27acf7f08b3962968b25f4527abb90e7336ad Mon Sep 17 00:00:00 2001
From: Shpuld Shpludson <shp@cock.li>
Date: Sun, 9 Jan 2022 18:26:35 +0000
Subject: [PATCH 168/169] Update CHANGELOG.md

---
 CHANGELOG.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07dbdba0..eb79d439 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,20 +3,20 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
-## Unreleased
+## [2.4.2] - 2022-01-09
 ### Added 
 - Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
 - Implemented user option to always show floating New Post button (normally mobile-only)
 - Display reasons for instance specific policies 
 - Added functionality to cancel follow request
 
-
 ### Fixed
 - Fixed link to external profile not working on user profiles
 - Fixed mobile shoutbox display 
 - Fixed favicon badge not working in Chrome
 - Escape html more properly in subject/display name 
 
+
 ## [2.4.0] - 2021-08-08
 ### Added
 - Added a quick settings to timeline header for easier access
@@ -26,10 +26,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Implemented user option to hide floating shout panel
 - Implemented "edit profile" button if viewing own profile which opens profile settings
 
-
 ### Fixed
 - Fixed follow request count showing in the wrong location in mobile view
 
+
 ## [2.3.0] - 2021-03-01
 ### Fixed
 - Button to remove uploaded media in post status form is now properly placed and sized.

From 2ac78219eee4f123c398cedb0709cc32c84740e0 Mon Sep 17 00:00:00 2001
From: Ilja <pleroma@spectraltheorem.be>
Date: Wed, 19 Jan 2022 13:09:48 +0000
Subject: [PATCH 169/169] Improve the user card for deactivated users

---
 src/components/follow_button/follow_button.js  |  5 ++++-
 src/components/follow_button/follow_button.vue |  2 +-
 src/components/user_card/user_card.vue         | 14 +++++++++++++-
 src/i18n/en.json                               |  1 +
 src/modules/users.js                           |  2 +-
 5 files changed, 20 insertions(+), 4 deletions(-)

diff --git a/src/components/follow_button/follow_button.js b/src/components/follow_button/follow_button.js
index 95e7cb6b..1a44d18e 100644
--- a/src/components/follow_button/follow_button.js
+++ b/src/components/follow_button/follow_button.js
@@ -1,6 +1,6 @@
 import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
 export default {
-  props: ['relationship', 'labelFollowing', 'buttonClass'],
+  props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
   data () {
     return {
       inProgress: false
@@ -29,6 +29,9 @@ export default {
       } else {
         return this.$t('user_card.follow')
       }
+    },
+    disabled () {
+      return this.inProgress || this.user.deactivated
     }
   },
   methods: {
diff --git a/src/components/follow_button/follow_button.vue b/src/components/follow_button/follow_button.vue
index 7f85f1d7..965d5256 100644
--- a/src/components/follow_button/follow_button.vue
+++ b/src/components/follow_button/follow_button.vue
@@ -2,7 +2,7 @@
   <button
     class="btn button-default follow-button"
     :class="{ toggled: isPressed }"
-    :disabled="inProgress"
+    :disabled="disabled"
     :title="title"
     @click="onClick"
   >
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 6b69d15a..5f957003 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -82,6 +82,12 @@
                 @{{ user.screen_name_ui }}
               </router-link>
               <template v-if="!hideBio">
+                <span
+                  v-if="user.deactivated"
+                  class="alert user-role"
+                >
+                  {{ $t('user_card.deactivated') }}
+                </span>
                 <span
                   v-if="!!visibleRole"
                   class="alert user-role"
@@ -160,7 +166,10 @@
           class="user-interactions"
         >
           <div class="btn-group">
-            <FollowButton :relationship="relationship" />
+            <FollowButton
+              :relationship="relationship"
+              :user="user"
+            />
             <template v-if="relationship.following">
               <ProgressButton
                 v-if="!relationship.subscribing"
@@ -195,6 +204,7 @@
             <button
               v-if="relationship.muting"
               class="btn button-default btn-block toggled"
+              :disabled="user.deactivated"
               @click="unmuteUser"
             >
               {{ $t('user_card.muted') }}
@@ -202,6 +212,7 @@
             <button
               v-else
               class="btn button-default btn-block"
+              :disabled="user.deactivated"
               @click="muteUser"
             >
               {{ $t('user_card.mute') }}
@@ -210,6 +221,7 @@
           <div>
             <button
               class="btn button-default btn-block"
+              :disabled="user.deactivated"
               @click="mentionUser"
             >
               {{ $t('user_card.mention') }}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 0dac0fa3..571e6ad3 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -727,6 +727,7 @@
     "approve": "Approve",
     "block": "Block",
     "blocked": "Blocked!",
+    "deactivated": "Deactivated",
     "deny": "Deny",
     "edit_profile": "Edit profile",
     "favorites": "Favorites",
diff --git a/src/modules/users.js b/src/modules/users.js
index fb92cc91..05ff44d5 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -393,7 +393,7 @@ const users = {
     toggleActivationStatus ({ rootState, commit }, { user }) {
       const api = user.deactivated ? rootState.api.backendInteractor.activateUser : rootState.api.backendInteractor.deactivateUser
       api({ user })
-        .then(({ deactivated }) => commit('updateActivationStatus', { user, deactivated }))
+        .then((user) => { let deactivated = !user.is_active; commit('updateActivationStatus', { user, deactivated }) })
     },
     registerPushNotifications (store) {
       const token = store.state.currentUser.credentials