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/CHANGELOG.md b/CHANGELOG.md
index 653207ef..ccbb27a4 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
@@ -12,6 +12,7 @@ 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
 - 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
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/App.js b/src/App.js
index 362ac19d..f5e0b9e9 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
+    },
     hideShoutbox () {
       return this.$store.getters.mergedConfig.hideShoutbox
     },
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/App.vue b/src/App.vue
index c30f5e98..eb65b548 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -53,6 +53,7 @@
       v-if="currentUser && shout && !hideShoutbox"
       :floating="true"
       class="floating-shout mobile-hidden"
+      :class="{ 'left': shoutboxPosition }"
     />
     <MobilePostStatusButton />
     <UserReportingModal />
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/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
index bee1ad53..e5032176 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,14 @@ 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 messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
       return {
         summary: '',
-        statusnet_html: messagePreview,
+        emojis: messageEmojis,
+        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..eb195bc1 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -57,8 +57,9 @@ const ChatMessage = {
     messageForStatusContent () {
       return {
         summary: '',
-        statusnet_html: this.message.content,
-        text: this.message.content,
+        emojis: this.message.emojis,
+        raw_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..fcfa7c8a 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
+      .RichContent::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/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.js b/src/components/mention_link/mention_link.js
new file mode 100644
index 00000000..65c62baa
--- /dev/null
+++ b/src/components/mention_link/mention_link.js
@@ -0,0 +1,95 @@
+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',
+  props: {
+    url: {
+      required: true,
+      type: String
+    },
+    content: {
+      required: true,
+      type: String
+    },
+    userId: {
+      required: false,
+      type: String
+    },
+    userScreenName: {
+      required: false,
+      type: String
+    }
+  },
+  methods: {
+    onClick () {
+      const link = generateProfileLink(
+        this.userId || this.user.id,
+        this.userScreenName || this.user.screen_name
+      )
+      this.$router.push(link)
+    }
+  },
+  computed: {
+    user () {
+      return this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
+    },
+    isYou () {
+      // FIXME why user !== currentUser???
+      return this.user && this.user.id === this.currentUser.id
+    },
+    userName () {
+      return this.user && this.userNameFullUi.split('@')[0]
+    },
+    userNameFull () {
+      return this.user && this.user.screen_name
+    },
+    userNameFullUi () {
+      return this.user && this.user.screen_name_ui
+    },
+    highlight () {
+      return this.user && this.mergedConfig.highlight[this.user.screen_name]
+    },
+    highlightType () {
+      return this.highlight && ('-' + this.highlight.type)
+    },
+    highlightClass () {
+      if (this.highlight) return highlightClass(this.user)
+    },
+    style () {
+      if (this.highlight) {
+        const {
+          backgroundColor,
+          backgroundPosition,
+          backgroundImage,
+          ...rest
+        } = highlightStyle(this.highlight)
+        return rest
+      }
+    },
+    classnames () {
+      return [
+        {
+          '-you': this.isYou,
+          '-highlighted': this.highlight
+        },
+        this.highlightType
+      ]
+    },
+    ...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..ec2689f8
--- /dev/null
+++ b/src/components/mention_link/mention_link.scss
@@ -0,0 +1,91 @@
+.MentionLink {
+  position: relative;
+  white-space: normal;
+  display: inline-block;
+  color: var(--link);
+
+  & .new,
+  & .original {
+    display: inline-block;
+    border-radius: 2px;
+  }
+
+  .full {
+    position: absolute;
+    display: inline-block;
+    pointer-events: none;
+    opacity: 0;
+    top: 100%;
+    left: 0;
+    height: 100%;
+    word-wrap: normal;
+    white-space: nowrap;
+    transition: opacity 0.2s ease;
+    z-index: 1;
+    margin-top: 0.25em;
+    padding: 0.5em;
+    user-select: all;
+  }
+
+  .short {
+    user-select: none;
+  }
+
+  & .short,
+  & .full {
+    white-space: nowrap;
+  }
+
+  .new {
+    &.-you {
+      & .shortName,
+      & .full {
+        font-weight: 600;
+      }
+    }
+
+    .at {
+      color: var(--link);
+      opacity: 0.8;
+      display: inline-block;
+      height: 50%;
+      line-height: 1;
+      padding: 0 0.1em;
+      vertical-align: -25%;
+      margin: 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);
+      }
+    }
+  }
+
+  &: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
new file mode 100644
index 00000000..a22b486c
--- /dev/null
+++ b/src/components/mention_link/mention_link.vue
@@ -0,0 +1,56 @@
+<template>
+  <span
+    class="MentionLink"
+  >
+    <!-- eslint-disable vue/no-v-html -->
+    <a
+      v-if="!user"
+      :href="url"
+      class="original"
+      target="_blank"
+      v-html="content"
+    />
+    <!-- eslint-enable vue/no-v-html -->
+    <span
+      v-if="user"
+      class="new"
+      :style="style"
+      :class="classnames"
+    >
+      <a
+        class="short button-unstyled"
+        :href="url"
+        @click.prevent="onClick"
+      >
+        <!-- eslint-disable vue/no-v-html -->
+        <FAIcon
+          size="sm"
+          icon="at"
+          class="at"
+        /><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 -->
+      </a>
+      <span
+        v-if="userName !== userNameFull"
+        class="full popover-default"
+        :class="[highlightType]"
+      >
+        <span
+          class="userNameFull"
+          v-text="'@' + userNameFull"
+        />
+      </span>
+    </span>
+  </span>
+</template>
+
+<script src="./mention_link.js"/>
+
+<style lang="scss" src="./mention_link.scss"/>
diff --git a/src/components/mentions_line/mentions_line.js b/src/components/mentions_line/mentions_line.js
new file mode 100644
index 00000000..a4a0c724
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.js
@@ -0,0 +1,37 @@
+import MentionLink from 'src/components/mention_link/mention_link.vue'
+import { mapGetters } from 'vuex'
+
+export const MENTIONS_LIMIT = 5
+
+const MentionsLine = {
+  name: 'MentionsLine',
+  props: {
+    mentions: {
+      required: true,
+      type: Array
+    }
+  },
+  data: () => ({ expanded: false }),
+  components: {
+    MentionLink
+  },
+  computed: {
+    mentionsComputed () {
+      return this.mentions.slice(0, MENTIONS_LIMIT)
+    },
+    extraMentions () {
+      return this.mentions.slice(MENTIONS_LIMIT)
+    },
+    manyMentions () {
+      return this.extraMentions.length > 0
+    },
+    ...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..b9d5c14a
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.scss
@@ -0,0 +1,11 @@
+.MentionsLine {
+  .showMoreLess {
+    white-space: normal;
+    color: var(--link);
+  }
+
+  .fullExtraMentions,
+  .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
new file mode 100644
index 00000000..f375e3b0
--- /dev/null
+++ b/src/components/mentions_line/mentions_line.vue
@@ -0,0 +1,43 @@
+<template>
+  <span class="MentionsLine">
+    <MentionLink
+      v-for="mention in mentionsComputed"
+      :key="mention.index"
+      class="mention-link"
+      :content="mention.content"
+      :url="mention.url"
+      :first-mention="false"
+    /><span
+      v-if="manyMentions"
+      class="extraMentions"
+    >
+      <span
+        v-if="expanded"
+        class="fullExtraMentions"
+      >
+        <MentionLink
+          v-for="mention in extraMentions"
+          :key="mention.index"
+          class="mention-link"
+          :content="mention.content"
+          :url="mention.url"
+          :first-mention="false"
+        />
+      </span><button
+        v-if="!expanded"
+        class="button-unstyled showMoreLess"
+        @click="toggleShowMore"
+      >
+        {{ $t('status.plus_more', { number: extraMentions.length }) }}
+      </button><button
+        v-if="expanded"
+        class="button-unstyled showMoreLess"
+        @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/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/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.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..63b44e4f 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -17,8 +17,11 @@
           <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 +45,11 @@
           :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/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
new file mode 100644
index 00000000..c0d20c5e
--- /dev/null
+++ b/src/components/rich_content/rich_content.jsx
@@ -0,0 +1,327 @@
+import Vue from 'vue'
+import { unescape, flattenDeep } from 'lodash'
+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 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'
+
+/**
+ * RichContent, The Über-powered component for rendering Post HTML.
+ *
+ * This takes post HTML and does multiple things to it:
+ * - 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.
+ *
+ * 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: {
+    // Original html content
+    html: {
+      required: true,
+      type: String
+    },
+    attentions: {
+      required: false,
+      default: () => []
+    },
+    // 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
+    },
+    // Meme arrows
+    greentext: {
+      required: false,
+      type: Boolean,
+      default: false
+    }
+  },
+  // NEVER EVER TOUCH DATA INSIDE RENDER
+  render (h) {
+    // 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
+    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
+    let tagsIndex = 0
+
+    const renderImage = (tag) => {
+      return <StillImage
+        {...{ attrs: getAttrs(tag) }}
+        class="img"
+      />
+    }
+
+    const renderHashtag = (attrs, children, encounteredTextReverse) => {
+      const linkData = getLinkData(attrs, children, tagsIndex++)
+      writtenTags.push(linkData)
+      if (!encounteredTextReverse) {
+        lastTags.push(linkData)
+      }
+      return <HashtagLink {...{ props: linkData }}/>
+    }
+
+    const renderMention = (attrs, children) => {
+      const linkData = getLinkData(attrs, children, mentionIndex++)
+      linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
+      writtenMentions.push(linkData)
+      if (currentMentions === null) {
+        currentMentions = []
+      }
+      currentMentions.push(linkData)
+      if (currentMentions.length > MENTIONS_LIMIT) {
+        invisibleMentions.push(linkData)
+      }
+      if (currentMentions.length === 1) {
+        return <MentionsLine mentions={ currentMentions } />
+      } else {
+        return ''
+      }
+    }
+
+    // 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) {
+          // don't include spaces when processing mentions - we'll include them
+          // in MentionsLine
+          lastSpacing = item
+          return currentMentions !== null ? item.trim() : item
+        }
+
+        currentMentions = null
+        if (item.includes(':')) {
+          item = ['', processTextForEmoji(
+            item,
+            this.emoji,
+            ({ shortcode, url }) => {
+              return <StillImage
+                class="emoji img"
+                src={url}
+                title={`:${shortcode}:`}
+                alt={`:${shortcode}:`}
+              />
+            }
+          )]
+        }
+        return item
+      }
+
+      // Handle tag nodes
+      if (Array.isArray(item)) {
+        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 ['', [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 {
+              currentMentions = null
+              break
+            }
+          case 'span':
+            if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
+              return ['', children.map(processItem), '']
+            }
+        }
+
+        if (children !== undefined) {
+          return [
+            '',
+            [
+              mentionsLinePadding,
+              [opener, children.map(processItem), closer]
+            ],
+            ''
+          ]
+        } else {
+          return ['', [mentionsLinePadding, item], '']
+        }
+      }
+    }
+
+    // Processor for back direction (for finding "last" stuff, just easier this way)
+    let encounteredTextReverse = false
+    const processItemReverse = (item, index, array, what) => {
+      // Handle text nodes - just add emoji
+      if (typeof item === 'string') {
+        const emptyText = item.trim() === ''
+        if (emptyText) return item
+        if (!encounteredTextReverse) encounteredTextReverse = true
+        return unescape(item)
+      } else if (Array.isArray(item)) {
+        // Handle tag nodes
+        const [opener, children] = item
+        const Tag = opener === '' ? '' : 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')) || // Pleroma style
+                (attrs['rel'] === 'tag') // Mastodon style
+            ) {
+              return renderHashtag(attrs, children, encounteredTextReverse)
+            } else {
+              attrs.target = '_blank'
+              const newChildren = [...children].reverse().map(processItemReverse).reverse()
+
+              return <a {...{ attrs }}>
+                { newChildren }
+              </a>
+            }
+          case '':
+            return [...children].reverse().map(processItemReverse).reverse()
+        }
+
+        // Render tag as is
+        if (children !== undefined) {
+          const newChildren = Array.isArray(children)
+            ? [...children].reverse().map(processItemReverse).reverse()
+            : children
+          return <Tag {...{ attrs: getAttrs(opener) }}>
+            { newChildren }
+          </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">
+      { pass2 }
+    </span>
+
+    const event = {
+      lastTags,
+      writtenMentions,
+      writtenTags,
+      invisibleMentions
+    }
+
+    // DO NOT MOVE TO UPDATE. BAD IDEA.
+    this.$emit('parseReady', event)
+
+    return result
+  }
+})
+
+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,
+    tag: attrs['data-tag'],
+    content: flattenDeep(children).join(''),
+    textContent
+  }
+}
+
+/** Pre-processing HTML
+ *
+ * Currently this does one thing:
+ * - add green/cyantexting
+ *
+ * @param {String} html - raw HTML to process
+ * @param {Boolean} greentext - whether to enable greentexting or not
+ */
+export const preProcessPerLine = (html, greentext) => {
+  const greentextHandle = new Set(['p', 'div'])
+
+  const lines = convertHtmlToLines(html)
+  const newHtml = lines.reverse().map((item, index, array) => {
+    if (!item.text) return item
+    const string = item.text
+
+    // Greentext stuff
+    if (
+      // Only if greentext is engaged
+      greentext &&
+        // 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;'))
+    ) {
+      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>`
+      }
+    }
+
+    return string
+  }).reverse().join('')
+
+  return { newHtml }
+}
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
new file mode 100644
index 00000000..db08ef1e
--- /dev/null
+++ b/src/components/rich_content/rich_content.scss
@@ -0,0 +1,64 @@
+.RichContent {
+  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 {
+    display: inline-block;
+    width: var(--emoji-size, 32px);
+    height: var(--emoji-size, 32px);
+  }
+
+  .img,
+  video {
+    max-width: 100%;
+    max-height: 400px;
+    vertical-align: middle;
+    object-fit: contain;
+  }
+}
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index d3e71b31..f2ec7d64 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -122,6 +122,11 @@
             {{ $t('settings.sensitive_by_default') }}
           </BooleanSetting>
         </li>
+        <li>
+          <BooleanSetting path="alwaysShowNewPostButton">
+            {{ $t('settings.always_show_post_button') }}
+          </BooleanSetting>
+        </li>
         <li>
           <BooleanSetting path="autohideFloatingPostButton">
             {{ $t('settings.autohide_floating_post_button') }}
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 8b81db5d..0b6669fc 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -475,7 +475,7 @@ export default {
           this.loadThemeFromLocalStorage(false, true)
           break
         case 'file':
-          console.err('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/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/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"
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 470c01f1..ac481534 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -9,9 +9,12 @@ 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'
+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'
 import { muteWordHits } from '../../services/status_parser/status_parser.js'
@@ -68,7 +71,10 @@ const Status = {
     StatusPopover,
     UserListPopover,
     EmojiReactions,
-    StatusContent
+    StatusContent,
+    RichContent,
+    MentionLink,
+    MentionsLine
   },
   props: [
     'statusoid',
@@ -92,7 +98,8 @@ const Status = {
       userExpanded: false,
       mediaPlaying: [],
       suspendable: true,
-      error: null
+      error: null,
+      headTailLinks: null
     }
   },
   computed: {
@@ -132,12 +139,15 @@ const Status = {
     },
     replyProfileLink () {
       if (this.isReply) {
-        return this.generateUserProfileLink(this.status.in_reply_to_user_id, this.replyToName)
+        const user = this.$store.getters.findUser(this.status.in_reply_to_user_id)
+        // FIXME Why user not found sometimes???
+        return user ? user.statusnet_profile_url : 'NOT_FOUND'
       }
     },
     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) {
@@ -156,6 +166,25 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
+    mentionsLine () {
+      if (!this.headTailLinks) return []
+      const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
+      return this.status.attentions.filter(attn => {
+        // 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,
+        userId: attn.id
+      }))
+    },
+    hasMentionsLine () {
+      return this.mentionsLine.length > 0
+    },
     muted () {
       if (this.statusoid.user.id === this.currentUser.id) return false
       const { status } = this
@@ -303,6 +332,9 @@ const Status = {
     },
     removeMediaPlaying (id) {
       this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
+    },
+    setHeadTailLinks (headTailLinks) {
+      this.headTailLinks = headTailLinks
     }
   },
   watch: {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 58b55bc8..71305dd7 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,10 +1,10 @@
-
 @import '../../_variables.scss';
 
 $status-margin: 0.75em;
 
 .Status {
   min-width: 0;
+  white-space: normal;
 
   &:hover {
     --_still-image-img-visibility: visible;
@@ -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 {
@@ -155,35 +151,24 @@ $status-margin: 0.75em;
     }
   }
 
+  .glued-label {
+    display: inline-flex;
+    white-space: nowrap;
+  }
+
   .timeago {
     margin-right: 0.2em;
   }
 
-  .heading-reply-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;
@@ -220,21 +205,27 @@ $status-margin: 0.75em;
     }
   }
 
-  .reply-to {
+  & .mentions,
+  & .reply-to {
+    white-space: nowrap;
     position: relative;
+    padding-right: 0.25em;
   }
 
-  .reply-to-text {
+  & .mentions-text,
+  & .reply-to-text {
+    color: var(--faint);
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;
   }
 
-  .replies-separator {
-    margin-left: 0.4em;
+  .mentions-line {
+    display: inline;
   }
 
   .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 00e962f3..2684e415 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,12 @@
             <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 +148,12 @@
                   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"
@@ -214,11 +221,13 @@
                 </button>
               </span>
             </div>
-
-            <div class="heading-reply-row">
-              <div
+            <div
+              v-if="isReply || hasMentionsLine"
+              class="heading-reply-row"
+            >
+              <span
                 v-if="isReply"
-                class="reply-to-and-accountname"
+                class="glued-label"
               >
                 <StatusPopover
                   v-if="!isPreview"
@@ -238,7 +247,7 @@
                       flip="horizontal"
                     />
                     <span
-                      class="faint-link reply-to-text"
+                      class="reply-to-text"
                     >
                       {{ $t('status.reply_to') }}
                     </span>
@@ -251,50 +260,76 @@
                 >
                   <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
                 </span>
-                <router-link
-                  class="reply-to-link"
-                  :title="replyToName"
-                  :to="replyProfileLink"
-                >
-                  {{ replyToName }}
-                </router-link>
-                <span
-                  v-if="replies && replies.length"
-                  class="faint replies-separator"
-                >
-                  -
-                </span>
-              </div>
-              <div
-                v-if="inConversation && !isPreview && replies && replies.length"
-                class="replies"
+                <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>
+
+              <!-- This little wrapper is made for sole purpose of "gluing" -->
+              <!-- "Mentions" label to the first mention -->
+              <span
+                v-if="hasMentionsLine"
+                class="glued-label"
               >
-                <span class="faint">{{ $t('status.replies_list') }}</span>
-                <StatusPopover
-                  v-for="reply in replies"
-                  :key="reply.id"
-                  :status-id="reply.id"
+                <span
+                  class="mentions"
+                  :aria-label="$t('tool_tip.mentions')"
+                  @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
                 >
-                  <button
-                    class="button-unstyled -link reply-link"
-                    @click.prevent="gotoOriginal(reply.id)"
+                  <span
+                    class="mentions-text"
                   >
-                    {{ reply.name }}
-                  </button>
-                </StatusPopover>
-              </div>
+                    {{ $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.slice(1)"
+                class="mentions-line"
+              />
             </div>
           </div>
 
           <StatusContent
+            ref="content"
             :status="status"
             :no-heading="noHeading"
             :highlight="highlight"
             :focused="isFocused"
             @mediaplay="addMediaPlaying($event)"
             @mediapause="removeMediaPlaying($event)"
+            @parseReady="setHeadTailLinks"
           />
 
+          <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"
@@ -402,7 +437,6 @@
       </div>
     </template>
   </div>
-<!-- eslint-enable vue/no-v-html -->
 </template>
 
 <script src="./status.js" ></script>
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
new file mode 100644
index 00000000..ef542307
--- /dev/null
+++ b/src/components/status_body/status_body.js
@@ -0,0 +1,127 @@
+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 {
+  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,
+      postLength: this.status.text.length,
+      parseReadyDone: false
+    }
+  },
+  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.raw_html.split(/<p|<br/).length + this.postLength / 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)
+    },
+    attachmentTypes () {
+      return this.status.attachments.map(file => fileType.fileType(file.mimetype))
+    },
+    ...mapGetters(['mergedConfig'])
+  },
+  components: {
+    RichContent
+  },
+  mounted () {
+    this.status.attentions && this.status.attentions.forEach(attn => {
+      const { id } = attn
+      this.$store.dispatch('fetchUserIfMissing', id)
+    })
+  },
+  methods: {
+    onParseReady (event) {
+      if (this.parseReadyDone) return
+      this.parseReadyDone = true
+      this.$emit('parseReady', event)
+      const { writtenMentions, invisibleMentions } = 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}`)
+        })
+      /* 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) {
+        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_body/status_body.scss b/src/components/status_body/status_body.scss
new file mode 100644
index 00000000..c7732bfe
--- /dev/null
+++ b/src/components/status_body/status_body.scss
@@ -0,0 +1,118 @@
+@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;
+    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;
+
+      .media-body {
+        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);
+  }
+
+  .cyantext {
+    color: var(--postCyantext, $fallback--cBlue);
+  }
+}
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
new file mode 100644
index 00000000..9f01c470
--- /dev/null
+++ b/src/components/status_body/status_body.vue
@@ -0,0 +1,97 @@
+<template>
+  <div class="StatusBody">
+    <div class="body">
+      <div
+        v-if="status.summary_raw_html"
+        class="summary-wrapper"
+        :class="{ '-tall': (longSubject && !showingLongSubject) }"
+      >
+        <RichContent
+          class="media-body summary"
+          :html="status.summary_raw_html"
+          :emoji="status.emojis"
+        />
+        <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_raw_html)"
+          :class="{ '-single-line': singleLine }"
+          class="text media-body"
+          :html="status.raw_html"
+          :emoji="status.emojis"
+          :handle-links="true"
+          :greentext="mergedConfig.greentext"
+          :attentions="status.attentions"
+          @parseReady="onParseReady"
+        />
+
+        <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 a6f79d76..1b80ee09 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -1,11 +1,9 @@
 import Attachment from '../attachment/attachment.vue'
 import Poll from '../poll/poll.vue'
 import Gallery from '../gallery/gallery.vue'
+import StatusBody from 'src/components/status_body/status_body.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 {
@@ -35,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
@@ -118,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.statusnet_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
     })
   },
@@ -164,48 +87,10 @@ const StatusContent = {
     Attachment,
     Poll,
     Gallery,
-    LinkPreview
+    LinkPreview,
+    StatusBody
   },
   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 90bfaf40..5cebc697 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -1,133 +1,55 @@
 <template>
-  <!-- eslint-disable vue/no-v-html -->
   <div class="StatusContent">
     <slot name="header" />
-    <div
-      v-if="status.summary_html"
-      class="summary-wrapper"
-      :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
+    <StatusBody
+      :status="status"
+      :single-line="singleLine"
+      @parseReady="$emit('parseReady', $event)"
     >
+      <div v-if="status.poll && status.poll.options">
+        <Poll
+          :base-poll="status.poll"
+          :emoji="status.emojis"
+        />
+      </div>
+
       <div
-        class="media-body summary"
-        @click.prevent="linkClicked"
-        v-html="status.summary_html"
-      />
-      <button
-        v-if="longSubject && showingLongSubject"
-        class="button-unstyled -link tall-subject-hider"
-        @click.prevent="showingLongSubject=false"
+        v-if="status.attachments.length !== 0"
+        class="attachments media-body"
       >
-        {{ $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>
+        <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="!hideSubjectStatus"
-        :class="{ 'single-line': singleLine }"
-        class="status-content media-body"
-        @click.prevent="linkClicked"
-        v-html="postBodyHtml"
-      />
-      <button
-        v-if="hideSubjectStatus"
-        class="button-unstyled -link cw-status-hider"
-        @click.prevent="toggleShowMore"
+        v-if="status.card && !noHeading"
+        class="link-preview media-body"
       >
-        {{ $t("status.show_content") }}
-        <FAIcon
-          v-if="attachmentTypes.includes('image')"
-          icon="image"
+        <link-preview
+          :card="status.card"
+          :size="attachmentSize"
+          :nsfw="nsfwClickthrough"
         />
-        <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="status.poll && status.poll.options && !hideSubjectStatus">
-      <poll :base-poll="status.poll" />
-    </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>
+    </StatusBody>
     <slot name="footer" />
   </div>
-  <!-- eslint-enable vue/no-v-html -->
 </template>
 
 <script src="./status_content.js" ></script>
@@ -139,156 +61,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;
-    }
-  }
-
-  img, video {
-    max-width: 100%;
-    max-height: 400px;
-    vertical-align: middle;
-    object-fit: contain;
-
-    &.emoji {
-      width: 32px;
-      height: 32px;
-    }
-  }
-
-  .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 {
-    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;
-      overflow: hidden;
-      height: 1.4em;
-    }
-  }
-}
-
-.greentext {
-  color: $fallback--cGreen;
-  color: var(--postGreentext, $fallback--cGreen);
 }
 </style>
diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue
index d3eb5925..0623b42e 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 {
@@ -47,12 +47,13 @@
 
   img {
     width: 100%;
-    min-height: 100%;
+    height: 100%;
     object-fit: contain;
   }
 
   &.animated {
     &::before {
+      zoom: var(--_still_image-label-scale, 1);
       content: 'gif';
       position: absolute;
       line-height: 10px;
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 367fbc6c..cd8ca420 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'
@@ -120,7 +121,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 528b92fb..6b69d15a 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"
                 class="button-unstyled edit-profile-button"
@@ -65,7 +56,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 +66,7 @@
                   class="icon"
                   icon="external-link-alt"
                 />
-              </button>
+              </a>
               <AccountActions
                 v-if="isOtherUser && loggedIn"
                 :user="user"
@@ -267,20 +258,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>
@@ -293,9 +276,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 {
@@ -339,12 +323,12 @@
     }
   }
 
-  p {
-    margin-bottom: 0;
-  }
-
   &-bio {
     text-align: center;
+    display: block;
+    line-height: 18px;
+    padding: 1em;
+    margin: 0;
 
     a {
       color: $fallback--link;
@@ -356,11 +340,6 @@
       vertical-align: middle;
       max-width: 100%;
       max-height: 400px;
-
-      &.emoji {
-        width: 32px;
-        height: 32px;
-      }
     }
   }
 
@@ -462,13 +441,6 @@
     // big one
     z-index: 1;
 
-    img {
-      width: 26px;
-      height: 26px;
-      vertical-align: middle;
-      object-fit: contain
-    }
-
     .top-line {
       display: flex;
     }
@@ -481,12 +453,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/i18n/ca.json b/src/i18n/ca.json
index b15b69f7..2536656f 100644
--- a/src/i18n/ca.json
+++ b/src/i18n/ca.json
@@ -10,11 +10,12 @@
     "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",
-    "find_user": "Find user"
+    "find_user": "Trobar usuari"
   },
   "general": {
     "apply": "Aplica",
@@ -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,19 +77,25 @@
     "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",
     "notifications": "Notificacions",
-    "read": "Read!",
+    "read": "Llegit!",
     "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.",
@@ -83,24 +104,33 @@
     "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…",
+    "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",
-      "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"
+    "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ó",
+    "media_description_error": "Ha fallat la pujada del contingut. Prova de nou"
   },
   "registration": {
     "bio": "Presentació",
@@ -118,13 +148,19 @@
       "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",
+    "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",
     "attachments": "Adjunts",
     "avatar": "Avatar",
-    "avatarAltRadius": "Avatars en les notificacions",
+    "avatarAltRadius": "Avatars (notificacions)",
     "avatarRadius": "Avatars",
     "background": "Fons de pantalla",
     "bio": "Presentació",
@@ -134,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",
@@ -176,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",
@@ -193,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",
@@ -216,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",
@@ -254,25 +290,257 @@
     "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",
+        "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…",
       "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",
+      "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",
-    "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)",
+    "style": {
+      "fonts": {
+        "family": "Nom de la font",
+        "size": "Mida (en píxels)",
+        "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.",
+        "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",
+        "link": "un bonic enllaç"
+      },
+      "shadows": {
+        "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": {
+      "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.",
+    "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}",
@@ -287,12 +555,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",
@@ -308,7 +576,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",
@@ -324,13 +598,60 @@
     "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",
+      "grant_admin": "Concedir permisos d'Administració",
+      "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…",
+    "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",
+    "highlight": {
+      "disabled": "Sense ressaltat",
+      "solid": "Fons sòlid",
+      "striped": "Fons a ratlles",
+      "side": "Ratlla lateral"
+    }
   },
   "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",
+    "more": "Més",
     "who_to_follow": "A qui seguir"
   },
   "selectable_list": {
@@ -342,10 +663,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}",
@@ -357,7 +687,11 @@
     "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",
+    "not_enough_options": "L'enquesta no té suficients opcions úniques"
   },
   "media_modal": {
     "next": "Següent",
@@ -365,7 +699,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 +714,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 +728,132 @@
         "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 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",
+        "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"
+  },
+  "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",
+    "media_upload": "Pujar multimèdia"
+  },
+  "search": {
+    "no_results": "No hi ha resultats",
+    "people": "Persones",
+    "hashtags": "Etiquetes",
+    "people_talking": "{count} persones parlant"
+  },
+  "upload": {
+    "file_size_units": {
+      "B": "B",
+      "KiB": "KiB",
+      "GiB": "GiB",
+      "TiB": "TiB",
+      "MiB": "MiB"
+    },
+    "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?",
+    "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"
   }
 }
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!",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index b31e4880..6afc9ecb 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_new_style": "Fancier mention links",
+    "mentions_new_place": "Put mentions on a separate line",
     "mfa": {
       "otp": "OTP",
       "setup_otp": "Setup OTP",
@@ -350,6 +352,7 @@
     "hide_isp": "Hide instance-specific panel",
     "hide_shoutbox": "Hide instance shoutbox",
     "right_sidebar": "Show sidebar on the right side",
+    "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",
@@ -698,6 +701,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",
@@ -712,7 +716,9 @@
     "hide_content": "Hide content",
     "status_deleted": "This post was deleted",
     "nsfw": "NSFW",
-    "expand": "Expand"
+    "expand": "Expand",
+    "you": "(You)",
+    "plus_more": "+{number} more"
   },
   "user_card": {
     "approve": "Approve",
diff --git a/src/i18n/eo.json b/src/i18n/eo.json
index 0d24a8f8..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",
@@ -87,7 +90,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 +123,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 +139,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 +148,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 +163,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 +250,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 +538,22 @@
     "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",
+    "hide_shoutbox": "Kaŝi kriujon de nodo"
   },
   "timeline": {
     "collapse": "Maletendi",
@@ -546,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",
@@ -696,7 +719,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 +727,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": {
diff --git a/src/i18n/es.json b/src/i18n/es.json
index b8a87ec7..0d343e8c 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",
@@ -147,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",
@@ -181,7 +184,7 @@
     "preview_empty": "Vacío",
     "preview": "Vista previa",
     "media_description": "Descripción multimedia",
-    "post": "Publicación"
+    "post": "Publicar"
   },
   "registration": {
     "bio": "Biografía",
@@ -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",
diff --git a/src/i18n/eu.json b/src/i18n/eu.json
index e543fda0..29eb7c50 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"
@@ -210,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",
@@ -468,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",
@@ -480,7 +495,11 @@
       "title": "Bertsioa",
       "backend_version": "Backend bertsioa",
       "frontend_version": "Frontend bertsioa"
-    }
+    },
+    "save": "Aldaketak gorde",
+    "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",
@@ -691,5 +710,12 @@
   },
   "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."
+  },
+  "remote_user_resolver": {
+    "searching_for": "Bilatzen",
+    "error": "Ez da aurkitu."
   }
 }
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",
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",
diff --git a/src/i18n/id.json b/src/i18n/id.json
new file mode 100644
index 00000000..a2e7df0c
--- /dev/null
+++ b/src/i18n/id.json
@@ -0,0 +1,622 @@
+{
+  "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",
+        "_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",
+        "_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.",
+          "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",
+        "load_theme": "Muat tema"
+      },
+      "fonts": {
+        "_tab_label": "Font",
+        "components": {
+          "interface": "Antarmuka",
+          "post": "Teks postingan"
+        },
+        "family": "Nama font",
+        "size": "Ukuran (dalam px)",
+        "weight": "Berat (ketebalan)"
+      },
+      "shadows": {
+        "components": {
+          "panel": "Panel",
+          "panelHeader": "Header panel"
+        }
+      }
+    },
+    "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",
+      "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.",
+    "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": "Ekspor blokiran",
+    "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",
+      "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",
+      "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",
+    "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",
+    "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",
+    "filtering": "Penyaringan",
+    "word_filter": "Penyaring kata",
+    "avatar_size_instruction": "Ukuran minimum gambar avatar yang disarankan adalah 150x150 piksel.",
+    "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": {
+      "keyword": {
+        "reject": "Tolak",
+        "is_replaced_by": "→"
+      },
+      "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",
+      "mrf_policies": "Kebijakan MRF yang diaktifkan"
+    },
+    "staff": "Staf"
+  },
+  "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}",
+    "migrated_to": "bermigrasi ke",
+    "load_older": "Muat notifikasi yang lebih lama",
+    "broken_favorite": "Status tak diketahui, mencarinya…"
+  },
+  "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.",
+    "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:",
+    "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",
+    "reason_placeholder": "Instansi ini menerima pendaftaran secara manual.\nBeritahu administrasinya mengapa Anda ingin mendaftar.",
+    "reason": "Alasan mendaftar",
+    "registration": "Pendaftaran"
+  },
+  "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",
+    "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",
+    "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",
+    "keep_open": "Tetap buka pemilih",
+    "custom": "Emoji kustom"
+  },
+  "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",
+    "not_enough_options": "Terlalu sedikit opsi yang unik pada 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",
+    "timelines": "Linimasa",
+    "chats": "Obrolan",
+    "dms": "Pesan langsung",
+    "friend_requests": "Ingin mengikuti"
+  },
+  "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",
+    "heading": {
+      "totp": "Otentikasi dua-faktor"
+    },
+    "enter_two_factor_code": "Masukkan kode dua-faktor"
+  },
+  "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",
+    "upload_limit": "Batas unggahan"
+  },
+  "exporter": {
+    "processing": "Memproses, Anda akan segera diminta untuk mengunduh berkas Anda",
+    "export": "Ekspor"
+  },
+  "domain_mute_card": {
+    "unmute": "Berhenti membisukan",
+    "mute_progress": "Membisukan…",
+    "mute": "Bisukan",
+    "unmute_progress": "Memberhentikan pembisuan…"
+  },
+  "display_date": {
+    "today": "Hari Ini"
+  },
+  "selectable_list": {
+    "select_all": "Pilih semua"
+  },
+  "interactions": {
+    "moves": "Pengguna yang bermigrasi",
+    "follows": "Pengikut baru",
+    "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."
+  },
+  "shoutbox": {
+    "title": "Kotak Suara"
+  }
+}
diff --git a/src/i18n/it.json b/src/i18n/it.json
index a88686ae..ee872328 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",
@@ -65,13 +68,13 @@
     "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",
     "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",
@@ -365,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",
@@ -443,7 +446,9 @@
       "backup_settings_theme": "Archivia impostazioni e tema localmente",
       "backup_settings": "Archivia impostazioni localmente",
       "backup_restore": "Archiviazione impostazioni"
-    }
+    },
+    "right_sidebar": "Mostra barra laterale a destra",
+    "hide_shoutbox": "Nascondi muro dei graffiti"
   },
   "timeline": {
     "error_fetching": "Errore nell'aggiornamento",
@@ -522,7 +527,8 @@
       "striped": "A righe",
       "solid": "Un colore",
       "disabled": "Nessun risalto"
-    }
+    },
+    "edit_profile": "Modifica profilo"
   },
   "chat": {
     "title": "Chat"
@@ -660,7 +666,7 @@
   },
   "domain_mute_card": {
     "mute": "Silenzia",
-    "mute_progress": "Silenzio…",
+    "mute_progress": "Procedo…",
     "unmute": "Ascolta",
     "unmute_progress": "Procedo…"
   },
@@ -701,7 +707,7 @@
   },
   "interactions": {
     "favs_repeats": "Condivisi e Graditi",
-    "load_older": "Carica vecchie interazioni",
+    "load_older": "Carica interazioni precedenti",
     "moves": "Utenti migrati",
     "follows": "Nuovi seguìti"
   },
diff --git a/src/i18n/pl.json b/src/i18n/pl.json
index 7cf06796..11409169 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,27 @@
     "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",
+    "hide_shoutbox": "Ukryj shoutbox instancji"
   },
   "time": {
     "day": "{0} dzień",
@@ -648,7 +681,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 +766,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",
diff --git a/src/i18n/uk.json b/src/i18n/uk.json
index e616291e..10a7375f 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": "Користувача не знайдено",
@@ -633,7 +636,9 @@
       "backup_settings_theme": "Резервне копіювання налаштувань та теми у файл",
       "backup_settings": "Резервне копіювання налаштувань у файл",
       "backup_restore": "Резервне копіювання налаштувань"
-    }
+    },
+    "right_sidebar": "Показувати бокову панель справа",
+    "hide_shoutbox": "Приховати оголошення інстансу"
   },
   "selectable_list": {
     "select_all": "Вибрати все"
@@ -799,7 +804,8 @@
       "solid": "Суцільний фон",
       "disabled": "Не виділяти"
     },
-    "bot": "Бот"
+    "bot": "Бот",
+    "edit_profile": "Редагувати профіль"
   },
   "status": {
     "copy_link": "Скопіювати посилання на допис",
diff --git a/src/i18n/vi.json b/src/i18n/vi.json
new file mode 100644
index 00000000..088d73cc
--- /dev/null
+++ b/src/i18n/vi.json
@@ -0,0 +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."
+  }
+}
diff --git a/src/i18n/zh.json b/src/i18n/zh.json
index bee75d84..9f91ef1a 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,9 @@
       "backup_settings_theme": "备份设置和主题到文件",
       "backup_settings": "备份设置到文件",
       "backup_restore": "设置备份"
-    }
+    },
+    "right_sidebar": "在右侧显示侧边栏",
+    "hide_shoutbox": "隐藏实例留言板"
   },
   "time": {
     "day": "{0} 天",
@@ -724,7 +729,8 @@
       "striped": "条纹背景",
       "solid": "单一颜色背景",
       "disabled": "不突出显示"
-    }
+    },
+    "edit_profile": "编辑个人资料"
   },
   "user_profile": {
     "timeline_title": "用户时间线",
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": "用戶時間線",
diff --git a/src/modules/config.js b/src/modules/config.js
index 33e2cb50..bc3db11b 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -35,6 +35,7 @@ export const defaultState = {
   loopVideoSilentOnly: true,
   streaming: false,
   emojiReactionsOnTimeline: true,
+  alwaysShowNewPostButton: false,
   autohideFloatingPostButton: false,
   pauseOnUnfocused: true,
   stopGifs: false,
diff --git a/src/modules/users.js b/src/modules/users.js
index 2b416f94..fb92cc91 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -246,6 +246,11 @@ export const getters = {
     }
     return result
   },
+  findUserByUrl: state => query => {
+    return state.users
+      .find(u => u.statusnet_profile_url &&
+            u.statusnet_profile_url.toLowerCase() === query.toLowerCase())
+  },
   relationship: state => id => {
     const rel = id && state.relationships[id]
     return rel || { id, loading: true }
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index a4ddf927..04bb45a4 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -54,17 +54,19 @@ 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)
+    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 => {
       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 => {
@@ -239,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 = {}
@@ -266,7 +258,8 @@ 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
 
     output.tags = data.tags
 
@@ -293,13 +286,13 @@ export const parseStatus = (data) => {
       output.retweeted_status = parseStatus(data.reblog)
     }
 
-    output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
+    output.summary_raw_html = escape(data.spoiler_text)
     output.external_url = data.url
     output.poll = data.poll
     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
@@ -325,7 +318,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
@@ -444,11 +437,8 @@ export const parseChatMessage = (message) => {
   output.id = message.id
   output.created_at = new Date(message.created_at)
   output.chat_id = message.chat_id
-  if (message.content) {
-    output.content = addEmojis(message.content, message.emojis)
-  } else {
-    output.content = ''
-  }
+  output.emojis = message.emojis
+  output.content = message.content
   if (message.attachment) {
     output.attachments = [parseAttachment(message.attachment)]
   } else {
diff --git a/src/services/favicon_service/favicon_service.js b/src/services/favicon_service/favicon_service.js
index d1ddee41..7e19629d 100644
--- a/src/services/favicon_service/favicon_service.js
+++ b/src/services/favicon_service/favicon_service.js
@@ -1,52 +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.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 {
diff --git a/src/services/html_converter/html_line_converter.service.js b/src/services/html_converter/html_line_converter.service.js
new file mode 100644
index 00000000..5eeaa7cb
--- /dev/null
+++ b/src/services/html_converter/html_line_converter.service.js
@@ -0,0 +1,136 @@
+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.
+ *
+ * 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
+ * @return {(string|{ text: string })[]} processed html in form of a list.
+ */
+export const convertHtmlToLines = (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'
+  ])
+  // 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
+  let textBuffer = '' // Current line content
+  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) {
+      buffer.push({ level: [...level], text: textBuffer })
+    } else {
+      buffer.push(textBuffer)
+    }
+    textBuffer = ''
+  }
+
+  const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing
+    flush()
+    buffer.push(tag)
+  }
+
+  const handleOpen = (tag) => { // handles opening tags
+    flush()
+    buffer.push(tag)
+    level.unshift(getTagName(tag))
+  }
+
+  const handleClose = (tag) => { // handles closing tags
+    if (level[0] === getTagName(tag)) {
+      flush()
+      buffer.push(tag)
+      level.shift()
+    } else { // Broken case
+      textBuffer += tag
+    }
+  }
+
+  for (let i = 0; i < html.length; i++) {
+    const char = html[i]
+    if (char === '<' && tagBuffer === null) {
+      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 (allElements.has(tagName)) {
+        if (linebreakElements.has(tagName)) {
+          handleBr(tagFull)
+        } else if (nonEmptyElements.has(tagName)) {
+          if (tagFull[1] === '/') {
+            handleClose(tagFull)
+          } else if (tagFull[tagFull.length - 2] === '/') {
+            // self-closing
+            handleBr(tagFull)
+          } else {
+            handleOpen(tagFull)
+          }
+        } else {
+          textBuffer += tagFull
+        }
+      } else {
+        textBuffer += tagFull
+      }
+    } else if (char === '\n') {
+      handleBr(char)
+    } else {
+      textBuffer += char
+    }
+  }
+  if (tagBuffer) {
+    textBuffer += tagBuffer
+  }
+
+  flush()
+
+  return buffer
+}
diff --git a/src/services/html_converter/html_tree_converter.service.js b/src/services/html_converter/html_tree_converter.service.js
new file mode 100644
index 00000000..6a8796c4
--- /dev/null
+++ b/src/services/html_converter/html_tree_converter.service.js
@@ -0,0 +1,97 @@
+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
+ * children.
+ *
+ * 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
+ * @return {string} processed 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([
+    '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
+}
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/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',
diff --git a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js b/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js
deleted file mode 100644
index de6f20ef..00000000
--- a/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * 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
- *
- * 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
- */
-export const processHtml = (html, processor) => {
-  const handledTags = new Set(['p', 'br', 'div'])
-  const openCloseTags = new Set(['p', 'div'])
-
-  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
-
-  // 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) {
-      buffer += processor(textBuffer)
-    } else {
-      buffer += textBuffer
-    }
-    textBuffer = ''
-  }
-
-  const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing
-    flush()
-    buffer += tag
-  }
-
-  const handleOpen = (tag) => { // handles opening tags
-    flush()
-    buffer += tag
-    level.push(tag)
-  }
-
-  const handleClose = (tag) => { // handles closing tags
-    flush()
-    buffer += tag
-    if (level[level.length - 1] === tag) {
-      level.pop()
-    }
-  }
-
-  for (let i = 0; i < html.length; i++) {
-    const char = html[i]
-    if (char === '<' && tagBuffer === null) {
-      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 (handledTags.has(tagName)) {
-        if (tagName === 'br') {
-          handleBr(tagFull)
-        } else if (openCloseTags.has(tagName)) {
-          if (tagFull[1] === '/') {
-            handleClose(tagFull)
-          } else if (tagFull[tagFull.length - 2] === '/') {
-            // self-closing
-            handleBr(tagFull)
-          } else {
-            handleOpen(tagFull)
-          }
-        }
-      } else {
-        textBuffer += tagFull
-      }
-    } else if (char === '\n') {
-      handleBr(char)
-    } else {
-      textBuffer += char
-    }
-  }
-  if (tagBuffer) {
-    textBuffer += tagBuffer
-  }
-
-  flush()
-
-  return buffer
-}
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
     }
   }
 }
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..f6c478a9
--- /dev/null
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -0,0 +1,480 @@
+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) => {
+  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 p = (...data) => `<p>${data.join('')}</p>`
+const compwrap = (...data) => `<span class="RichContent">${data.join('')}</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', () => {
+    const html = p('Hello world!')
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        attentions,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    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: {
+        attentions,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(expected))
+  })
+
+  it('replaces mention with mentionsline', () => {
+    const html = p(
+      makeMention('John'),
+      ' how are you doing today?'
+    )
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        attentions,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    expect(wrapper.html()).to.eql(compwrap(p(
+      mentionsLine(1),
+      ' how are you doing today?'
+    )))
+  })
+
+  it('replaces mentions at the end of the hellpost', () => {
+    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: {
+        attentions,
+        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: {
+        attentions,
+        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: {
+        attentions,
+        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: {
+        attentions,
+        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: {
+        attentions,
+        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: {
+        attentions,
+        handleLinks: false,
+        greentext: false,
+        emoji: [],
+        html
+      }
+    })
+
+    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>',
+      mentionsLine(1),
+      '<span class="greentext">&gt;quote</span>',
+      '<span class="greentext">&gt;quote</span>'
+    ].join('\n')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        attentions,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    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',
+      mentionsLine(3),
+      'Bruh'
+    ].join('<br>')
+
+    const wrapper = shallowMount(RichContent, {
+      localVue,
+      propsData: {
+        attentions,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    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>',
+      ' <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('')
+
+    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 are handled properly', () => {
+    attentions.push({ statusnet_profile_url: 'lol' })
+    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">',
+        '<span class="MentionLink mention-link">',
+        '<a href="lol" target="_blank" class="original">',
+        '<span>',
+        'https://</span>',
+        '<span>',
+        'lol.tld/</span>',
+        '<span>',
+        '</span>',
+        '</a>',
+        ' ',
+        '<!---->', // v-if placeholder, mentionlink's "new" (i.e. rich) display
+        '</span>',
+        '<!---->', // v-if placeholder, mentionsline's extra mentions and stuff
+        '</span>'
+      ),
+      p(
+        'Testing'
+      )
+    ].join('')
+
+    const wrapper = mount(RichContent, {
+      localVue,
+      propsData: {
+        attentions,
+        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>',
+      '<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: {
+        attentions,
+        handleLinks: true,
+        greentext: true,
+        emoji: [],
+        html
+      }
+    })
+
+    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))
+  })
+})
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..03fb32c9 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'
 
@@ -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',
@@ -232,22 +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 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:' })
-
-        const parsedPost = parseStatus(post)
-
-        expect(parsedPost).to.have.property('summary_html').that.contains('<img')
-      })
     })
   })
 
@@ -261,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>' }] })
 
@@ -355,41 +309,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"'
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
new file mode 100644
index 00000000..86bd7e8b
--- /dev/null
+++ b/test/unit/specs/services/html_converter/html_line_converter.spec.js
@@ -0,0 +1,171 @@
+import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
+
+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('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/>'
+      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>'
+      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>'
+      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'
+      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='
+      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>'
+      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"/> ?'
+      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]]>'
+      const result = convertHtmlToLines(inputOutput)
+      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) => '_'
+    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/>'
+      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 = '_'
+      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 = '_<div>_<p>'
+      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>_'
+      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 = '_'
+      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>'
+      const result = convertHtmlToLines(input)
+      const comparableResult = result.map(mapOnlyText(processorReplace)).join('')
+      expect(comparableResult).to.eql(output)
+    })
+
+    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)
+      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 = '_'
+      const result = convertHtmlToLines(input)
+      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)
+    })
+  })
+})
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
new file mode 100644
index 00000000..7283021b
--- /dev/null
+++ b/test/unit/specs/services/html_converter/html_tree_converter.spec.js
@@ -0,0 +1,132 @@
+import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
+
+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'
+      expect(convertHtmlToTree(input)).to.eql([
+        '1 ',
+        [
+          '<p>',
+          ['2'],
+          '</p>'
+        ],
+        ' ',
+        [
+          '<b>',
+          [
+            '3',
+            ['<img src="a">'],
+            '4'
+          ],
+          '</b>'
+        ],
+        '5'
+      ])
+    })
+    it('converts html to tree while preserving tag formatting', () => {
+      const input = '1 <p >2</p><b >3<img   src="a">4</b>5'
+      expect(convertHtmlToTree(input)).to.eql([
+        '1 ',
+        [
+          '<p >',
+          ['2'],
+          '</p>'
+        ],
+        [
+          '<b >',
+          [
+            '3',
+            ['<img   src="a">'],
+            '4'
+          ],
+          '</b>'
+        ],
+        '5'
+      ])
+    })
+    it('converts semi-broken html', () => {
+      const input = '1 <br> 2 <p> 42'
+      expect(convertHtmlToTree(input)).to.eql([
+        '1 ',
+        ['<br>'],
+        ' 2 ',
+        [
+          '<p>',
+          [' 42']
+        ]
+      ])
+    })
+    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(convertHtmlToTree(input)).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>'
+        ]
+      ])
+    })
+    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(convertHtmlToTree(inputOutput)).to.eql([
+        'Country improv: give me a city',
+        [
+          '<br/>'
+        ],
+        'Audience: Memphis',
+        [
+          '<br/>'
+        ],
+        'Improv troupe: come on, a better one',
+        [
+          '<br/>'
+        ],
+        'Audience: el paso'
+      ])
+    })
+  })
+})
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)
+    })
+  })
+})
diff --git a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js b/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js
deleted file mode 100644
index f301429d..00000000
--- a/test/unit/specs/services/tiny_post_html_processor/tiny_post_html_processor.spec.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
-
-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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    it('fed with valid XHTML containing a CDATA', () => {
-      const inputOutput = 'Yes, it is me, <![CDATA[DIO]]>'
-      expect(processHtml(inputOutput, processorKeep)).to.eql(inputOutput)
-    })
-  })
-  describe('with processor that replaces lines with word "_" should match expected line when', () => {
-    const processorReplace = (line) => '_'
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-
-    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)
-    })
-  })
-})
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"