diff --git a/README.md b/README.md
index 9be1c3bd..43e177b4 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,22 @@
-# Pleroma-FE 
+# Akkoma-FE 
 
 ![English OK](https://img.shields.io/badge/English-OK-blueviolet) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet)
 
-This is a fork of Pleroma-FE from the Pleroma project, with support for new Akkoma features such as:
+This is a fork of Akkoma-FE from the Pleroma project, with support for new Akkoma features such as:
 - MFM support via [marked-mfm](https://akkoma.dev/sfr/marked-mfm)
 - Custom emoji reactions
 
 # For Translators
 
-The [Weblate UI](https://translate.akkoma.dev/projects/akkoma/pleroma-fe/) is recommended for adding or modifying translations for Pleroma-FE. 
+The [Weblate UI](https://translate.akkoma.dev/projects/akkoma/pleroma-fe/) is recommended for adding or modifying translations for Akkoma-FE. 
 
 Alternatively, edit/create `src/i18n/$LANGUAGE_CODE.json` (where `$LANGUAGE_CODE` is the [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language), then add your language to [src/i18n/messages.js](https://akkoma.dev/AkkomaGang/pleroma-fe/src/branch/develop/src/i18n/messages.js) if it doesn't already exist there.
 
-Pleroma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
+Akkoma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
 
 # FOR ADMINS
 
-To use Pleroma-FE in Akkoma, use the [frontend](https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/) CLI task to install Pleroma-FE, then modify your configuration as described in the [Frontend Management](https://docs.akkoma.dev/stable/configuration/frontend_management/) doc.
+To use Akkoma-FE in Akkoma, use the [frontend](https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/) CLI task to install Akkoma-FE, then modify your configuration as described in the [Frontend Management](https://docs.akkoma.dev/stable/configuration/frontend_management/) doc.
 
 ## Build Setup
 
@@ -52,4 +52,4 @@ Edit config.json for configuration.
 
 ### Login methods
 
-```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations.
+```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations. 
diff --git a/index.html b/index.html
index 79613dd2..fda91b0f 100644
--- a/index.html
+++ b/index.html
@@ -10,6 +10,7 @@
     <link rel="stylesheet" href="/static/font/css/lato.css">
     <link rel="stylesheet" href="/static/mfm.css">
     <link rel="stylesheet" href="/static/custom.css">
+    <link rel="stylesheet" href="/static/theme-holder.css" id="theme-holder">
     <!--server-generated-meta-->
     <link rel="icon" type="image/png" href="/favicon.png">
     <link rel="manifest" href="/manifest.json">
diff --git a/package.json b/package.json
index e712cffd..4bb94fcd 100644
--- a/package.json
+++ b/package.json
@@ -18,19 +18,21 @@
   "dependencies": {
     "@babel/runtime": "7.17.8",
     "@chenfengyuan/vue-qrcode": "2.0.0",
+    "@floatingghost/pinch-zoom-element": "^1.3.1",
     "@fortawesome/fontawesome-svg-core": "1.3.0",
     "@fortawesome/free-regular-svg-icons": "^6.1.2",
     "@fortawesome/free-solid-svg-icons": "^6.2.0",
     "@fortawesome/vue-fontawesome": "3.0.1",
-    "@kazvmoe-infra/pinch-zoom-element": "1.2.0",
     "@vuelidate/core": "^2.0.0",
     "@vuelidate/validators": "^2.0.0",
+    "blurhash": "^2.0.4",
     "body-scroll-lock": "2.7.1",
     "chromatism": "3.0.0",
     "click-outside-vue3": "4.0.1",
     "cropperjs": "1.5.12",
     "diff": "3.5.0",
     "escape-html": "1.0.3",
+    "iso-639-1": "^2.1.15",
     "js-cookie": "^3.0.1",
     "localforage": "1.10.0",
     "parse-link-header": "^2.0.0",
@@ -82,7 +84,6 @@
     "html-webpack-plugin": "^5.5.0",
     "http-proxy-middleware": "0.21.0",
     "inject-loader": "2.0.1",
-    "iso-639-1": "2.1.15",
     "isparta-loader": "2.0.0",
     "json-loader": "0.5.7",
     "karma": "6.3.17",
diff --git a/src/App.scss b/src/App.scss
index 7e6d0dfc..38574cab 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1,6 +1,7 @@
 // stylelint-disable rscss/class-format
 @import './_variables.scss';
-
+@import '@fortawesome/fontawesome-svg-core/styles.css';
+@import '@floatingghost/pinch-zoom-element/dist/pinch-zoom.css';
 :root {
   --navbar-height: 3.5rem;
   --post-line-height: 1.4;
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 4bafca1d..36b087a5 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -4,6 +4,8 @@ import { createRouter, createWebHistory } from 'vue-router'
 import vClickOutside from 'click-outside-vue3'
 
 import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
+import { config } from '@fortawesome/fontawesome-svg-core';
+config.autoAddCss = false
 
 import App from '../App.vue'
 import routes from './routes'
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index 4dcacc7e..3155abf0 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -18,6 +18,7 @@ import {
   faPencilAlt,
   faAlignRight
 } from '@fortawesome/free-solid-svg-icons'
+import Blurhash from '../blurhash/Blurhash.vue'
 
 library.add(
   faFile,
@@ -63,7 +64,8 @@ const Attachment = {
   components: {
     Flash,
     StillImage,
-    VideoAttachment
+    VideoAttachment,
+    Blurhash
   },
   computed: {
     classNames () {
@@ -84,6 +86,9 @@ const Attachment = {
     useContainFit () {
       return this.$store.getters.mergedConfig.useContainFit
     },
+    useBlurhash () {
+      return this.$store.getters.mergedConfig.useBlurhash
+    },
     placeholderName () {
       if (this.attachment.description === '' || !this.attachment.description) {
         return this.type.toUpperCase()
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index 947b1bfc..0ccdb776 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -64,7 +64,15 @@
         :title="attachment.description"
         @click.prevent.stop="toggleHidden"
       >
+        <Blurhash
+          v-if="useBlurhash && attachment.blurhash"
+          :height="512"
+          :width="1024"
+          :hash="attachment.blurhash"
+          :punch="1"
+        />
         <img
+          v-else
           :key="nsfwImage"
           class="nsfw"
           :src="nsfwImage"
diff --git a/src/components/blurhash/Blurhash.vue b/src/components/blurhash/Blurhash.vue
new file mode 100644
index 00000000..c2e7f5b9
--- /dev/null
+++ b/src/components/blurhash/Blurhash.vue
@@ -0,0 +1,66 @@
+<template>
+  <canvas
+    ref="canvas"
+    class="blurhash"
+  />
+</template>
+
+<script>
+import { decode } from "blurhash";
+
+export default {
+  name: 'Blurhash',
+  props: {
+    hash: {
+      type: String,
+      required: true,
+    },
+    width: {
+      type: Number,
+      required: true,
+    },
+    height: {
+      type: Number,
+      required: true,
+    },
+    punch: {
+      type: Number,
+      default: null,
+    },
+  },
+  data() {
+    return {
+      canvas: null,
+      ctx: null,
+    };
+  },
+  mounted() {
+    this.canvas = this.$refs.canvas;
+    this.ctx = this.canvas.getContext('2d');
+    this.canvas.width = 1024;
+    this.canvas.height = 512;
+    this.draw();
+  },
+  methods: {
+    draw() {
+      const pixels = decode(this.hash, this.width, this.height, this.punch);
+      const imageData = this.ctx.createImageData(this.width, this.height);
+      imageData.data.set(pixels);
+      this.ctx.putImageData(imageData, 0, 0);
+      fetch("/static/blurhash-overlay.png")
+        .then((response) => response.blob())
+        .then((blob) => {
+          const img = new Image();
+          img.src = URL.createObjectURL(blob);
+          img.onload = () => {
+            this.ctx.drawImage(img, 0, 0, this.width, this.height);
+          };
+        });
+    },
+  }
+}
+</script>
+
+<style scoped>
+
+</style>
diff --git a/src/components/emoji_grid/emoji_grid.js b/src/components/emoji_grid/emoji_grid.js
new file mode 100644
index 00000000..f73b0913
--- /dev/null
+++ b/src/components/emoji_grid/emoji_grid.js
@@ -0,0 +1,133 @@
+const EMOJI_SIZE = 32 + 8
+const GROUP_TITLE_HEIGHT = 24
+const BUFFER_SIZE = 3 * EMOJI_SIZE
+
+const EmojiGrid = {
+  props: {
+    groups: {
+      required: true,
+      type: Array
+    }
+  },
+  data () {
+    return {
+      containerWidth: 0,
+      containerHeight: 0,
+      scrollPos: 0,
+      resizeObserver: null
+    }
+  },
+  mounted () {
+    const rect = this.$refs.container.getBoundingClientRect()
+    this.containerWidth = rect.width
+    this.containerHeight = rect.height
+    this.resizeObserver = new ResizeObserver((entries) => {
+      for (const entry of entries) {
+        this.containerWidth = entry.contentRect.width
+        this.containerHeight = entry.contentRect.height
+      }
+    })
+    this.resizeObserver.observe(this.$refs.container)
+  },
+  beforeUnmount () {
+    this.resizeObserver.disconnect()
+    this.resizeObserver = null
+  },
+  watch: {
+    groups () {
+      // Scroll to top when grid content changes
+      if (this.$refs.container) {
+        this.$refs.container.scrollTo(0, 0)
+      }
+    },
+    activeGroup (group) {
+      this.$emit('activeGroup', group)
+    }
+  },
+  methods: {
+    onScroll () {
+      this.scrollPos = this.$refs.container.scrollTop
+    },
+    onEmoji (emoji) {
+      this.$emit('emoji', emoji)
+    },
+    scrollToItem (itemId) {
+      const container = this.$refs.container
+      if (!container) return
+
+      for (const item of this.itemList) {
+        if (item.id === itemId) {
+          container.scrollTo(0, item.position.y)
+          return
+        }
+      }
+    }
+  },
+  computed: {
+    // Total height of scroller content
+    gridHeight () {
+      if (this.itemList.length === 0) return 0
+      const lastItem = this.itemList[this.itemList.length - 1]
+      return (
+        lastItem.position.y +
+        ('title' in lastItem ? GROUP_TITLE_HEIGHT : EMOJI_SIZE)
+      )
+    },
+    activeGroup () {
+      const items = this.itemList
+      for (let i = items.length - 1; i >= 0; i--) {
+        const item = items[i]
+        if ('title' in item && item.position.y <= this.scrollPos) {
+          return item.id
+        }
+      }
+      return null
+    },
+    itemList () {
+      const items = []
+      let x = 0
+      let y = 0
+      for (const group of this.groups) {
+        items.push({ position: { x, y }, id: group.id, title: group.text })
+        if (group.text.length) {
+          y += GROUP_TITLE_HEIGHT
+        }
+        for (const emoji of group.emojis) {
+          items.push({
+            position: { x, y },
+            id: `${group.id}-${emoji.displayText}`,
+            emoji
+          })
+          x += EMOJI_SIZE
+          if (x + EMOJI_SIZE > this.containerWidth) {
+            y += EMOJI_SIZE
+            x = 0
+          }
+        }
+        if (x > 0) {
+          y += EMOJI_SIZE
+          x = 0
+        }
+      }
+      return items
+    },
+    visibleItems () {
+      const startPos = this.scrollPos - BUFFER_SIZE
+      const endPos = this.scrollPos + this.containerHeight + BUFFER_SIZE
+      return this.itemList.filter((i) => {
+        return i.position.y >= startPos && i.position.y < endPos
+      })
+    },
+    scrolledClass () {
+      if (this.scrollPos <= 5) {
+        return 'scrolled-top'
+      } else if (this.scrollPos >= this.gridHeight - this.containerHeight - 5) {
+        return 'scrolled-bottom'
+      } else {
+        return 'scrolled-middle'
+      }
+    }
+  }
+}
+
+export default EmojiGrid
diff --git a/src/components/emoji_grid/emoji_grid.scss b/src/components/emoji_grid/emoji_grid.scss
new file mode 100644
index 00000000..5d5b153f
--- /dev/null
+++ b/src/components/emoji_grid/emoji_grid.scss
@@ -0,0 +1,60 @@
+.emoji {
+  &-grid {
+    flex: 1 1 1px;
+    position: relative;
+    overflow: auto;
+    user-select: none;
+    mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
+          linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
+          linear-gradient(to top, white, white);
+    transition: mask-size 150ms;
+    mask-size: 100% 20px, 100% 20px, auto;
+    // Autoprefixed seem to ignore this one, and also syntax is different
+    -webkit-mask-composite: xor;
+    mask-composite: exclude;
+    &.scrolled {
+      &-top {
+        mask-size: 100% 20px, 100% 0, auto;
+      }
+      &-bottom {
+        mask-size: 100% 0, 100% 20px, auto;
+      }
+    }
+    margin-left: 5px;
+    min-height: 200px;
+  }
+
+  &-group-title {
+    position: absolute;
+    font-size: 0.85em;
+    width: 100%;
+    margin: 0;
+    height: 24px;
+    display: flex;
+    align-items: end;
+
+    &.disabled {
+        display: none;
+    }
+  }
+
+  &-item {
+    position: absolute;
+    width: 32px;
+    height: 32px;
+    box-sizing: border-box;
+    display: flex;
+    font-size: 32px;
+    align-items: center;
+    justify-content: center;
+    margin: 4px;
+
+    cursor: pointer;
+
+    img {
+      object-fit: contain;
+      max-width: 100%;
+      max-height: 100%;
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/components/emoji_grid/emoji_grid.vue b/src/components/emoji_grid/emoji_grid.vue
new file mode 100644
index 00000000..94732319
--- /dev/null
+++ b/src/components/emoji_grid/emoji_grid.vue
@@ -0,0 +1,48 @@
+<template>
+  <div
+    ref="container"
+    class="emoji-grid"
+    :class="scrolledClass"
+    @scroll.passive="onScroll"
+  >
+    <div
+      :style="{
+        height: `${gridHeight}px`,
+      }"
+    >
+      <template v-for="item in visibleItems">
+        <h6
+          v-if="'title' in item && item.title.length"
+          :key="'title-' + item.id"
+          class="emoji-group-title"
+          :style="{
+            top: item.position.y + 'px',
+            left: item.position.x + 'px'
+          }"
+        >
+          {{ item.title }}
+        </h6>
+        <span
+          v-else-if="'emoji' in item"
+          :key="'emoji-' + item.id"
+          class="emoji-item"
+          :title="item.emoji.displayText"
+          :style="{
+            top: item.position.y + 'px',
+            left: item.position.x + 'px'
+          }"
+          @click.stop.prevent="onEmoji(item.emoji)"
+        >
+          <span v-if="!item.emoji.imageUrl">{{ item.emoji.replacement }}</span>
+          <img
+            v-else
+            :src="item.emoji.imageUrl"
+          >
+        </span>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script src="./emoji_grid.js"></script>
+<style lang="scss" src="./emoji_grid.scss"></style>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 846274b8..138a5b51 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -205,7 +205,6 @@ const EmojiInput = {
     },
     triggerShowPicker () {
       this.showPicker = true
-      this.$refs.picker.startEmojiLoad()
       this.$nextTick(() => {
         this.scrollIntoView()
         this.focusPickerInput()
@@ -223,7 +222,6 @@ const EmojiInput = {
       this.showPicker = !this.showPicker
       if (this.showPicker) {
         this.scrollIntoView()
-        this.$refs.picker.startEmojiLoad()
         this.$nextTick(this.focusPickerInput)
       }
     },
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index 078253c2..d4760edc 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -18,6 +18,7 @@
       <EmojiPicker
         v-if="enableEmojiPicker"
         ref="picker"
+        show-keep-open
         :class="{ hide: !showPicker }"
         :enable-sticker-picker="enableStickerPicker"
         class="emoji-picker-panel"
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index 6617a937..76934e53 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,5 +1,6 @@
 import { defineAsyncComponent } from 'vue'
 import Checkbox from '../checkbox/checkbox.vue'
+import EmojiGrid from '../emoji_grid/emoji_grid.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faBoxOpen,
@@ -14,19 +15,17 @@ library.add(
   faSmileBeam
 )
 
-// At widest, approximately 20 emoji are visible in a row,
-// loading 3 rows, could be overkill for narrow picker
-const LOAD_EMOJI_BY = 60
-
-// When to start loading new batch emoji, in pixels
-const LOAD_EMOJI_MARGIN = 64
-
 const EmojiPicker = {
   props: {
     enableStickerPicker: {
       required: false,
       type: Boolean,
       default: false
+    },
+    showKeepOpen: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   data () {
@@ -34,16 +33,13 @@ const EmojiPicker = {
       keyword: '',
       activeGroup: 'standard',
       showingStickers: false,
-      groupsScrolledClass: 'scrolled-top',
-      keepOpen: false,
-      customEmojiBufferSlice: LOAD_EMOJI_BY,
-      customEmojiTimeout: null,
-      customEmojiLoadAllConfirmed: false
+      keepOpen: false
     }
   },
   components: {
     StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
-    Checkbox
+    Checkbox,
+    EmojiGrid
   },
   methods: {
     onStickerUploaded (e) {
@@ -56,12 +52,6 @@ const EmojiPicker = {
       const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
       this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
     },
-    onScroll (e) {
-      const target = (e && e.target) || this.$refs['emoji-groups']
-      this.updateScrolledClass(target)
-      this.scrolledGroup(target)
-      this.triggerLoadMore(target)
-    },
     onWheel (e) {
       e.preventDefault()
       this.$refs['emoji-tabs'].scrollBy(e.deltaY, 0)
@@ -69,68 +59,12 @@ const EmojiPicker = {
     highlight (key) {
       this.setShowStickers(false)
       this.activeGroup = key
-    },
-    updateScrolledClass (target) {
-      if (target.scrollTop <= 5) {
-        this.groupsScrolledClass = 'scrolled-top'
-      } else if (target.scrollTop >= target.scrollTopMax - 5) {
-        this.groupsScrolledClass = 'scrolled-bottom'
-      } else {
-        this.groupsScrolledClass = 'scrolled-middle'
+      if (this.keyword.length) {
+        this.$refs.emojiGrid.scrollToItem(key)
       }
     },
-    triggerLoadMore (target) {
-      const ref = this.$refs['group-end-custom']
-      if (!ref) return
-      const bottom = ref.offsetTop + ref.offsetHeight
-
-      const scrollerBottom = target.scrollTop + target.clientHeight
-      const scrollerTop = target.scrollTop
-      const scrollerMax = target.scrollHeight
-
-      // Loads more emoji when they come into view
-      const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
-      // Always load when at the very top in case there's no scroll space yet
-      const atTop = scrollerTop < 5
-      // Don't load when looking at unicode category or at the very bottom
-      const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
-      if (!bottomAboveViewport && (approachingBottom || atTop)) {
-        this.loadEmoji()
-      }
-    },
-    scrolledGroup (target) {
-      const top = target.scrollTop + 5
-      this.$nextTick(() => {
-        this.emojisView.forEach(group => {
-          const ref = this.$refs['group-' + group.id]
-          if (ref.offsetTop <= top) {
-            this.activeGroup = group.id
-          }
-        })
-      })
-    },
-    loadEmoji () {
-      const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
-
-      if (allLoaded) {
-        return
-      }
-
-      this.customEmojiBufferSlice += LOAD_EMOJI_BY
-    },
-    startEmojiLoad (forceUpdate = false) {
-      if (!forceUpdate) {
-        this.keyword = ''
-      }
-      this.$nextTick(() => {
-        this.$refs['emoji-groups'].scrollTop = 0
-      })
-      const bufferSize = this.customEmojiBuffer.length
-      const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
-      if (bufferPrefilledAll && !forceUpdate) {
-        return
-      }
-      this.customEmojiBufferSlice = LOAD_EMOJI_BY
+    onActiveGroup (group) {
+      this.activeGroup = group
     },
     toggleStickers () {
       this.showingStickers = !this.showingStickers
@@ -146,13 +80,6 @@ const EmojiPicker = {
       })
     }
   },
-  watch: {
-    keyword () {
-      this.customEmojiLoadAllConfirmed = false
-      this.onScroll()
-      this.startEmojiLoad(true)
-    }
-  },
   computed: {
     activeGroupView () {
       return this.showingStickers ? '' : this.activeGroup
@@ -168,9 +95,6 @@ const EmojiPicker = {
         this.$store.state.instance.customEmoji || []
       )
     },
-    customEmojiBuffer () {
-      return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
-    },
     emojis () {
       const standardEmojis = this.$store.state.instance.emoji || []
       const customEmojis = this.sortedEmoji
diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss
index ac7b8b5d..6ce8cbd8 100644
--- a/src/components/emoji_picker/emoji_picker.scss
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -1,5 +1,16 @@
 @import '../../_variables.scss';
 
+// The worst query selector ever
+// selects ONLY emojis pickers in replies in notifications
+// who thought this was a good idea?
+.notification > .Status > .status-container > .post-status-form > form > .form-group > .emoji-input > .emoji-picker {
+  max-width: 100%;
+  left: 0;
+  @media (min-width: 1300px) {
+    left: -30px;
+  }
+}
+
 .Notification {
   .emoji-picker {
     min-width: 160%;
@@ -7,7 +18,7 @@
     overflow: hidden;
     left: -70%;
     max-width: 100%;
-    @media (min-width: 800px) and (max-width: 1300px) {
+    @media (min-width: 800px) and (max-width: 1280px) {
       left: -50%;
       min-width: 50%;
       max-width: 130%;
@@ -18,6 +29,10 @@
       min-width: 50%;
       max-width: 130%;
     }
+
+    .Status > .emoji-picker {
+      z-index: 1000;
+    }
   }
 }
 .emoji-picker {
@@ -70,10 +85,6 @@
     flex-grow: 1;
   }
 
-  .emoji-groups {
-    min-height: 200px;
-  }
-
   .additional-tabs {
     border-left: 1px solid;
     border-left-color: $fallback--icon;
@@ -152,76 +163,12 @@
     }
   }
 
-  .emoji {
-    &-search {
-      padding: 5px;
-      flex: 0 0 auto;
+  .emoji-search {
+    padding: 5px;
+    flex: 0 0 auto;
 
-      input {
-        width: 100%;
-      }
+    input {
+      width: 100%;
     }
-
-    &-groups {
-      flex: 1 1 1px;
-      position: relative;
-      overflow: auto;
-      user-select: none;
-      mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
-            linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
-            linear-gradient(to top, white, white);
-      transition: mask-size 150ms;
-      mask-size: 100% 20px, 100% 20px, auto;
-      // Autoprefixed seem to ignore this one, and also syntax is different
-      -webkit-mask-composite: xor;
-      mask-composite: exclude;
-      &.scrolled {
-        &-top {
-          mask-size: 100% 20px, 100% 0, auto;
-        }
-        &-bottom {
-          mask-size: 100% 0, 100% 20px, auto;
-        }
-      }
-    }
-
-    &-group {
-      display: flex;
-      align-items: center;
-      flex-wrap: wrap;
-      padding-left: 5px;
-      justify-content: left;
-
-      &-title {
-        font-size: 0.85em;
-        width: 100%;
-        margin: 0;
-
-        &.disabled {
-          display: none;
-        }
-      }
-    }
-
-    &-item {
-      width: 32px;
-      height: 32px;
-      box-sizing: border-box;
-      display: flex;
-      font-size: 32px;
-      align-items: center;
-      justify-content: center;
-      margin: 4px;
-
-      cursor: pointer;
-
-      img {
-        object-fit: contain;
-        max-width: 100%;
-        max-height: 100%;
-      }
-    }
-
   }
-
 }
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
index 00ffb9d2..fe2e39b2 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -2,9 +2,9 @@
   <div class="emoji-picker panel panel-default panel-body">
     <div class="heading">
       <span
+        ref="emoji-tabs"
         class="emoji-tabs"
         @wheel="onWheel"
-        ref="emoji-tabs"
       >
         <span
           v-for="group in emojis"
@@ -51,40 +51,16 @@
             @input="$event.target.composing = false"
           >
         </div>
+        <EmojiGrid
+          ref="emojiGrid"
+          :groups="emojisView"
+          @emoji="onEmoji"
+          @active-group="onActiveGroup"
+        />
         <div
-          ref="emoji-groups"
-          class="emoji-groups"
-          :class="groupsScrolledClass"
-          @scroll="onScroll"
+          v-if="showKeepOpen"
+          class="keep-open"
         >
-          <div
-            v-for="group in emojisView"
-            :key="group.id"
-            class="emoji-group"
-          >
-            <h6
-              :ref="'group-' + group.id"
-              class="emoji-group-title"
-            >
-              {{ group.text }}
-            </h6>
-            <span
-              v-for="emoji in group.emojis"
-              :key="group.id + emoji.displayText"
-              :title="emoji.displayText"
-              class="emoji-item"
-              @click.stop.prevent="onEmoji(emoji)"
-            >
-              <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
-              <img
-                v-else
-                :src="emoji.imageUrl"
-              >
-            </span>
-            <span :ref="'group-end-' + group.id" />
-          </div>
-        </div>
-        <div class="keep-open">
           <Checkbox v-model="keepOpen">
             {{ $t('emoji.keep_open') }}
           </Checkbox>
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index d9c568f6..3fe81f77 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -19,6 +19,7 @@
             :title="reaction.name"
             class="reaction-emoji"
             width="2.55em"
+            height="2.55em"
           >
           {{ reaction.count }}
         </span>
@@ -65,6 +66,7 @@
   box-sizing: border-box;
   .reaction-emoji {
     width: 2.55em !important;
+    height: 2.55em !important;
     margin-right: 0.25em;
   }
   &:focus {
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index 4bc6144c..5eb98264 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -144,6 +144,7 @@ const ExtraButtons = {
           statusPoll: this.status.poll,
           statusFiles: [...this.status.attachments],
           statusScope: this.status.visibility,
+          statusLanguage: this.status.language,
           statusContentType: data.content_type
         }))
       this.doDeleteStatus()
diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js
index b0873bb1..47c86e15 100644
--- a/src/components/follow_request_card/follow_request_card.js
+++ b/src/components/follow_request_card/follow_request_card.js
@@ -43,6 +43,7 @@ const FollowRequestCard = {
     doApprove () {
       this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
       this.$store.dispatch('removeFollowRequest', this.user)
+      this.$store.dispatch('decrementFollowRequestsCount')
 
       const notifId = this.findFollowRequestNotificationId()
       this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
@@ -66,6 +67,7 @@ const FollowRequestCard = {
       this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
         .then(() => {
           this.$store.dispatch('dismissNotificationLocal', { id: notifId })
+          this.$store.dispatch('decrementFollowRequestsCount')
           this.$store.dispatch('removeFollowRequest', this.user)
         })
       this.hideDenyConfirmDialog()
@@ -80,6 +82,11 @@ const FollowRequestCard = {
     },
     shouldConfirmDeny () {
       return this.mergedConfig.modalOnDenyFollow
+    },
+    show () {
+      const notifId = this.$store.state.api.followRequests.find(req => req.id === this.user.id)
+
+      return notifId !== undefined
     }
   }
 }
diff --git a/src/components/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue
index 835471e7..80445021 100644
--- a/src/components/follow_request_card/follow_request_card.vue
+++ b/src/components/follow_request_card/follow_request_card.vue
@@ -1,5 +1,5 @@
 <template>
-  <basic-user-card :user="user">
+  <basic-user-card :user="user" v-if="show">
     <div class="follow-request-card-content-container">
       <button
         class="btn button-default"
diff --git a/src/components/follow_requests/follow_requests.js b/src/components/follow_requests/follow_requests.js
index 704a76c6..e5f05643 100644
--- a/src/components/follow_requests/follow_requests.js
+++ b/src/components/follow_requests/follow_requests.js
@@ -1,10 +1,26 @@
 import FollowRequestCard from '../follow_request_card/follow_request_card.vue'
+import withLoadMore from '../../hocs/with_load_more/with_load_more'
+import List from '../list/list.vue'
+import get from 'lodash/get'
+
+const FollowRequestList = withLoadMore({
+  fetch: (props, $store) => $store.dispatch('fetchFollowRequests'),
+  select: (props, $store) => get($store.state.api, 'followRequests', []).map(req => $store.getters.findUser(req.id)),
+  destroy: (props, $store) => $store.dispatch('clearFollowRequests'),
+  childPropName: 'items',
+  additionalPropNames: ['userId']
+})(List);
+
 
 const FollowRequests = {
   components: {
-    FollowRequestCard
+    FollowRequestCard,
+    FollowRequestList
   },
   computed: {
+    userId () {
+      return this.$store.state.users.currentUser.id
+    },
     requests () {
       return this.$store.state.api.followRequests
     }
diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue
index 41f19db8..c3098292 100644
--- a/src/components/follow_requests/follow_requests.vue
+++ b/src/components/follow_requests/follow_requests.vue
@@ -6,12 +6,11 @@
       </div>
     </div>
     <div class="panel-body">
-      <FollowRequestCard
-        v-for="request in requests"
-        :key="request.id"
-        :user="request"
-        class="list-item"
-      />
+      <FollowRequestList :user-id="userId">
+        <template #item="{item}">
+          <FollowRequestCard :user="item" />
+        </template>
+      </FollowRequestList>
     </div>
   </div>
 </template>
diff --git a/src/components/followed_tag_card/FollowedTagCard.vue b/src/components/followed_tag_card/FollowedTagCard.vue
new file mode 100644
index 00000000..d9394ddc
--- /dev/null
+++ b/src/components/followed_tag_card/FollowedTagCard.vue
@@ -0,0 +1,77 @@
+<template>
+  <div class="followed-tag-card">
+    <span>
+      <router-link :to="{ name: 'tag-timeline', params: {tag: tag.name}}">
+        <span class="tag-link">#{{ tag.name }}</span>
+      </router-link>
+      <span class="unfollow-tag">
+        <button
+          v-if="isFollowing"
+          class="button-default unfollow-tag-button"
+          :title="$t('user_card.unfollow_tag')"
+          @click="unfollowTag(tag.name)"
+        >
+          {{ $t('user_card.unfollow_tag') }}
+        </button>
+        <button
+          v-else
+          class="button-default follow-tag-button"
+          :title="$t('user_card.follow_tag')"
+          @click="followTag(tag.name)"
+        >
+          {{ $t('user_card.follow_tag') }}
+        </button>
+      </span>
+    </span>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'FollowedTagCard',
+  props: {
+    tag: {
+      type: Object,
+      required: true
+    },
+  },
+  // this is a hack to update the state of the button
+  // for some reason, List does not update on changes to the tag object
+  data: () => ({
+    isFollowing: true
+  }),
+  mounted () {
+    this.isFollowing = this.tag.following
+  },
+  methods: {
+    unfollowTag (tag) {
+      this.$store.dispatch('unfollowTag', tag)
+      this.isFollowing = false
+    },
+    followTag (tag) {
+      this.$store.dispatch('followTag', tag)
+      this.isFollowing = true
+    }
+  }
+}
+</script>
+
+<style scoped>
+  .followed-tag-card {
+    margin-left: 1rem;
+    margin-top: 1rem;
+    margin-bottom: 1rem;
+  }
+  .unfollow-tag {
+    position: absolute;
+    right: 1rem;
+  }
+
+  .tag-link {
+    font-size: large;
+  }
+
+  .unfollow-tag-button, .follow-tag-button {
+    font-size: medium;
+  }
+</style>
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index af165d47..2eda912e 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -33,11 +33,6 @@ library.add(
 )
 
 const NavPanel = {
-  created () {
-    if (this.currentUser && this.currentUser.locked) {
-      this.$store.dispatch('startFetchingFollowRequests')
-    }
-  },
   components: {
     TimelineMenuContent
   },
@@ -54,11 +49,13 @@ const NavPanel = {
   computed: {
     ...mapState({
       currentUser: state => state.users.currentUser,
-      followRequestCount: state => state.api.followRequests.length,
       privateMode: state => state.instance.private,
       federating: state => state.instance.federating
     }),
-    ...mapGetters(['unreadAnnouncementCount'])
+    ...mapGetters(['unreadAnnouncementCount']),
+    followRequestCount () {
+      return this.$store.state.users.currentUser.follow_requests_count
+    }
   }
 }
 
diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js
index 82670ddf..b7e8f673 100644
--- a/src/components/pinch_zoom/pinch_zoom.js
+++ b/src/components/pinch_zoom/pinch_zoom.js
@@ -1,4 +1,4 @@
-import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
+import PinchZoom from '@floatingghost/pinch-zoom-element'
 
 export default {
   methods: {
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index c9f492c3..b7c66fc7 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -13,6 +13,7 @@ import suggestor from '../emoji_input/suggestor.js'
 import { mapGetters, mapState } from 'vuex'
 import Checkbox from '../checkbox/checkbox.vue'
 import Select from '../select/select.vue'
+import iso6391 from 'iso-639-1'
 
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -63,6 +64,7 @@ const PostStatusForm = {
     'statusMediaDescriptions',
     'statusScope',
     'statusContentType',
+    'statusLanguage',
     'replyTo',
     'quoteId',
     'repliedUser',
@@ -128,7 +130,7 @@ const PostStatusForm = {
       statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
     }
 
-    const { postContentType: contentType, sensitiveByDefault, sensitiveIfSubject } = this.$store.getters.mergedConfig
+    const { postContentType: contentType, sensitiveByDefault, sensitiveIfSubject, interfaceLanguage } = this.$store.getters.mergedConfig
 
     let statusParams = {
       spoilerText: this.subject || '',
@@ -139,6 +141,7 @@ const PostStatusForm = {
       poll: {},
       mediaDescriptions: {},
       visibility: this.suggestedVisibility(),
+      language: interfaceLanguage,
       contentType
     }
 
@@ -153,6 +156,7 @@ const PostStatusForm = {
         poll: this.statusPoll || {},
         mediaDescriptions: this.statusMediaDescriptions || {},
         visibility: this.statusScope || this.suggestedVisibility(),
+        language: this.statusLanguage || interfaceLanguage,
         contentType: statusContentType
       }
     }
@@ -259,7 +263,10 @@ const PostStatusForm = {
     ...mapGetters(['mergedConfig']),
     ...mapState({
       mobileLayout: state => state.interface.mobileLayout
-    })
+    }),
+    isoLanguages () {
+      return iso6391.getAllCodes();
+    }
   },
   watch: {
     'newStatus': {
@@ -282,6 +289,7 @@ const PostStatusForm = {
         files: [],
         visibility: newStatus.visibility,
         contentType: newStatus.contentType,
+        language: newStatus.language,
         poll: {},
         mediaDescriptions: {}
       }
@@ -341,6 +349,7 @@ const PostStatusForm = {
         inReplyToStatusId: this.replyTo,
         quoteId: this.quoteId,
         contentType: newStatus.contentType,
+        language: newStatus.language,
         poll,
         idempotencyKey: this.idempotencyKey
       }
@@ -375,6 +384,7 @@ const PostStatusForm = {
         inReplyToStatusId: this.replyTo,
         quoteId: this.quoteId,
         contentType: newStatus.contentType,
+        language: newStatus.language,
         poll: {},
         preview: true
       }).then((data) => {
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index b6516585..02468f17 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -194,6 +194,23 @@
             :on-scope-change="changeVis"
           />
 
+          <div
+            class="language-selector"
+            >
+            <Select
+              id="post-language"
+              v-model="newStatus.language"
+              class="form-control"
+            >
+              <option
+                v-for="language in isoLanguages"
+                :key="language"
+                :value="language"
+              >
+                {{ language }}
+              </option>
+            </Select>
+          </div>
           <div
             v-if="postFormats.length > 1"
             class="text-format"
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 2fa20742..5d135711 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -407,6 +407,15 @@
               {{ $t('settings.preload_images') }}
             </BooleanSetting>
           </li>
+          <li>
+            <BooleanSetting
+              path="useBlurhash"
+              expert="1"
+              :disabled="!hideNsfw"
+            >
+              {{ $t('settings.use_blurhash') }}
+            </BooleanSetting>
+          </li>
           <li>
             <BooleanSetting
               path="useOneClickNsfw"
diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss
index 07ab7bec..0a5e744e 100644
--- a/src/components/user_card/user_card.scss
+++ b/src/components/user_card/user_card.scss
@@ -2,7 +2,7 @@
 
 .user-card {
   position: relative;
-  z-index: 1;
+  z-index: 10;
 
   &:hover {
     --_still-image-img-visibility: visible;
@@ -235,7 +235,7 @@
     line-height: 22px;
     flex-wrap: wrap;
 
-    .following, .requested_by {
+    .following, .requested_by, .blocking {
       flex: 1 0 auto;
       margin: 0;
       margin-bottom: .25em;
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 2eefbad8..289db15b 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -127,6 +127,12 @@
           </div>
         </div>
         <div class="user-meta">
+          <div
+            v-if="relationship.blocked_by && loggedIn && isOtherUser"
+            class="blocking"
+          >
+            {{ $t('user_card.blocks_you') }}
+          </div>
           <div
             v-if="relationship.followed_by && loggedIn && isOtherUser"
             class="following"
@@ -187,6 +193,7 @@
             <FollowButton
               :relationship="relationship"
               :user="user"
+              :disabled="relationship.blocked_by"
             />
             <template v-if="relationship.following">
               <ProgressButton
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 42c5ec9e..9ea8c2a7 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -10,11 +10,14 @@ import withLoadMore from '../../hocs/with_load_more/with_load_more'
 import { debounce } from 'lodash'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
-  faCircleNotch
+  faCircleNotch,
+  faCircleCheck
 } from '@fortawesome/free-solid-svg-icons'
+import FollowedTagCard from '../followed_tag_card/FollowedTagCard.vue'
 
 library.add(
-  faCircleNotch
+  faCircleNotch,
+  faCircleCheck
 )
 
 const FollowerList = withLoadMore({
@@ -33,6 +36,14 @@ const FriendList = withLoadMore({
   additionalPropNames: ['userId']
 })(List)
 
+const FollowedTagList = withLoadMore({
+  fetch: (props, $store) => $store.dispatch('fetchFollowedTags', props.userId),
+  select: (props, $store) => get($store.getters.findUser(props.userId), 'followedTagIds', []).map(id => $store.getters.findTag(id)),
+  destroy: (props, $store) => $store.dispatch('clearFollowedTags', props.userId),
+  childPropName: 'items',
+  additionalPropNames: ['userId']
+})(List)
+
 const isUserPage = ({ name }) => name === 'user-profile' || name === 'external-user-profile'
 
 const UserProfile = {
@@ -41,6 +52,7 @@ const UserProfile = {
       error: false,
       userId: null,
       tab: 'statuses',
+      followsTab: 'users',
       footerRef: null,
       note: null,
       noteLoading: false
@@ -165,6 +177,9 @@ const UserProfile = {
       this.tab = tab
       this.$router.replace({ hash: `#${tab}` })
     },
+    onFollowsTabSwitch (tab) {
+      this.followsTab = tab
+    },
     linkClicked ({ target }) {
       if (target.tagName === 'SPAN') {
         target = target.parentNode
@@ -200,6 +215,7 @@ const UserProfile = {
     }
   },
   components: {
+    FollowedTagCard,
     UserCard,
     Timeline,
     FollowerList,
@@ -207,7 +223,8 @@ const UserProfile = {
     FollowCard,
     TabSwitcher,
     Conversation,
-    RichContent
+    RichContent,
+    FollowedTagList
   }
 }
 
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index d16483e2..5465778a 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -37,6 +37,15 @@
               :html="field.value"
               :emoji="user.emoji"
             />
+            <span
+              v-if="field.verified_at"
+              class="user-profile-field-validated"
+            >
+              <FAIcon
+                icon="check-circle"
+                :title="$t('user_profile.field_validated')"
+              />
+            </span>
           </dd>
         </dl>
       </div>
@@ -95,22 +104,48 @@
           v-if="followsTabVisible"
           key="followees"
           :label="$t('user_card.followees')"
-          :disabled="!user.friends_count"
         >
-          <FriendList :user-id="userId">
-            <template v-slot:item="{item}">
-              <FollowCard :user="item" />
-            </template>
-          </FriendList>
+          <tab-switcher
+            :active-tab="followsTab"
+            :render-only-focused="true"
+            :on-switch="onFollowsTabSwitch"
+          >
+            <div
+              key="users"
+              :label="$t('user_card.followed_users')"
+            >
+              <FriendList :user-id="userId">
+                <template #item="{item}">
+                  <FollowCard :user="item" />
+                </template>
+              </FriendList>
+            </div>
+            <div
+              key="tags"
+              v-if="isUs"
+              :label="$t('user_card.followed_tags')"
+            >
+              <FollowedTagList
+                :user-id="userId"
+                :get-key="(item) => item.name"
+              >
+                <template #item="{item}">
+                  <FollowedTagCard :tag="item" />
+                </template>
+                <template #empty>
+                  {{ $t('user_card.not_following_any_hashtags')}}
+                </template>
+              </FollowedTagList>
+            </div>
+          </tab-switcher>
         </div>
         <div
           v-if="followersTabVisible"
           key="followers"
           :label="$t('user_card.followers')"
-          :disabled="!user.followers_count"
         >
           <FollowerList :user-id="userId">
-            <template v-slot:item="{item}">
+            <template #item="{item}">
               <FollowCard
                 :user="item"
                 :no-follows-you="isUs"
@@ -225,6 +260,11 @@
         padding: 0.5em 1.5em;
         box-sizing: border-box;
       }
+
+      .user-profile-field-validated {
+        margin-left: 1rem;
+        color: green;
+      }
     }
   }
 
diff --git a/src/hocs/with_load_more/with_load_more.jsx b/src/hocs/with_load_more/with_load_more.jsx
index c0ae1856..c55eccf5 100644
--- a/src/hocs/with_load_more/with_load_more.jsx
+++ b/src/hocs/with_load_more/with_load_more.jsx
@@ -59,7 +59,8 @@ const withLoadMore = ({
               this.loading = false
               this.bottomedOut = isEmpty(newEntries)
             })
-            .catch(() => {
+            .catch((e) => {
+              console.error(e)
               this.loading = false
               this.error = true
             })
@@ -88,7 +89,7 @@ const withLoadMore = ({
       const children = this.$slots
       return (
         <div class="with-load-more">
-          <WrappedComponent {...props}>
+          <WrappedComponent {...props} >
             {children}
           </WrappedComponent>
           <div class="with-load-more-footer">
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 32785561..46890345 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -939,6 +939,7 @@
             "title": "Version"
         },
         "virtual_scrolling": "Optimize timeline rendering",
+        "use_blurhash": "Use blurhashes for NSFW thumbnails",
         "word_filter": "Word filter",
         "wordfilter": "Wordfilter"
     },
@@ -1056,6 +1057,7 @@
         "show_new": "Show new",
         "socket_broke": "Realtime connection lost: CloseEvent code {0}",
         "socket_reconnected": "Realtime connection established",
+        "follow_tag": "Follow hashtag",
         "unfollow_tag": "Unfollow hashtag",
         "up_to_date": "Up-to-date"
     },
@@ -1121,6 +1123,7 @@
         "block_confirm_title": "Block user",
         "block_progress": "Blocking…",
         "blocked": "Blocked!",
+        "blocks_you": "Blocks you!",
         "bot": "Bot",
         "deactivated": "Deactivated",
         "deny": "Deny",
@@ -1138,6 +1141,8 @@
         "follow_unfollow": "Unfollow",
         "followees": "Following",
         "followers": "Followers",
+        "followed_tags": "Followed hashtags",
+        "followed_users": "Followed users",
         "following": "Following!",
         "follows_you": "Follows you!",
         "hidden": "Hidden",
@@ -1176,6 +1181,9 @@
         "unfollow_confirm_accept_button": "Yes, unfollow",
         "unfollow_confirm_cancel_button": "No, don't unfollow",
         "unfollow_confirm_title": "Unfollow user",
+        "not_following_any_hashtags": "You are not following any hashtags",
+        "follow_tag": "Follow hashtag",
+        "unfollow_tag": "Unfollow hashtag",
         "unmute": "Unmute",
         "unmute_progress": "Unmuting…",
         "unsubscribe": "Unsubscribe"
@@ -1183,7 +1191,8 @@
     "user_profile": {
         "profile_does_not_exist": "Sorry, this profile does not exist.",
         "profile_loading_error": "Sorry, there was an error loading this profile.",
-        "timeline_title": "User timeline"
+        "timeline_title": "User timeline",
+        "field_validated": "Link Verified"
     },
     "user_reporting": {
         "add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
diff --git a/src/modules/api.js b/src/modules/api.js
index c54aa4fb..8de1449b 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,5 +1,6 @@
 import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
 import { WSConnectionStatus } from '../services/api/api.service.js'
+import { map } from 'lodash'
 
 const retryTimeout = (multiplier) => 1000 * multiplier
 
@@ -40,9 +41,6 @@ const api = {
     setSocket (state, socket) {
       state.socket = socket
     },
-    setFollowRequests (state, value) {
-      state.followRequests = value
-    },
     setMastoUserSocketStatus (state, value) {
       state.mastoUserSocketStatus = value
     },
@@ -51,6 +49,15 @@ const api = {
     },
     resetRetryMultiplier (state) {
       state.retryMultiplier = 1
+    },
+    setFollowRequests (state, value) {
+      state.followRequests = [...value]
+    },
+    saveFollowRequests (state, requests) {
+      state.followRequests = [...state.followRequests, ...requests]
+    },
+    saveFollowRequestPagination (state, pagination) {
+      state.followRequestsPagination = pagination
     }
   },
   actions: {
@@ -240,24 +247,22 @@ const api = {
         ...rest
       })
     },
-
-    // Follow requests
-    startFetchingFollowRequests (store) {
-      if (store.state.fetchers['followRequests']) return
-      const fetcher = store.state.backendInteractor.startFetchingFollowRequests({ store })
-
-      store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
-    },
-    stopFetchingFollowRequests (store) {
-      const fetcher = store.state.fetchers.followRequests
-      if (!fetcher) return
-      store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
-    },
     removeFollowRequest (store, request) {
-      let requests = store.state.followRequests.filter((it) => it !== request)
+      let requests = [...store.state.followRequests].filter((it) => it.id !== request.id)
       store.commit('setFollowRequests', requests)
     },
-
+    fetchFollowRequests ({ rootState, commit }) {
+      const pagination = rootState.api.followRequestsPagination
+      return rootState.api.backendInteractor.getFollowRequests({ pagination })
+        .then((requests) => {
+          if (requests.data.length > 0) {
+            commit('addNewUsers', requests.data)
+            commit('saveFollowRequests', requests.data)
+            commit('saveFollowRequestPagination', requests.pagination)
+          }
+          return requests
+        })
+    },
     // Lists
     startFetchingLists (store) {
       if (store.state.fetchers['lists']) return
diff --git a/src/modules/config.js b/src/modules/config.js
index 854dae50..ebb27929 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -117,7 +117,8 @@ export const defaultState = {
   maxDepthInThread: undefined, // instance default
   translationLanguage: undefined, // instance default,
   supportedTranslationLanguages: {}, // instance default
-  userProfileDefaultTab: 'statuses'
+  userProfileDefaultTab: 'statuses',
+  useBlurhash: true,
 }
 
 // caching the instance default properties
diff --git a/src/modules/instance.js b/src/modules/instance.js
index c8c718d0..02cbe1f8 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -178,7 +178,7 @@ const instance = {
 
     async getCustomEmoji ({ commit, state }) {
       try {
-        const res = await window.fetch('/api/pleroma/emoji.json')
+        const res = await window.fetch('/api/v1/pleroma/emoji')
         if (res.ok) {
           const result = await res.json()
           const values = Array.isArray(result) ? Object.assign({}, ...result) : result
diff --git a/src/modules/interface.js b/src/modules/interface.js
index ae1a31c3..33528c0d 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -186,7 +186,7 @@ const interfaceMod = {
       if (thirdColumnMode === 'none' || !rootState.users.currentUser) {
         commit('setLayoutType', normalOrMobile)
       } else {
-        const wideLayout = width >= 1300
+        const wideLayout = width >= 1280
         commit('setLayoutType', wideLayout ? 'wide' : normalOrMobile)
       }
     },
diff --git a/src/modules/tags.js b/src/modules/tags.js
index cff54b7d..63f6888d 100644
--- a/src/modules/tags.js
+++ b/src/modules/tags.js
@@ -2,9 +2,15 @@ import { merge } from 'lodash'
 
 const tags = {
   state: {
-    // Contains key = id, value = number of trackers for this poll
+    // Contains key = name, value = tag json
     tags: {}
   },
+  getters: {
+    findTag: state => query => {
+      const result = state.tags[query]
+      return result
+    },
+  },
   mutations: {
     setTag (state, { name, data }) {
       state.tags[name] = data
@@ -17,17 +23,17 @@ const tags = {
         return tag
       })
     },
-    followTag (store, tagName) {
-      return store.rootState.api.backendInteractor.followHashtag({ tag: tagName })
+    followTag ({ rootState, commit }, tagName) {
+      return rootState.api.backendInteractor.followHashtag({ tag: tagName })
         .then((resp) => {
-          store.commit('setTag', { name: tagName, data: resp })
+          commit('setTag', { name: tagName, data: resp })
           return resp
         })
     },
-    unfollowTag ({ rootState, commit }, tag) {
-      return rootState.api.backendInteractor.unfollowHashtag({ tag })
+    unfollowTag ({ rootState, commit }, tagName) {
+      return rootState.api.backendInteractor.unfollowHashtag({ tag: tagName })
         .then((resp) => {
-          commit('setTag', { name: tag, data: resp })
+          commit('setTag', { name: tagName, data: resp })
           return resp
         })
     }
diff --git a/src/modules/users.js b/src/modules/users.js
index 022cc1dc..bc1943c8 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -5,9 +5,9 @@ import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'loda
 import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
 
 // TODO: Unify with mergeOrAdd in statuses.js
-export const mergeOrAdd = (arr, obj, item) => {
+export const mergeOrAdd = (arr, obj, item, key = 'id') => {
   if (!item) { return false }
-  const oldItem = obj[item.id]
+  const oldItem = obj[item[key]]
   if (oldItem) {
     // We already have this, so only merge the new info.
     mergeWith(oldItem, item, mergeArrayLength)
@@ -15,7 +15,7 @@ export const mergeOrAdd = (arr, obj, item) => {
   } else {
     // This is a new item, prepare it
     arr.push(item)
-    obj[item.id] = item
+    obj[item[key]] = item
     if (item.screen_name && !item.screen_name.includes('@')) {
       obj[item.screen_name.toLowerCase()] = item
     }
@@ -157,6 +157,14 @@ export const mutations = {
     const user = state.usersObject[id]
     user.followerIds = uniq(concat(user.followerIds || [], followerIds))
   },
+  saveFollowedTagIds (state, { id, followedTagIds }) {
+    const user = state.usersObject[id]
+    user.followedTagIds = uniq(concat(user.followedTagIds || [], followedTagIds))
+  },
+  saveFollowedTagPagination (state, { id, pagination }) {
+    const user = state.usersObject[id]
+    user.followedTagPagination = pagination
+  },
   // Because frontend doesn't have a reason to keep these stuff in memory
   // outside of viewing someones user profile.
   clearFriends (state, userId) {
@@ -171,6 +179,12 @@ export const mutations = {
       user['followerIds'] = []
     }
   },
+  clearFollowedTags (state, userId) {
+    const user = state.usersObject[userId]
+    if (user) {
+      user['followedTagIds'] = []
+    }
+  },
   addNewUsers (state, users) {
     each(users, (user) => {
       if (user.relationship) {
@@ -251,6 +265,12 @@ export const mutations = {
   signUpFailure (state, errors) {
     state.signUpPending = false
     state.signUpErrors = errors
+  },
+  decrementFollowRequestsCount (store) {
+    store.currentUser.follow_requests_count--
+  },
+  incrementFollowRequestsCount (store) {
+    store.currentUser.follow_requests_count++
   }
 }
 
@@ -271,7 +291,7 @@ export const getters = {
   relationship: state => id => {
     const rel = id && state.relationships[id]
     return rel || { id, loading: true }
-  }
+  },
 }
 
 export const defaultState = {
@@ -282,7 +302,9 @@ export const defaultState = {
   usersObject: {},
   signUpPending: false,
   signUpErrors: [],
-  relationships: {}
+  relationships: {},
+  tags: [],
+  tagsObject: {}
 }
 
 const users = {
@@ -402,12 +424,27 @@ const users = {
           return followers
         })
     },
+    fetchFollowedTags ({ rootState, commit }, id) {
+      const user = rootState.users.usersObject[id]
+      const pagination = user.followedTagPagination
+
+      return rootState.api.backendInteractor.getFollowedHashtags({ pagination })
+        .then(({ data: tags, pagination }) => {
+          each(tags, tag => commit('setTag', { name: tag.name, data: tag }))
+          commit('saveFollowedTagIds', { id, followedTagIds: tags.map(tag => tag.name) })
+          commit('saveFollowedTagPagination', { id, pagination })
+          return tags
+        })
+    },
     clearFriends ({ commit }, userId) {
       commit('clearFriends', userId)
     },
     clearFollowers ({ commit }, userId) {
       commit('clearFollowers', userId)
     },
+    clearFollowedTags ({ commit }, userId) {
+      commit('clearFollowedTags', userId)
+    },
     subscribeUser ({ rootState, commit }, id) {
       return rootState.api.backendInteractor.subscribeUser({ id })
         .then((relationship) => commit('updateUserRelationship', [relationship]))
@@ -473,6 +510,12 @@ const users = {
         store.commit('setUserForNotification', notification)
       })
     },
+    decrementFollowRequestsCount (store) {
+      store.commit('decrementFollowRequestsCount')
+    },
+    incrementFollowRequestsCount (store) {
+      store.commit('incrementFollowRequestsCount')
+    },
     searchUsers ({ rootState, commit }, { query }) {
       return rootState.api.backendInteractor.searchUsers({ query })
         .then((users) => {
@@ -536,7 +579,6 @@ const users = {
           store.dispatch('stopFetchingTimeline', 'friends')
           store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
           store.dispatch('stopFetchingNotifications')
-          store.dispatch('stopFetchingFollowRequests')
           store.dispatch('stopFetchingConfig')
           store.commit('clearNotifications')
           store.commit('resetStatuses')
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 9e6f39f2..947e9da9 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,6 +1,7 @@
 import { each, map, concat, last, get } from 'lodash'
 import { parseStatus, parseSource, parseUser, parseNotification, parseReport, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
 import { RegistrationError, StatusCodeError } from '../errors/errors'
+import { Url } from 'url'
 
 /* eslint-env browser */
 const MUTES_IMPORT_URL = '/api/pleroma/mutes_import'
@@ -11,11 +12,11 @@ const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
 const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
 const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
 const ALIASES_URL = '/api/pleroma/aliases'
-const TAG_USER_URL = '/api/pleroma/admin/users/tag'
-const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}`
-const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate'
-const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate'
-const ADMIN_USERS_URL = '/api/pleroma/admin/users'
+const TAG_USER_URL = '/api/v1/pleroma/admin/users/tag'
+const PERMISSION_GROUP_URL = (screenName, right) => `/api/v1/pleroma/admin/users/${screenName}/permission_group/${right}`
+const ACTIVATE_USER_URL = '/api/v1/pleroma/admin/users/activate'
+const DEACTIVATE_USER_URL = '/api/v1/pleroma/admin/users/deactivate'
+const ADMIN_USERS_URL = '/api/v1/pleroma/admin/users'
 const SUGGESTIONS_URL = '/api/v1/suggestions'
 const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
 const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
@@ -111,6 +112,7 @@ const AKKOMA_SETTING_PROFILE_LIST = `/api/v1/akkoma/frontend_settings/pleroma-fe
 const MASTODON_TAG_URL = (name) => `/api/v1/tags/${name}`
 const MASTODON_FOLLOW_TAG_URL = (name) => `/api/v1/tags/${name}/follow`
 const MASTODON_UNFOLLOW_TAG_URL = (name) => `/api/v1/tags/${name}/unfollow`
+const MASTODON_FOLLOWED_TAGS_URL = '/api/v1/followed_tags'
 
 const oldfetch = window.fetch
 
@@ -246,7 +248,7 @@ const register = ({ params, credentials }) => {
     })
 }
 
-const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json())
+const getCaptcha = () => fetch('/api/v1/pleroma/captcha').then(resp => resp.json())
 
 const authHeaders = (accessToken) => {
   if (accessToken) {
@@ -404,14 +406,6 @@ const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
     .then((data) => data.json())
     .then((data) => data.map(parseUser))
 }
-
-const fetchFollowRequests = ({ credentials }) => {
-  const url = MASTODON_FOLLOW_REQUESTS_URL
-  return fetch(url, { headers: authHeaders(credentials) })
-    .then((data) => data.json())
-    .then((data) => data.map(parseUser))
-}
-
 const fetchLists = ({ credentials }) => {
   const url = MASTODON_LISTS_URL
   return fetch(url, { headers: authHeaders(credentials) })
@@ -878,7 +872,8 @@ const postStatus = ({
   quoteId,
   contentType,
   preview,
-  idempotencyKey
+  idempotencyKey,
+  language
 }) => {
   const form = new FormData()
   const pollOptions = poll.options || []
@@ -889,6 +884,7 @@ const postStatus = ({
   if (visibility) form.append('visibility', visibility)
   if (sensitive) form.append('sensitive', sensitive)
   if (contentType) form.append('content_type', contentType)
+  if (language) form.append('language', language)
   mediaIds.forEach(val => {
     form.append('media_ids[]', val)
   })
@@ -1335,7 +1331,7 @@ const fetchEmojiReactions = ({ id, credentials }) => {
 
 const reactWithEmoji = ({ id, emoji, credentials }) => {
   return promisedRequest({
-    url: PLEROMA_EMOJI_REACT_URL(id, emoji),
+    url: PLEROMA_EMOJI_REACT_URL(id, encodeURIComponent(emoji)),
     method: 'PUT',
     credentials
   }).then(parseStatus)
@@ -1343,7 +1339,7 @@ const reactWithEmoji = ({ id, emoji, credentials }) => {
 
 const unreactWithEmoji = ({ id, emoji, credentials }) => {
   return promisedRequest({
-    url: PLEROMA_EMOJI_UNREACT_URL(id, emoji),
+    url: PLEROMA_EMOJI_UNREACT_URL(id, encodeURIComponent(emoji)),
     method: 'DELETE',
     credentials
   }).then(parseStatus)
@@ -1575,6 +1571,48 @@ const unfollowHashtag = ({ tag, credentials }) => {
   })
 }
 
+const getFollowedHashtags = ({ credentials, pagination: savedPagination }) => {
+  const queryParams = new URLSearchParams()
+  if (savedPagination?.maxId) {
+    queryParams.append('max_id', savedPagination.maxId)
+  }
+  const url = `${MASTODON_FOLLOWED_TAGS_URL}?${queryParams.toString()}`
+  let pagination = {};
+  return fetch(url, {
+    credentials
+  }).then((data) => {
+    pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
+      flakeId: false
+    });
+    return data.json()
+  }).then((data) => {
+    return {
+      pagination,
+      data
+    }
+  });
+}
+
+const getFollowRequests = ({ credentials, pagination: savedPagination }) => {
+  const queryParams = new URLSearchParams()
+  if (savedPagination?.maxId) {
+    queryParams.append('max_id', savedPagination.maxId)
+  }
+  const url = `${MASTODON_FOLLOW_REQUESTS_URL}?${queryParams.toString()}`
+  let pagination = {};
+  return fetch(url, {
+    credentials
+  }).then((data) => {
+    pagination = parseLinkHeaderPagination(data.headers.get('Link'), { flakeId: true });
+    return data.json()
+  }).then((data) => {
+    return {
+      pagination,
+      data: data.map(parseUser)
+    }
+  });
+}
+
 export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
   return Object.entries({
     ...(credentials
@@ -1764,7 +1802,6 @@ const apiService = {
   mfaConfirmOTP,
   addBackup,
   listBackups,
-  fetchFollowRequests,
   fetchLists,
   createList,
   getList,
@@ -1813,7 +1850,9 @@ const apiService = {
   deleteNoteFromReport,
   getHashtag,
   followHashtag,
-  unfollowHashtag
+  unfollowHashtag,
+  getFollowedHashtags,
+  getFollowRequests
 }
 
 export default apiService
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index 4d6f80c2..58515387 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -1,7 +1,6 @@
 import apiService, { getMastodonSocketURI, ProcessedWS } from '../api/api.service.js'
 import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js'
 import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js'
-import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service'
 import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js'
 import announcementsFetcher from '../../services/announcements_fetcher/announcements_fetcher.service.js'
 import configFetcher from '../config_fetcher/config_fetcher.service.js'
@@ -28,10 +27,6 @@ const backendInteractorService = credentials => ({
     return notificationsFetcher.fetchAndUpdate({ ...args, credentials })
   },
 
-  startFetchingFollowRequests ({ store }) {
-    return followRequestFetcher.startFetching({ store, credentials })
-  },
-
   startFetchingLists ({ store }) {
     return listsFetcher.startFetching({ store, credentials })
   },
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 465d6fad..e330ca8c 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -68,13 +68,15 @@ export const parseUser = (data) => {
     output.fields_html = data.fields.map(field => {
       return {
         name: escape(field.name),
-        value: field.value
+        value: field.value,
+        verified_at: field.verified_at
       }
     })
     output.fields_text = data.fields.map(field => {
       return {
         name: unescape(field.name.replace(/<[^>]*>/g, '')),
-        value: unescape(field.value.replace(/<[^>]*>/g, ''))
+        value: unescape(field.value.replace(/<[^>]*>/g, '')),
+        verified_at: field.verified_at
       }
     })
 
@@ -88,6 +90,7 @@ export const parseUser = (data) => {
     output.friends_count = data.following_count
 
     output.bot = data.bot
+    output.follow_requests_count = data.follow_requests_count
     if (data.akkoma) {
       output.instance = data.akkoma.instance
       output.status_ttl_days = data.akkoma.status_ttl_days
@@ -232,13 +235,14 @@ export const parseAttachment = (data) => {
   if (masto) {
     // Not exactly same...
     output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
-    output.meta = data.meta // not present in BE yet
+    output.meta = data.meta
     output.id = data.id
   } else {
     output.mimetype = data.mimetype
     // output.meta = ??? missing
   }
 
+  output.blurhash = data.blurhash
   output.url = data.url
   output.large_thumb_url = data.preview_url
   output.description = data.description
@@ -408,8 +412,10 @@ export const parseNotification = (data) => {
   if (masto) {
     output.type = mastoDict[data.type] || data.type
     output.seen = data.pleroma.is_seen
-    output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
-    output.action = output.status // TODO: Refactor, this is unneeded
+    if (data.status) {
+      output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
+      output.action = output.status // TODO: Refactor, this is unneeded
+    }
     output.target = output.type !== 'move'
       ? null
       : parseUser(data.target)
diff --git a/src/services/follow_request_fetcher/follow_request_fetcher.service.js b/src/services/follow_request_fetcher/follow_request_fetcher.service.js
deleted file mode 100644
index 5c0ab85e..00000000
--- a/src/services/follow_request_fetcher/follow_request_fetcher.service.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import apiService from '../api/api.service.js'
-import { promiseInterval } from '../promise_interval/promise_interval.js'
-
-const fetchAndUpdate = ({ store, credentials }) => {
-  return apiService.fetchFollowRequests({ credentials })
-    .then((requests) => {
-      store.commit('setFollowRequests', requests)
-      store.commit('addNewUsers', requests)
-    }, () => {})
-    .catch(() => {})
-}
-
-const startFetching = ({ credentials, store }) => {
-  const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
-  boundFetchAndUpdate()
-  return promiseInterval(boundFetchAndUpdate, 240000)
-}
-
-const followRequestFetcher = {
-  startFetching
-}
-
-export default followRequestFetcher
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index aaef5a7a..0dbedf07 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -13,7 +13,8 @@ const postStatus = ({
   quoteId = undefined,
   contentType = 'text/plain',
   preview = false,
-  idempotencyKey = ''
+  idempotencyKey = '',
+  language
 }) => {
   const mediaIds = map(media, 'id')
 
@@ -29,7 +30,8 @@ const postStatus = ({
     contentType,
     poll,
     preview,
-    idempotencyKey
+    idempotencyKey,
+    language
   })
     .then((data) => {
       if (!data.error && !preview) {
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index d5bf8749..9e691261 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -4,12 +4,10 @@ import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/th
 
 export const applyTheme = (input) => {
   const { rules } = generatePreset(input)
-  const head = document.head
   const body = document.body
   body.classList.add('hidden')
 
-  const styleEl = document.createElement('style')
-  head.appendChild(styleEl)
+  const styleEl = document.getElementById('theme-holder')
   const styleSheet = styleEl.sheet
 
   styleSheet.toString()
diff --git a/static/.tos b/static/.tos
deleted file mode 100644
index 4d91f9b0..00000000
--- a/static/.tos
+++ /dev/null
@@ -1,45 +0,0 @@
-<h4>Terms of Service</h4>
-
-<p>It's mainly "be nice"</p>
-
-<ol>
-    <li>
-        <h3>Don't be a big meanie</h4>
-        <p>Arguments are cool and all but don't make them into flamewars. Try to act in good faith - we want to be at least on good terms with people. Please act with understanding towards others on this instance. Most people here are probably struggling with a lot, be mindful of that.</p>
-    </li>
-    <li>
-        <h3>Mark your lewds!</h3>
-	<p>Reminder that lewd is bad and nobody wants to be forced to see that. Just mark it sensitive, and post unlisted. That is to say, anything suggestive/ecchi upwards should be marked. If you wouldn't look at it with your parents/boss in the room, mark it. It goes without saying that if you're <em>going</em> to post lewd stuff, keep it sensible. Obviously nothing underaged or otherwise questionable. Or you could just not post lewd stuff. Either/or.</p>
-    </li>
-    <li>
-	    <h3>This is a <b>Kink Shame Zone</b></h3>
-	    <p>Being a lewdie will be met with many anime girl reaction images shaming you for your lewdness. Go think about icky things on someone else's webzone™</p>
-    </li>
-    <li>
-	   <h3>Keep it legal!</h3>
-	   <p>Server is hosted in france, keep content legal for there (+ wherever you're browsing from)</p>
-    </li>
-    <li>
-	    <h3>No ads/spambots</h3>
-	    <p>I didn't think I'd have to specify this, but please do not set up bots solely for trying to advertise.</h3>
-    </li>
-    <li>
-	    <h3>Non-TOS recommendations</h3>
-	    <p>This is stuff that'd I'd <em>like</em> you to do, but I won't outright ban you if you don't follow them</p>
-	    <ul>
-		    <li>If someone is sadposting, don't antagonise them - they probably just want to vent</li>
-		    <li>Put walls of text behind a subject (CW) - helps the timeline not get flooded with text</li>
-	    </ul>
-    </li>
-
-    <li>
-	    <h3>Other</h3>
-	    <p>If you're here and you happen to play minecraft, feel free to message me with your username and come play with us sometime!</p>
-   </li>
-
-</ol>
-
-<p>So I guess yeah, that's about it. Try to be nice, eh? We're probably all sad here.</p>
-
-<br>
-<img src="/static/logo.png" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" />
diff --git a/static/blurhash-overlay.png b/static/blurhash-overlay.png
new file mode 100755
index 00000000..209a9438
Binary files /dev/null and b/static/blurhash-overlay.png differ
diff --git a/static/logo.png b/static/logo.png
deleted file mode 100644
index 14aaf4a3..00000000
Binary files a/static/logo.png and /dev/null differ
diff --git a/static/theme-holder.css b/static/theme-holder.css
new file mode 100644
index 00000000..3e884036
--- /dev/null
+++ b/static/theme-holder.css
@@ -0,0 +1 @@
+// This file intentionally left blank
diff --git a/yarn.lock b/yarn.lock
index bbceba0b..7027b7d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1350,6 +1350,13 @@
     minimatch "^3.0.4"
     strip-json-comments "^3.1.1"
 
+"@floatingghost/pinch-zoom-element@^1.3.1":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@floatingghost/pinch-zoom-element/-/pinch-zoom-element-1.3.1.tgz#5f327ad17ddf1f56777098aca088fdbf99cbd049"
+  integrity sha512-KnE7aBQdd/Fj1TzU5uzgwD9YAQ58DTMUks/PoTEBFW4zi0lBM9cN/j45wzcnzsT2VXG1S6qM7NMmq7NGm2//Fg==
+  dependencies:
+    pointer-tracker "^2.0.3"
+
 "@fortawesome/fontawesome-common-types@6.2.0":
   version "6.2.0"
   resolved "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz"
@@ -1516,13 +1523,6 @@
     "@jridgewell/resolve-uri" "^3.0.3"
     "@jridgewell/sourcemap-codec" "^1.4.10"
 
-"@kazvmoe-infra/pinch-zoom-element@1.2.0":
-  version "1.2.0"
-  resolved "https://registry.npmjs.org/@kazvmoe-infra/pinch-zoom-element/-/pinch-zoom-element-1.2.0.tgz"
-  integrity sha512-HBrhH5O/Fsp2bB7EGTXzCsBAVcMjknSagKC5pBdGpKsF8meHISR0kjDIdw4YoE0S+0oNMwJ6ZUZyIBrdywxPPw==
-  dependencies:
-    pointer-tracker "^2.0.3"
-
 "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1":
   version "5.1.1-v1"
   resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129"
@@ -2494,6 +2494,11 @@ binary-extensions@^2.0.0:
   resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
+blurhash@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.4.tgz#60642a823b50acaaf3732ddb6c7dfd721bdfef2a"
+  integrity sha512-r/As72u2FbucLoK5NTegM/GucxJc3d8GvHc4ngo13IO/nt2HU4gONxNLq1XPN6EM/V8Y9URIa7PcSz2RZu553A==
+
 body-parser@1.19.2:
   version "1.19.2"
   resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.19.2.tgz"
@@ -5090,9 +5095,9 @@ isexe@^2.0.0:
   resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"
   integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
-iso-639-1@2.1.15:
+iso-639-1@^2.1.15:
   version "2.1.15"
-  resolved "https://registry.npmjs.org/iso-639-1/-/iso-639-1-2.1.15.tgz"
+  resolved "https://registry.yarnpkg.com/iso-639-1/-/iso-639-1-2.1.15.tgz#20cf78a4f691aeb802c16f17a6bad7d99271e85d"
   integrity sha512-7c7mBznZu2ktfvyT582E2msM+Udc1EjOyhVRE/0ZsjD9LBtWSm23h3PtiRh2a35XoUsTQQjJXaJzuLjXsOdFDg==
 
 isobject@^3.0.1: