diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac740f23c78a74ad02610a11bf6f0c9e41871329..6a53ed8f970b9d0d4c35c97788a7d320f7e61878 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Support pagination of local emoji packs and files
 - Add MRF Activity Expiration setting
 - Add ability to disable multi-factor authentication for a user
+- Add ability to manually evict and ban URLs from the Pleroma MediaProxy cache
+- Add Invalidation settings on MediaProxy tab
 - Ability to configure S3 settings on Upload tab
 - Show number of open reports in Sidebar Menu
 - Add confirmation message when deleting a user
@@ -30,6 +32,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Remove ability to moderate users that don't have valid nickname
 - Displays both labels and description in the header of group of settiings
 - Ability to add custom values in Pleroma.Upload.Filter.Mogrify setting
+- Change types of the following settings: ':groups', ':replace', ':federated_timeline_removal', ':reject', ':match_actor'. Update functions that parses and wraps settings data according to this change.
+- Move rendering Crontab setting from a separate component to EditableKeyword component
 - Show only those MRF settings that have been enabled in MRF Policies setting
 - Move Auto Linker settings to Link Formatter Tab as its configuration was moved to :pleroma, Pleroma.Formatter
 
diff --git a/src/api/mediaProxyCache.js b/src/api/mediaProxyCache.js
new file mode 100644
index 0000000000000000000000000000000000000000..0822d984214f4df6c2c98003061c2eec318d8752
--- /dev/null
+++ b/src/api/mediaProxyCache.js
@@ -0,0 +1,34 @@
+import request from '@/utils/request'
+import { getToken } from '@/utils/auth'
+import { baseName } from './utils'
+
+export async function listBannedUrls(page, authHost, token) {
+  return await request({
+    baseURL: baseName(authHost),
+    url: `/api/pleroma/admin/media_proxy_caches?page=${page}`,
+    method: 'get',
+    headers: authHeaders(token)
+  })
+}
+
+export async function purgeUrls(urls, ban, authHost, token) {
+  return await request({
+    baseURL: baseName(authHost),
+    url: `/api/pleroma/admin/media_proxy_caches/purge`,
+    method: 'post',
+    headers: authHeaders(token),
+    data: { urls, ban }
+  })
+}
+
+export async function removeBannedUrls(urls, authHost, token) {
+  return await request({
+    baseURL: baseName(authHost),
+    url: `/api/pleroma/admin/media_proxy_caches/delete`,
+    method: 'post',
+    headers: authHeaders(token),
+    data: { urls }
+  })
+}
+
+const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}
diff --git a/src/lang/en.js b/src/lang/en.js
index 3b644597981df9dc74b8a81a7b66eb5df2ad4351..478a5cc7767218ba7482ebac1964b7e1196b98b8 100644
--- a/src/lang/en.js
+++ b/src/lang/en.js
@@ -65,8 +65,11 @@ export default {
     externalLink: 'External Link',
     users: 'Users',
     reports: 'Reports',
+    invites: 'Invites',
+    statuses: 'Statuses',
     settings: 'Settings',
     moderationLog: 'Moderation Log',
+    mediaProxyCache: 'MediaProxy Cache',
     'emoji-packs': 'Emoji packs'
   },
   navbar: {
@@ -89,6 +92,19 @@ export default {
     pleromaFELoginFailed: 'Failed to login via PleromaFE, please login with username/password',
     pleromaFELoginSucceed: 'Logged in via PleromaFE'
   },
+  mediaProxyCache: {
+    mediaProxyCache: 'MediaProxy Cache',
+    ban: 'Ban',
+    url: 'URL',
+    evict: 'Evict',
+    evictedMessage: 'This URL was evicted',
+    actions: 'Actions',
+    remove: 'Remove from Cachex',
+    evictObjectsHeader: 'Evict object from the MediaProxy cache',
+    listBannedUrlsHeader: 'List of all banned MediaProxy URLs',
+    multipleInput: 'You can enter a single URL or several comma separated links',
+    removeSelected: 'Remove Selected'
+  },
   documentation: {
     documentation: 'Documentation',
     github: 'Github Repository'
diff --git a/src/router/index.js b/src/router/index.js
index 40d4d7cd176441d38b1821633f2c23c8d1d2eb5f..b61e7b448e8546fedb0050d8f77bd4bc0db463d1 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -16,7 +16,7 @@ const settings = {
       path: 'index',
       component: () => import('@/views/settings/index'),
       name: 'Settings',
-      meta: { title: 'Settings', icon: 'settings', noCache: true }
+      meta: { title: 'settings', icon: 'settings', noCache: true }
     }
   ]
 }
@@ -30,7 +30,7 @@ const statuses = {
       path: 'index',
       component: () => import('@/views/statuses/index'),
       name: 'Statuses',
-      meta: { title: 'Statuses', icon: 'form', noCache: true }
+      meta: { title: 'statuses', icon: 'form', noCache: true }
     }
   ]
 }
@@ -44,7 +44,7 @@ const reports = {
       path: 'index',
       component: () => import('@/views/reports/index'),
       name: 'Reports',
-      meta: { title: 'Reports', icon: 'documentation', noCache: true }
+      meta: { title: 'reports', icon: 'documentation', noCache: true }
     }
   ]
 }
@@ -58,7 +58,7 @@ const invites = {
       path: 'index',
       component: () => import('@/views/invites/index'),
       name: 'Invites',
-      meta: { title: 'Invites', icon: 'guide', noCache: true }
+      meta: { title: 'invites', icon: 'guide', noCache: true }
     }
   ]
 }
@@ -72,7 +72,7 @@ const emojiPacks = {
       path: 'index',
       component: () => import('@/views/emojiPacks/index'),
       name: 'Emoji Packs',
-      meta: { title: 'Emoji Packs', icon: 'eye-open', noCache: true }
+      meta: { title: 'emoji-packs', icon: 'eye-open', noCache: true }
     }
   ]
 }
@@ -91,6 +91,20 @@ const moderationLog = {
   ]
 }
 
+const mediaProxyCacheDisabled = disabledFeatures.includes('media-proxy-cache')
+const mediaProxyCache = {
+  path: '/media_proxy_cache',
+  component: Layout,
+  children: [
+    {
+      path: 'index',
+      component: () => import('@/views/mediaProxyCache/index'),
+      name: 'MediaProxy Cache',
+      meta: { title: 'mediaProxyCache', icon: 'example', noCache: true }
+    }
+  ]
+}
+
 export const constantRouterMap = [
   {
     path: '/redirect',
@@ -159,6 +173,7 @@ export const asyncRouterMap = [
   ...(invitesDisabled ? [] : [invites]),
   ...(emojiPacksDisabled ? [] : [emojiPacks]),
   ...(moderationLogDisabled ? [] : [moderationLog]),
+  ...(mediaProxyCacheDisabled ? [] : [mediaProxyCache]),
   ...(settingsDisabled ? [] : [settings]),
   {
     path: '/users/:id',
diff --git a/src/store/index.js b/src/store/index.js
index e2fcd651768e6391e2a3e6685fd69eb4660ed1a7..bd4a6e5b0776fd2828d379d34a140ca7963c078b 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -5,6 +5,7 @@ import emojiPacks from './modules/emojiPacks'
 import errorLog from './modules/errorLog'
 import getters from './getters'
 import invites from './modules/invites'
+import mediaProxyCache from './modules/mediaProxyCache'
 import moderationLog from './modules/moderationLog'
 import peers from './modules/peers'
 import permission from './modules/permission'
@@ -24,8 +25,9 @@ const store = new Vuex.Store({
     app,
     errorLog,
     emojiPacks,
-    moderationLog,
     invites,
+    mediaProxyCache,
+    moderationLog,
     peers,
     permission,
     relays,
diff --git a/src/store/modules/mediaProxyCache.js b/src/store/modules/mediaProxyCache.js
new file mode 100644
index 0000000000000000000000000000000000000000..815c14e493659e7a7dbef7bd5082602306fed43a
--- /dev/null
+++ b/src/store/modules/mediaProxyCache.js
@@ -0,0 +1,53 @@
+import { listBannedUrls, purgeUrls, removeBannedUrls } from '@/api/mediaProxyCache'
+import { Message } from 'element-ui'
+import i18n from '@/lang'
+
+const mediaProxyCache = {
+  state: {
+    bannedUrls: [],
+    bannedUrlsCount: 0,
+    currentPage: 1,
+    loading: false
+  },
+  mutations: {
+    SET_BANNED_URLS: (state, urls) => {
+      state.bannedUrls = urls.map(el => { return { url: el } })
+    },
+    SET_BANNED_URLS_COUNT: (state, count) => {
+      state.bannedUrlsCount = count
+    },
+    SET_LOADING: (state, status) => {
+      state.loading = status
+    },
+    SET_PAGE: (state, page) => {
+      state.currentPage = page
+    }
+  },
+  actions: {
+    async ListBannedUrls({ commit, getters }, page) {
+      commit('SET_LOADING', true)
+      const response = await listBannedUrls(page, getters.authHost, getters.token)
+      commit('SET_BANNED_URLS', response.data.urls)
+      // commit('SET_BANNED_URLS_COUNT', count)
+      commit('SET_PAGE', page)
+      commit('SET_LOADING', false)
+    },
+    async PurgeUrls({ dispatch, getters, state }, { urls, ban }) {
+      await purgeUrls(urls, ban, getters.authHost, getters.token)
+      Message({
+        message: i18n.t('mediaProxyCache.evictedMessage'),
+        type: 'success',
+        duration: 5 * 1000
+      })
+      if (ban) {
+        dispatch('ListBannedUrls', state.currentPage)
+      }
+    },
+    async RemoveBannedUrls({ dispatch, getters, state }, urls) {
+      await removeBannedUrls(urls, getters.authHost, getters.token)
+      dispatch('ListBannedUrls', state.currentPage)
+    }
+  }
+}
+
+export default mediaProxyCache
diff --git a/src/store/modules/normalizers.js b/src/store/modules/normalizers.js
index 349cebffefcde7030e5f8e56a27f14c8fea5244a..c7fc33e8fc56be9d7ea0a940d136369a46c79f43 100644
--- a/src/store/modules/normalizers.js
+++ b/src/store/modules/normalizers.js
@@ -71,18 +71,16 @@ export const parseTuples = (tuples, key) => {
         return [...acc, { [mascot.tuple[0]]: { ...mascot.tuple[1], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
       }, [])
     } else if (Array.isArray(item.tuple[1]) &&
-      (item.tuple[0] === ':groups' || item.tuple[0] === ':replace' || item.tuple[0] === ':retries')) {
-      accum[item.tuple[0]] = item.tuple[1].reduce((acc, group) => {
-        return [...acc, { [group.tuple[0]]: { value: group.tuple[1], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
-      }, [])
-    } else if (item.tuple[0] === ':crontab') {
-      accum[item.tuple[0]] = item.tuple[1].reduce((acc, group) => {
-        return { ...acc, [group.tuple[1]]: group.tuple[0] }
-      }, {})
-    } else if (item.tuple[0] === ':match_actor') {
-      accum[item.tuple[0]] = Object.keys(item.tuple[1]).reduce((acc, regex) => {
-        return [...acc, { [regex]: { value: item.tuple[1][regex], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
-      }, [])
+      (item.tuple[0] === ':groups' || item.tuple[0] === ':replace' || item.tuple[0] === ':retries' || item.tuple[0] === ':headers' || item.tuple[0] === ':crontab')) {
+      if (item.tuple[0] === ':crontab') {
+        accum[item.tuple[0]] = item.tuple[1].reduce((acc, group) => {
+          return [...acc, { [group.tuple[1]]: { value: group.tuple[0], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
+        }, [])
+      } else {
+        accum[item.tuple[0]] = item.tuple[1].reduce((acc, group) => {
+          return [...acc, { [group.tuple[0]]: { value: group.tuple[1], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
+        }, [])
+      }
     } else if (item.tuple[0] === ':icons') {
       accum[item.tuple[0]] = item.tuple[1].map(icon => {
         return Object.keys(icon).map(name => {
@@ -103,7 +101,13 @@ export const parseTuples = (tuples, key) => {
     } else if (item.tuple[0] === ':ip') {
       accum[item.tuple[0]] = item.tuple[1].tuple.join('.')
     } else if (item.tuple[1] && typeof item.tuple[1] === 'object') {
-      accum[item.tuple[0]] = parseObject(item.tuple[1])
+      if (item.tuple[0] === ':params' || item.tuple[0] === ':match_actor') {
+        accum[item.tuple[0]] = Object.keys(item.tuple[1]).reduce((acc, key) => {
+          return [...acc, { [key]: { value: item.tuple[1][key], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
+        }, [])
+      } else {
+        accum[item.tuple[0]] = parseObject(item.tuple[1])
+      }
     } else {
       accum[item.tuple[0]] = item.tuple[1]
     }
@@ -214,11 +218,11 @@ export const wrapUpdatedSettings = (group, settings, currentState) => {
 const wrapValues = (settings, currentState) => {
   return Object.keys(settings).map(setting => {
     const [type, value] = settings[setting]
-    if (
-      type === 'keyword' ||
-      type.includes('keyword') ||
-      type.includes('tuple') && type.includes('list') ||
-      setting === ':replace'
+    if (type === 'keyword' ||
+      (Array.isArray(type) && (
+        type.includes('keyword') ||
+        (type.includes('tuple') && type.includes('list'))
+      ))
     ) {
       return { 'tuple': [setting, wrapValues(value, currentState)] }
     } else if (prependWithСolon(type, value)) {
@@ -231,15 +235,16 @@ const wrapValues = (settings, currentState) => {
       return { 'tuple': [value, setting] }
     } else if (type === 'map') {
       const mapValue = Object.keys(value).reduce((acc, key) => {
-        acc[key] = setting === ':match_actor' ? value[key] : value[key][1]
+        acc[key] = value[key][1]
+        return acc
+      }, {})
+      return { 'tuple': [setting, { ...currentState[setting], ...mapValue }] }
+    } else if (type.includes('map')) {
+      const mapValue = Object.keys(value).reduce((acc, key) => {
+        acc[key] = value[key][1]
         return acc
       }, {})
-      const mapCurrentState = setting === ':match_actor'
-        ? currentState[setting].reduce((acc, element) => {
-          return { ...acc, ...{ [Object.keys(element)[0]]: Object.values(element)[0].value }}
-        }, {})
-        : currentState[setting]
-      return { 'tuple': [setting, { ...mapCurrentState, ...mapValue }] }
+      return { 'tuple': [setting, mapValue] }
     } else if (setting === ':ip') {
       const ip = value.split('.').map(s => parseInt(s, 10))
       return { 'tuple': [setting, { 'tuple': ip }] }
diff --git a/src/views/mediaProxyCache/index.vue b/src/views/mediaProxyCache/index.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6c37082b4ea8064100fd4cfa80d57100cfd160ad
--- /dev/null
+++ b/src/views/mediaProxyCache/index.vue
@@ -0,0 +1,157 @@
+<template>
+  <div class="media-proxy-cache-container">
+    <div class="media-proxy-cache-header-container">
+      <h1>{{ $t('mediaProxyCache.mediaProxyCache') }}</h1>
+      <reboot-button/>
+    </div>
+    <p class="media-proxy-cache-header">{{ $t('mediaProxyCache.evictObjectsHeader') }}</p>
+    <div class="url-input-container">
+      <el-input
+        :placeholder="$t('mediaProxyCache.url')"
+        v-model="urls"
+        type="textarea"
+        autosize
+        clearable
+        class="url-input"/>
+      <el-checkbox v-model="ban">{{ $t('mediaProxyCache.ban') }}</el-checkbox>
+      <el-button class="evict-button" @click="evictURL">{{ $t('mediaProxyCache.evict') }}</el-button>
+    </div>
+    <span class="expl url-input-expl">{{ $t('mediaProxyCache.multipleInput') }}</span>
+    <p class="media-proxy-cache-header">{{ $t('mediaProxyCache.listBannedUrlsHeader') }}</p>
+    <el-table
+      v-loading="loading"
+      :data="bannedUrls"
+      class="banned-urls-table"
+      @selection-change="handleSelectionChange">>
+      <el-table-column
+        type="selection"
+        align="center"
+        width="55"/>
+      <el-table-column
+        :min-width="isDesktop ? 320 : 120"
+        prop="url"/>
+      <el-table-column>
+        <template slot="header">
+          <el-button
+            :disabled="removeSelectedDisabled"
+            size="mini"
+            class="remove-url-button"
+            @click="removeSelected()">{{ $t('mediaProxyCache.removeSelected') }}</el-button>
+        </template>
+        <template slot-scope="scope">
+          <el-button
+            size="mini"
+            class="remove-url-button"
+            @click="removeUrl(scope.row.url)">{{ $t('mediaProxyCache.remove') }}</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script>
+import RebootButton from '@/components/RebootButton'
+
+export default {
+  name: 'MediaProxyCache',
+  components: { RebootButton },
+  data() {
+    return {
+      urls: '',
+      ban: false,
+      selectedUrls: []
+    }
+  },
+  computed: {
+    bannedUrls() {
+      return this.$store.state.mediaProxyCache.bannedUrls
+    },
+    isDesktop() {
+      return this.$store.state.app.device === 'desktop'
+    },
+    loading() {
+      return this.$store.state.mediaProxyCache.loading
+    },
+    removeSelectedDisabled() {
+      return this.selectedUrls.length === 0
+    }
+  },
+  mounted() {
+    this.$store.dispatch('GetNodeInfo')
+    this.$store.dispatch('NeedReboot')
+    this.$store.dispatch('ListBannedUrls', 1)
+  },
+  methods: {
+    evictURL() {
+      const urls = this.urls.split(',').map(url => url.trim()).filter(el => el.length > 0)
+      this.$store.dispatch('PurgeUrls', { urls, ban: this.ban })
+      this.urls = ''
+    },
+    handleSelectionChange(value) {
+      this.$data.selectedUrls = value
+    },
+    removeSelected() {
+      const urls = this.selectedUrls.map(el => el.url)
+      this.$store.dispatch('RemoveBannedUrls', urls)
+      this.selectedUrls = []
+    },
+    removeUrl(url) {
+      this.$store.dispatch('RemoveBannedUrls', [url])
+    }
+  }
+}
+</script>
+
+<style rel='stylesheet/scss' lang='scss' scoped>
+h1 {
+  margin: 0;
+}
+.expl {
+  color: #666666;
+  font-size: 13px;
+  line-height: 22px;
+  margin: 5px 0 0 0;
+  overflow-wrap: break-word;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.banned-urls-table {
+  margin-top: 15px;
+  margin-bottom: 15px;
+}
+.evict-button {
+  margin-left: 15px;
+}
+.media-proxy-cache-header {
+  margin-left: 15px;
+  margin-top: 22px;
+  font-weight: 500;
+}
+.media-proxy-cache-header-container {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin: 10px 15px;
+}
+.remove-url-button {
+  width: 150px;
+}
+.url-input {
+  margin-right: 15px;
+}
+.url-input-container {
+  display: flex;
+  align-items: baseline;
+  margin: 15px 15px 5px 15px;
+}
+.url-input-expl {
+  margin-left: 15px;
+}
+
+@media only screen and (max-width:480px) {
+  .url-input {
+    width: 100%;
+    margin-bottom: 5px;
+  }
+}
+</style>
diff --git a/src/views/settings/components/Inputs.vue b/src/views/settings/components/Inputs.vue
index 8bbd8121b9544ee954bac9d6ddd04eaaf06f7401..0639994e48fb937844270290a0748cd10f2fa78a 100644
--- a/src/views/settings/components/Inputs.vue
+++ b/src/views/settings/components/Inputs.vue
@@ -95,15 +95,14 @@
         <el-input
           v-if="setting.type === 'atom'"
           :value="inputValue"
-          :placeholder="setting.suggestions[0] ? setting.suggestions[0].substr(1) : ''"
+          :placeholder="setting.suggestions && setting.suggestions[0] ? setting.suggestions[0].substr(1) : ''"
           :data-search="setting.key || setting.group"
           class="input"
           @input="update($event, settingGroup.group, settingGroup.key, settingParent, setting.key, setting.type, nested)">
           <template slot="prepend">:</template>
         </el-input>
         <!-- special inputs -->
-        <crontab-input v-if="setting.key === ':crontab'" :data="data[setting.key]" :setting-group="settingGroup" :setting="setting"/>
-        <editable-keyword-input v-if="editableKeyword(setting.key, setting.type)" :data="keywordData" :setting-group="settingGroup" :setting="setting"/>
+        <editable-keyword-input v-if="editableKeyword(setting.key, setting.type)" :data="keywordData" :setting-group="settingGroup" :setting="setting" :parents="settingParent"/>
         <icons-input v-if="setting.key === ':icons'" :data="iconsData" :setting-group="settingGroup" :setting="setting"/>
         <link-formatter-input v-if="booleanCombinedInput" :data="data" :setting-group="settingGroup" :setting="setting"/>
         <mascots-input v-if="setting.key === ':mascots'" :data="keywordData" :setting-group="settingGroup" :setting="setting"/>
@@ -129,7 +128,6 @@
 <script>
 import i18n from '@/lang'
 import {
-  CrontabInput,
   EditableKeywordInput,
   IconsInput,
   ImageUploadInput,
@@ -148,7 +146,6 @@ import marked from 'marked'
 export default {
   name: 'Inputs',
   components: {
-    CrontabInput,
     EditableKeywordInput,
     IconsInput,
     ImageUploadInput,
@@ -225,7 +222,7 @@ export default {
         this.$store.state.settings.db[group][key].includes(this.setting.key)
     },
     iconsData() {
-      return Array.isArray(this.data[':icons']) ? this.data[':icons'] : []
+      return Array.isArray(this.data) ? this.data : []
     },
     inputValue() {
       if ([':esshd', ':cors_plug', ':quack', ':tesla', ':swoosh'].includes(this.settingGroup.group) &&
@@ -267,6 +264,10 @@ export default {
       }
     },
     keywordData() {
+      if (this.settingParent.length > 0 ||
+        (Array.isArray(this.setting.type) && this.setting.type.includes('tuple') && this.setting.type.includes('list'))) {
+        return Array.isArray(this.data[this.setting.key]) ? this.data[this.setting.key] : []
+      }
       return Array.isArray(this.data) ? this.data : []
     },
     reducedSelects() {
@@ -296,10 +297,14 @@ export default {
   },
   methods: {
     editableKeyword(key, type) {
-      return key === ':replace' ||
-        type === 'map' ||
-        (Array.isArray(type) && type.includes('keyword') && type.includes('integer')) ||
-        (Array.isArray(type) && type.includes('keyword') && type.findIndex(el => el.includes('list') && el.includes('string')) !== -1)
+      return Array.isArray(type) && (
+        (type.includes('map') && type.includes('string')) ||
+        (type.includes('map') && type.findIndex(el => el.includes('list') && el.includes('string')) !== -1) ||
+        (type.includes('keyword') && type.includes('integer')) ||
+        (type.includes('keyword') && type.includes('string')) ||
+        (type.includes('tuple') && type.includes('list')) ||
+        (type.includes('keyword') && type.findIndex(el => el.includes('list') && el.includes('string')) !== -1)
+      )
     },
     getFormattedDescription(desc) {
       return marked(desc)
@@ -346,7 +351,7 @@ export default {
         type.includes('module') ||
         (type.includes('list') && type.includes('string')) ||
         (type.includes('list') && type.includes('atom')) ||
-        (type.includes('regex') && type.includes('string'))
+        (!type.includes('keyword') && type.includes('regex') && type.includes('string'))
       )
     },
     renderSingleSelect(type) {
diff --git a/src/views/settings/components/MediaProxy.vue b/src/views/settings/components/MediaProxy.vue
index 11c9d7931746492f5b002a463d65ca63b9c733fe..d3fa619d75d7e9465b24ec538503ee168110bfc3 100644
--- a/src/views/settings/components/MediaProxy.vue
+++ b/src/views/settings/components/MediaProxy.vue
@@ -3,6 +3,14 @@
     <el-form v-if="!loading" :model="mediaProxyData" :label-position="labelPosition" :label-width="labelWidth">
       <setting :setting-group="mediaProxy" :data="mediaProxyData"/>
     </el-form>
+    <el-divider v-if="mediaProxy" class="divider thick-line"/>
+    <el-form v-if="!loading" :model="httpInvalidationData" :label-position="labelPosition" :label-width="labelWidth">
+      <setting :setting-group="httpInvalidation" :data="httpInvalidationData"/>
+    </el-form>
+    <el-divider v-if="httpInvalidation" class="divider thick-line"/>
+    <el-form v-if="!loading" :model="scriptInvalidationData" :label-position="labelPosition" :label-width="labelWidth">
+      <setting :setting-group="scriptInvalidation" :data="scriptInvalidationData"/>
+    </el-form>
     <div class="submit-button-container">
       <el-button class="submit-button" type="primary" @click="onSubmit">Submit</el-button>
     </div>
@@ -22,6 +30,12 @@ export default {
     ...mapGetters([
       'settings'
     ]),
+    httpInvalidation() {
+      return this.settings.description.find(setting => setting.key === 'Pleroma.Web.MediaProxy.Invalidation.Http')
+    },
+    httpInvalidationData() {
+      return _.get(this.settings.settings, [':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Http']) || {}
+    },
     isMobile() {
       return this.$store.state.app.device === 'mobile'
     },
@@ -51,6 +65,12 @@ export default {
     },
     mediaProxyData() {
       return _.get(this.settings.settings, [':pleroma', ':media_proxy']) || {}
+    },
+    scriptInvalidation() {
+      return this.settings.description.find(setting => setting.key === 'Pleroma.Web.MediaProxy.Invalidation.Script')
+    },
+    scriptInvalidationData() {
+      return _.get(this.settings.settings, [':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Script']) || {}
     }
   },
   methods: {
diff --git a/src/views/settings/components/Setting.vue b/src/views/settings/components/Setting.vue
index 1656db6142af04e21267916868430aa9d7734b8b..7593fb3d2c417065afa1d3510d522959bc21ffe5 100644
--- a/src/views/settings/components/Setting.vue
+++ b/src/views/settings/components/Setting.vue
@@ -122,7 +122,7 @@ export default {
       return type === 'keyword' ||
         type === 'map' ||
         type.includes('keyword') ||
-        key === ':replace'
+        type.includes('map')
     },
     divideSetting(key) {
       return [':sslopts', ':tlsopts', ':adapter', ':poll_limits', ':queues', ':styling', ':invalidation', ':multi_factor_authentication'].includes(key)
diff --git a/src/views/settings/components/inputComponents/CrontabInput.vue b/src/views/settings/components/inputComponents/CrontabInput.vue
deleted file mode 100644
index 89a1491549784d8c0295c047d1ac3e21e774089d..0000000000000000000000000000000000000000
--- a/src/views/settings/components/inputComponents/CrontabInput.vue
+++ /dev/null
@@ -1,86 +0,0 @@
-<template>
-  <el-form :label-width="labelWidth" :label-position="isMobile ? 'top' : 'right'" class="crontab">
-    <el-form-item v-for="worker in workers" :key="worker" :label="worker" :data-search="setting.key" class="crontab-container">
-      <el-input
-        :value="data[worker]"
-        :placeholder="getSuggestion(worker) || null"
-        class="input setting-input"
-        @input="update($event, worker)"/>
-    </el-form-item>
-  </el-form>
-</template>
-
-<script>
-export default {
-  name: 'CrontabInput',
-  props: {
-    data: {
-      type: Object,
-      default: function() {
-        return {}
-      }
-    },
-    setting: {
-      type: Object,
-      default: function() {
-        return {}
-      }
-    },
-    settingGroup: {
-      type: Object,
-      default: function() {
-        return {}
-      }
-    }
-  },
-  computed: {
-    isDesktop() {
-      return this.$store.state.app.device === 'desktop'
-    },
-    isMobile() {
-      return this.$store.state.app.device === 'mobile'
-    },
-    isTablet() {
-      return this.$store.state.app.device === 'tablet'
-    },
-    labelWidth() {
-      if (this.isMobile) {
-        return '100%'
-      } else {
-        return '380px'
-      }
-    },
-    workers() {
-      return this.setting.suggestions.map(worker => worker[1])
-    }
-  },
-  methods: {
-    getSuggestion(worker) {
-      return this.setting.suggestions.find(suggestion => suggestion[1] === worker)[0]
-    },
-    update(value, worker) {
-      const currentValue = this.$store.state.settings.settings[this.settingGroup.group][this.settingGroup.key][this.setting.key]
-      const updatedValue = { ...currentValue, [worker]: value }
-      const updatedValueWithType = Object.keys(currentValue).reduce((acc, key) => {
-        if (key === worker) {
-          return { ...acc, [key]: ['reversed_tuple', value] }
-        } else {
-          return { ...acc, [key]: ['reversed_tuple', currentValue[key]] }
-        }
-      }, {})
-
-      this.$store.dispatch('UpdateSettings',
-        { group: this.settingGroup.group, key: this.settingGroup.key, input: this.setting.key, value: updatedValueWithType, type: this.setting.type }
-      )
-      this.$store.dispatch('UpdateState',
-        { group: this.settingGroup.group, key: this.settingGroup.key, input: this.setting.key, value: updatedValue }
-      )
-    }
-  }
-}
-</script>
-
-<style rel='stylesheet/scss' lang='scss'>
-@import '../../styles/main';
-@include settings
-</style>
diff --git a/src/views/settings/components/inputComponents/EditableKeywordInput.vue b/src/views/settings/components/inputComponents/EditableKeywordInput.vue
index 009b2033bcf750d296a6471e98466e085b89c318..727ff0da61c11f874bf7bfc657d7c17af0e86bf0 100644
--- a/src/views/settings/components/inputComponents/EditableKeywordInput.vue
+++ b/src/views/settings/components/inputComponents/EditableKeywordInput.vue
@@ -1,22 +1,31 @@
 <template>
   <div class="editable-keyword-container">
-    <div v-if="setting.key === ':replace'" :data-search="setting.key || setting.group">
+    <div v-if="setting.key === ':crontab'" :data-search="setting.key" class="crontab">
+      <el-form-item v-for="worker in data" :key="getId(worker)" :label="getCrontabWorkerLabel(worker)" class="crontab-container">
+        <el-input
+          :value="getValue(worker)"
+          :placeholder="getSuggestion(worker) || null"
+          class="input setting-input"
+          @input="updateCrontab($event, 'value', worker)"/>
+      </el-form-item>
+    </div>
+    <div v-else-if="editableKeywordWithInteger" :data-search="setting.key || setting.group">
       <div v-for="element in data" :key="getId(element)" class="setting-input">
-        <el-input :value="getKey(element)" placeholder="pattern" class="name-input" @input="parseEditableKeyword($event, 'key', element)"/> :
-        <el-input :value="getValue(element)" placeholder="replacement" class="value-input" @input="parseEditableKeyword($event, 'value', element)"/>
+        <el-input :value="getKey(element)" placeholder="key" class="name-input" @input="parseEditableKeyword($event, 'key', element)"/> :
+        <el-input-number :value="getValue(element)" :min="0" size="large" class="value-input" @change="parseEditableKeyword($event, 'value', element)"/>
         <el-button :size="isDesktop ? 'medium' : 'mini'" class="icon-minus-button" icon="el-icon-minus" circle @click="deleteEditableKeywordRow(element)"/>
       </div>
       <el-button :size="isDesktop ? 'medium' : 'mini'" icon="el-icon-plus" circle @click="addRowToEditableKeyword"/>
     </div>
-    <div v-else-if="editableKeywordWithInteger" :data-search="setting.key || setting.group">
+    <div v-else-if="editableKeywordWithString" :data-search="setting.key || setting.group">
       <div v-for="element in data" :key="getId(element)" class="setting-input">
-        <el-input :value="getKey(element)" placeholder="key" class="name-input" @input="parseEditableKeyword($event, 'key', element)"/> :
-        <el-input-number :value="getValue(element)" :min="0" size="large" class="value-input" @change="parseEditableKeyword($event, 'value', element)"/>
+        <el-input :value="getKey(element)" :placeholder="keyPlaceholder" class="name-input" @input="parseEditableKeyword($event, 'key', element)"/> :
+        <el-input :value="getValue(element)" :placeholder="valuePlaceholder" class="value-input" @input="parseEditableKeyword($event, 'value', element)"/>
         <el-button :size="isDesktop ? 'medium' : 'mini'" class="icon-minus-button" icon="el-icon-minus" circle @click="deleteEditableKeywordRow(element)"/>
       </div>
       <el-button :size="isDesktop ? 'medium' : 'mini'" icon="el-icon-plus" circle @click="addRowToEditableKeyword"/>
     </div>
-    <div v-else :data-search="setting.key || setting.group">
+    <div v-else-if="editableKeywordWithSelect" :data-search="setting.key || setting.group">
       <div v-for="element in data" :key="getId(element)" class="setting-input">
         <el-input :value="getKey(element)" placeholder="key" class="name-input" @input="parseEditableKeyword($event, 'key', element)"/> :
         <el-select :value="getValue(element)" multiple filterable allow-create class="value-input" @change="parseEditableKeyword($event, 'value', element)"/>
@@ -28,6 +37,8 @@
 </template>
 
 <script>
+import { processNested } from '@/store/modules/normalizers'
+
 export default {
   name: 'EditableKeywordInput',
   props: {
@@ -37,6 +48,13 @@ export default {
         return {}
       }
     },
+    parents: {
+      type: Array,
+      default: function() {
+        return []
+      },
+      required: false
+    },
     setting: {
       type: Object,
       default: function() {
@@ -52,10 +70,33 @@ export default {
   },
   computed: {
     editableKeywordWithInteger() {
-      return Array.isArray(this.setting.type) && this.setting.type.includes('keyword') && this.setting.type.includes('integer')
+      return this.setting.type.includes('keyword') && this.setting.type.includes('integer')
+    },
+    editableKeywordWithSelect() {
+      return (this.setting.type.includes('map') && this.setting.type.findIndex(el => el.includes('list') && el.includes('string')) !== -1) ||
+        (this.setting.type.includes('keyword') && this.setting.type.findIndex(el => el.includes('list') && el.includes('string')) !== -1)
+    },
+    editableKeywordWithString() {
+      return this.setting.key !== ':crontab' && (
+        (this.setting.type.includes('keyword') && this.setting.type.includes('string')) ||
+        (this.setting.type.includes('tuple') && this.setting.type.includes('list')) ||
+        (this.setting.type.includes('map') && this.setting.type.includes('string'))
+      )
     },
     isDesktop() {
       return this.$store.state.app.device === 'desktop'
+    },
+    keyPlaceholder() {
+      return this.setting.key === ':replace' ? 'pattern' : 'key'
+    },
+    settings() {
+      return this.$store.state.settings.settings
+    },
+    updatedSettings() {
+      return this.$store.state.settings.updatedSettings
+    },
+    valuePlaceholder() {
+      return this.setting.key === ':replace' ? 'replacement' : 'value'
     }
   },
   methods: {
@@ -71,6 +112,10 @@ export default {
     generateID() {
       return `f${(~~(Math.random() * 1e8)).toString(16)}`
     },
+    getCrontabWorkerLabel(worker) {
+      const workerKey = this.getKey(worker)
+      return workerKey.includes('Pleroma.Workers.Cron.') ? workerKey.replace('Pleroma.Workers.Cron.', '') : workerKey
+    },
     getKey(element) {
       return Object.keys(element)[0]
     },
@@ -78,6 +123,9 @@ export default {
       const { id } = Object.values(element)[0]
       return id
     },
+    getSuggestion(worker) {
+      return this.setting.suggestions.find(suggestion => suggestion[1] === this.getKey(worker))[0]
+    },
     getValue(element) {
       const { value } = Object.values(element)[0]
       return value
@@ -95,10 +143,40 @@ export default {
 
       this.updateSetting(updatedValue, this.settingGroup.group, this.settingGroup.key, this.setting.key, this.setting.type)
     },
+    updateCrontab(value, inputType, worker) {
+      const updatedId = this.getId(worker)
+      const updatedValue = this.data.map((worker, index) => {
+        if (Object.values(worker)[0].id === updatedId) {
+          return { [Object.keys(worker)[0]]: { ...Object.values(this.data[index])[0], value }}
+        }
+        return worker
+      })
+      const updatedValueWithType = updatedValue.reduce((acc, worker) => {
+        return { ...acc, [Object.keys(worker)[0]]: ['reversed_tuple', Object.values(worker)[0].value] }
+      }, {})
+
+      this.$store.dispatch('UpdateSettings',
+        { group: this.settingGroup.group, key: this.settingGroup.key, input: this.setting.key, value: updatedValueWithType, type: this.setting.type }
+      )
+      this.$store.dispatch('UpdateState',
+        { group: this.settingGroup.group, key: this.settingGroup.key, input: this.setting.key, value: updatedValue }
+      )
+    },
     updateSetting(value, group, key, input, type) {
-      const updatedSettings = this.wrapUpdatedSettings(value, input, type)
-      this.$store.dispatch('UpdateSettings', { group, key, input, value: updatedSettings, type })
-      this.$store.dispatch('UpdateState', { group, key, input, value })
+      const wrappedSettings = this.wrapUpdatedSettings(value, input, type)
+
+      if (this.parents.length > 0) {
+        const { valueForState,
+          valueForUpdatedSettings,
+          setting } = processNested(value, wrappedSettings, group, key, this.parents.reverse(), this.settings, this.updatedSettings)
+        this.$store.dispatch('UpdateSettings',
+          { group, key, input: setting.key, value: valueForUpdatedSettings, type: setting.type })
+        this.$store.dispatch('UpdateState',
+          { group, key, input: setting.key, value: valueForState })
+      } else {
+        this.$store.dispatch('UpdateSettings', { group, key, input, value: wrappedSettings, type })
+        this.$store.dispatch('UpdateState', { group, key, input, value })
+      }
     },
     wrapUpdatedSettings(value, input, type) {
       return type === 'map'
diff --git a/src/views/settings/components/inputComponents/RateLimitInput.vue b/src/views/settings/components/inputComponents/RateLimitInput.vue
index 431ae83b48d7efcc8e1c54aa07ba67787c8c35b4..60668369a6aa35cfa9579bc3c5655011efd194f6 100644
--- a/src/views/settings/components/inputComponents/RateLimitInput.vue
+++ b/src/views/settings/components/inputComponents/RateLimitInput.vue
@@ -1,14 +1,16 @@
 <template>
   <div :data-search="setting.key || setting.group" class="rate-limit-container">
     <div v-if="!rateLimitAuthUsers">
-      <el-input
+      <el-input-number
         :value="rateLimitAllUsers[0]"
+        :controls="false"
         placeholder="scale"
         class="scale-input"
         @input="parseRateLimiter($event, setting.key, 'scale', 'oneLimit', rateLimitAllUsers)"/>
       <span>:</span>
-      <el-input
+      <el-input-number
         :value="rateLimitAllUsers[1]"
+        :controls="false"
         placeholder="limit"
         class="limit-input"
         @input="parseRateLimiter($event, setting.key, 'limit', 'oneLimit', rateLimitAllUsers)"/>
@@ -25,16 +27,18 @@
           </span>
         </div>
         <div class="rate-limit-content">
-          <el-input
+          <el-input-number
             :value="rateLimitUnauthUsers[0]"
+            :controls="false"
             placeholder="scale"
             class="scale-input"
             @input="parseRateLimiter(
               $event, setting.key, 'scale', 'unauthUsersLimit', [rateLimitUnauthUsers, rateLimitAuthUsers]
           )"/>
           <span>:</span>
-          <el-input
+          <el-input-number
             :value="rateLimitUnauthUsers[1]"
+            :controls="false"
             placeholder="limit"
             class="limit-input"
             @input="parseRateLimiter(
@@ -49,14 +53,16 @@
           </span>
         </div>
         <div class="rate-limit-content">
-          <el-input
+          <el-input-number
             :value="rateLimitAuthUsers[0]"
+            :controls="false"
             placeholder="scale"
             class="scale-input"
             @input="parseRateLimiter($event, setting.key, 'scale', 'authUserslimit', [rateLimitUnauthUsers, rateLimitAuthUsers])"/>
           <span>:</span>
-          <el-input
+          <el-input-number
             :value="rateLimitAuthUsers[1]"
+            :controls="false"
             placeholder="limit"
             class="limit-input"
             @input="parseRateLimiter($event, setting.key, 'limit', 'authUserslimit', [rateLimitUnauthUsers, rateLimitAuthUsers])"/>
diff --git a/src/views/settings/components/inputComponents/index.js b/src/views/settings/components/inputComponents/index.js
index c7ac42bfaa2c618e8e32a1278f1b26f521ff46c9..0ef58841b87988f5b1edf3d10d687b23edac1092 100644
--- a/src/views/settings/components/inputComponents/index.js
+++ b/src/views/settings/components/inputComponents/index.js
@@ -1,5 +1,4 @@
 export { default as EditableKeywordInput } from './EditableKeywordInput'
-export { default as CrontabInput } from './CrontabInput'
 export { default as IconsInput } from './IconsInput'
 export { default as ImageUploadInput } from './ImageUploadInput'
 export { default as LinkFormatterInput } from './LinkFormatterInput'
diff --git a/src/views/settings/components/tabs.js b/src/views/settings/components/tabs.js
index bf3c752f6834abc3d1cc8f7fa5e1424badd8202c..394f0670998c6510ca810ce68a3fa3a4f0fa82bf 100644
--- a/src/views/settings/components/tabs.js
+++ b/src/views/settings/components/tabs.js
@@ -1,82 +1,84 @@
-export const tabs = {
-  'activity-pub': {
-    label: 'settings.activityPub',
-    settings: [':activitypub', ':user']
-  },
-  'authentication': {
-    label: 'settings.auth',
-    settings: [':auth', ':ldap', ':oauth2', 'Pleroma.Web.Auth.Authenticator']
-  },
-  'esshd': {
-    label: 'settings.esshd',
-    settings: [':esshd']
-  },
-  'captcha': {
-    label: 'settings.captcha',
-    settings: ['Pleroma.Captcha', 'Pleroma.Captcha.Kocaptcha']
-  },
-  'frontend': {
-    label: 'settings.frontend',
-    settings: [':assets', ':chat', ':emoji', ':frontend_configurations', ':markup', ':static_fe']
-  },
-  'gopher': {
-    label: 'settings.gopher',
-    settings: [':gopher']
-  },
-  'http': {
-    label: 'settings.http',
-    settings: [':cors_plug', ':http', ':http_security', ':web_cache_ttl']
-  },
-  'instance': {
-    label: 'settings.instance',
-    settings: [':admin_token', ':instance', ':manifest', 'Pleroma.User', 'Pleroma.ScheduledActivity', ':uri_schemes', ':feed', ':streamer']
-  },
-  'job-queue': {
-    label: 'settings.jobQueue',
-    settings: ['Pleroma.ActivityExpiration', 'Oban', ':workers']
-  },
-  'link-formatter': {
-    label: 'settings.linkFormatter',
-    settings: ['Pleroma.Formatter']
-  },
-  'logger': {
-    label: 'settings.logger',
-    settings: [':console', ':ex_syslogger', ':quack', ':logger']
-  },
-  'mailer': {
-    label: 'settings.mailer',
-    settings: [':email_notifications', 'Pleroma.Emails.Mailer', 'Pleroma.Emails.UserEmail', ':swoosh', 'Pleroma.Emails.NewUsersDigestEmail']
-  },
-  'media-proxy': {
-    label: 'settings.mediaProxy',
-    settings: [':media_proxy']
-  },
-  'metadata': {
-    label: 'settings.metadata',
-    settings: ['Pleroma.Web.Metadata', ':rich_media']
-  },
-  'mrf': {
-    label: 'settings.mrf',
-    settings: [':mrf_simple', ':mrf_rejectnonpublic', ':mrf_hellthread', ':mrf_keyword', ':mrf_subchain', ':mrf_mention', ':mrf_normalize_markup', ':mrf_vocabulary', ':mrf_object_age', ':modules']
-  },
-  'rate-limiters': {
-    label: 'settings.rateLimiters',
-    settings: [':rate_limit']
-  },
-  'relays': {
-    label: 'settings.relays',
-    settings: ['relays']
-  },
-  'web-push': {
-    label: 'settings.webPush',
-    settings: [':vapid_details']
-  },
-  'upload': {
-    label: 'settings.upload',
-    settings: ['Pleroma.Upload.Filter.AnonymizeFilename', 'Pleroma.Upload.Filter.Mogrify', 'Pleroma.Uploaders.S3', 'Pleroma.Uploaders.Local', 'Pleroma.Upload']
-  },
-  'other': {
-    label: 'settings.other',
-    settings: [':mime', 'Pleroma.Plugs.RemoteIp']
+export const tabs = description => {
+  return {
+    'activity-pub': {
+      label: 'settings.activityPub',
+      settings: [':activitypub', ':user']
+    },
+    'authentication': {
+      label: 'settings.auth',
+      settings: [':auth', ':ldap', ':oauth2', 'Pleroma.Web.Auth.Authenticator']
+    },
+    'auto-linker': {
+      label: 'settings.autoLinker',
+      settings: [':opts']
+    },
+    'esshd': {
+      label: 'settings.esshd',
+      settings: [':esshd']
+    },
+    'captcha': {
+      label: 'settings.captcha',
+      settings: ['Pleroma.Captcha', 'Pleroma.Captcha.Kocaptcha']
+    },
+    'frontend': {
+      label: 'settings.frontend',
+      settings: [':assets', ':chat', ':emoji', ':frontend_configurations', ':markup', ':static_fe']
+    },
+    'gopher': {
+      label: 'settings.gopher',
+      settings: [':gopher']
+    },
+    'http': {
+      label: 'settings.http',
+      settings: [':cors_plug', ':http', ':http_security', ':web_cache_ttl']
+    },
+    'instance': {
+      label: 'settings.instance',
+      settings: [':admin_token', ':instance', ':manifest', 'Pleroma.User', 'Pleroma.ScheduledActivity', ':uri_schemes', ':feed', ':streamer']
+    },
+    'job-queue': {
+      label: 'settings.jobQueue',
+      settings: ['Pleroma.ActivityExpiration', 'Oban', ':workers']
+    },
+    'logger': {
+      label: 'settings.logger',
+      settings: [':console', ':ex_syslogger', ':quack', ':logger']
+    },
+    'mailer': {
+      label: 'settings.mailer',
+      settings: [':email_notifications', 'Pleroma.Emails.Mailer', 'Pleroma.Emails.UserEmail', ':swoosh', 'Pleroma.Emails.NewUsersDigestEmail']
+    },
+    'media-proxy': {
+      label: 'settings.mediaProxy',
+      settings: [':media_proxy', 'Pleroma.Web.MediaProxy.Invalidation.Http', 'Pleroma.Web.MediaProxy.Invalidation.Script']
+    },
+    'metadata': {
+      label: 'settings.metadata',
+      settings: ['Pleroma.Web.Metadata', ':rich_media']
+    },
+    'mrf': {
+      label: 'settings.mrf',
+      settings: description.filter(el => el.tab === 'mrf').map(setting => setting.key)
+    },
+    'rate-limiters': {
+      label: 'settings.rateLimiters',
+      settings: [':rate_limit']
+    },
+    'relays': {
+      label: 'settings.relays',
+      settings: ['relays']
+    },
+    'web-push': {
+      label: 'settings.webPush',
+      settings: [':vapid_details']
+    },
+    'upload': {
+      label: 'settings.upload',
+      settings: ['Pleroma.Upload.Filter.AnonymizeFilename', 'Pleroma.Upload.Filter.Mogrify', 'Pleroma.Uploaders.S3', 'Pleroma.Uploaders.Local', 'Pleroma.Upload', ':s3']
+    },
+    'other': {
+      label: 'settings.other',
+      settings: [':mime', 'Pleroma.Plugs.RemoteIp']
+    }
   }
 }
diff --git a/src/views/settings/index.vue b/src/views/settings/index.vue
index 45f20324ec0f2fe73dcba56e84e37b6842d8e7a8..08f0cb64f20abdafac4fce6bfd70c66affcdb303 100644
--- a/src/views/settings/index.vue
+++ b/src/views/settings/index.vue
@@ -200,7 +200,7 @@ export default {
       return this.$store.state.settings.searchData
     },
     tabs() {
-      return tabs
+      return tabs(this.$store.state.settings.description)
     }
   },
   mounted: function() {
diff --git a/test/modules/normalizers/parseTuples.test.js b/test/modules/normalizers/parseTuples.test.js
index 70192e76f00c5b876a96072976bd109bc1d51827..9cbe9d2fea0b390537ac26946c95489bd8f2dca4 100644
--- a/test/modules/normalizers/parseTuples.test.js
+++ b/test/modules/normalizers/parseTuples.test.js
@@ -198,6 +198,30 @@ describe('Parse tuples', () => {
     expect(_.isEqual(expectedResult, result)).toBeTruthy()
   })
 
+  it('parses crontab setting', () => {
+    const tuples = [{ tuple: [':crontab', [
+      { tuple: ['0 0 * * *', 'Pleroma.Workers.Cron.ClearOauthTokenWorker'] },
+      { tuple: ['0 * * * *', 'Pleroma.Workers.Cron.StatsWorker'] },
+      { tuple: ['* * * * *', 'Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker']}
+    ]]}]
+    const expectedResult = { ':crontab': [
+      { 'Pleroma.Workers.Cron.ClearOauthTokenWorker': { value: '0 0 * * *'}},
+      { 'Pleroma.Workers.Cron.StatsWorker': { value: '0 * * * *'}},
+      { 'Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker': { value: '* * * * *'}}
+    ]}
+
+    const parsed = parseTuples(tuples, 'Oban')
+
+    expect(typeof parsed).toBe('object')
+    expect(':crontab' in parsed).toBeTruthy()
+    const result = { ...parsed, ':crontab': parsed[':crontab'].map(el => {
+      const key = Object.keys(el)[0]
+      const { id, ...rest } = el[key]
+      return { [key]: rest }
+    })}
+    expect(_.isEqual(expectedResult, result)).toBeTruthy()
+  })
+
   it('parses match_actor setting in mrf_subchain group', () => {
     const tuples = [{ tuple: [":match_actor",
       { '~r/https:\/\/example.com/s': ["Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy"]}]}]
@@ -216,6 +240,26 @@ describe('Parse tuples', () => {
     expect(_.isEqual(expectedResult, result)).toBeTruthy()
   })
 
+  it('parses options setting in MediaProxy.Invalidation.Http group', () => {
+    const tuples = [{ tuple: [":options", [{ tuple: [":params", { xxx: "zzz", aaa: "bbb" }]}]]}]
+    const expectedResult = { ':options': { ':params': 
+      [ { xxx: { value: 'zzz' }}, { aaa: { value: 'bbb' }}]
+    }}
+
+    const parsed = parseTuples(tuples, 'Pleroma.Web.MediaProxy.Invalidation.Http')
+
+    expect(typeof parsed).toBe('object')
+    expect(':options' in parsed).toBeTruthy()
+    
+    const idRemoved = parsed[':options'][':params'].map(el => {
+      const key = Object.keys(el)[0]
+      const { id, ...rest } = el[key]
+      return { [key]: rest }
+    })
+    parsed[':options'][':params'] = idRemoved
+    expect(_.isEqual(expectedResult, parsed)).toBeTruthy()
+  })
+
   it('parses proxy_url', () => {
     const proxyUrlNull = [{ tuple: [":proxy_url", null] }]
     const proxyUrlTuple = [{ tuple: [":proxy_url", { tuple: [":socks5", ":localhost", 3090] }]}]
diff --git a/test/modules/normalizers/wrapUpdatedSettings.test.js b/test/modules/normalizers/wrapUpdatedSettings.test.js
index 6b360f85c8b90f495c2e943b4ca10346a354bc64..dedb5c3460c25a403de2563b6e61160b7503657b 100644
--- a/test/modules/normalizers/wrapUpdatedSettings.test.js
+++ b/test/modules/normalizers/wrapUpdatedSettings.test.js
@@ -130,7 +130,7 @@ describe('Wrap settings', () => {
     }]
 
     const settings2 = { ':emoji': { ':groups': [
-      ['keyword', 'string', ['list', 'string']],
+      ['keyword', ['list', 'string']],
       { ':custom': [['list'], ['/emoji/*.png', '/emoji/**/*.png']],
         ':another_group': ['list', ['/custom_emoji/*.png']]}
     ]}}
@@ -151,7 +151,7 @@ describe('Wrap settings', () => {
 
   it('wraps :replace setting', () => {
     const settings = { ':mrf_keyword': { ':replace': [
-      [['tuple', 'string', 'string'], ['tuple', 'regex', 'string']],
+      ['list', 'tuple'],
       { 'pattern': ['list', 'replacement'],
         '/\w+/': ['list', 'test_replacement']}
     ]}}
@@ -296,17 +296,23 @@ describe('Wrap settings', () => {
       }]}]
     }]
 
-    const settings3 = { ':mrf_subchain': { ':match_actor': ['map', {
-      '~r/https:\/\/example.com/s': ['Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy'],
-      '~r/https:\/\/test.com': ['Elixir.Pleroma.Web.ActivityPub.MRF.TestPolicy']
+    expect(_.isEqual(result1, expectedResult1)).toBeTruthy()
+    expect(_.isEqual(result2, expectedResult2)).toBeTruthy()
+  })
+
+  it('wraps settings with type that includes map', () => {
+    const settings1 = { ':mrf_subchain': { ':match_actor': [['map', ['list', 'string']], {
+      '~r/https:\/\/example.com/s': ['list', ['Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy']],
+      '~r/https:\/\/test.com': ['list', ['Elixir.Pleroma.Web.ActivityPub.MRF.TestPolicy']]
     }]}}
-    const state3 = { ':pleroma': { ':mrf_subchain': { ':match_actor': [
-      { '~r/https:\/\/example.com/s': ['Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy'] },
-      { '~r/https:\/\/test.com': ['Elixir.Pleroma.Web.ActivityPub.MRF.TestPolicy'] }
+    const state1 = { ':pleroma': { ':mrf_subchain': { ':match_actor': [
+      { '~r/https:\/\/example.com/s': { value: ['Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy'], id: '1234' }},
+      { '~r/https:\/\/test.com': { value: ['Elixir.Pleroma.Web.ActivityPub.MRF.TestPolicy'], id: '5678' } }
     ]
     }}}
-    const result3 = wrapUpdatedSettings(':pleroma', settings3, state3)
-    const expectedResult3 = [{
+
+    const result1 = wrapUpdatedSettings(':pleroma', settings1, state1)
+    const expectedResult1 = [{
       group: ':pleroma',
       key: ':mrf_subchain',
       value: [{ tuple: [':match_actor', {
@@ -315,9 +321,24 @@ describe('Wrap settings', () => {
       }]}]
     }]
 
+    const settings2 = { 'Pleroma.Web.MediaProxy.Invalidation.Http': { 
+      ':options': ['keyword', { ':params': [['map', 'string'], { aaa: ['list', 'bbb'], xxx: ['list', 'zzz'] }]}]
+    }}
+    const state2 = { ':pleroma': { 'Pleroma.Web.MediaProxy.Invalidation.Http': { 
+      ':options': { ':params': [{ aaa: { value: 'bbb', id: '1' }, xxx: { value: 'zzz', id: '2' }}] }
+    }}}
+
+    const result2 = wrapUpdatedSettings(':pleroma', settings2, state2)
+    const expectedResult2 = [{
+      group: ':pleroma',
+      key: 'Pleroma.Web.MediaProxy.Invalidation.Http',
+      value: [{ tuple: [':options', [
+        { tuple: [':params', { aaa: 'bbb', xxx: 'zzz' }]}
+      ]]}]
+    }]
+
     expect(_.isEqual(result1, expectedResult1)).toBeTruthy()
     expect(_.isEqual(result2, expectedResult2)).toBeTruthy()
-    expect(_.isEqual(result3, expectedResult3)).toBeTruthy()
   })
 
   it('wraps IP setting', () => {
@@ -351,10 +372,10 @@ describe('Wrap settings', () => {
 
   it('wraps regular settings', () => {
     const settings = { ':http_security': {
-      ':report_uri': ["string", "https://test.com"],
-      ':ct_max_age': ["integer", 150000],
-      ':sts': ["boolean", true],
-      ':methods': [["list", "string"], ["POST", "PUT", "PATCH"]]
+      ':report_uri': ['string', 'https://test.com'],
+      ':ct_max_age': ['integer', 150000],
+      ':sts': ['boolean', true],
+      ':methods': [['list', 'string'], ['POST', 'PUT', 'PATCH']]
     }}
     const state = { ':pleroma': { ':http_security': {}}}
     const result = wrapUpdatedSettings(':pleroma', settings, state)
@@ -362,10 +383,10 @@ describe('Wrap settings', () => {
       group: ':pleroma',
       key: ':http_security',
       value: [
-        { tuple: [":report_uri", "https://test.com"] },
-        { tuple: [":ct_max_age", 150000] },
-        { tuple: [":sts", true] },
-        { tuple: [":methods", ["POST", "PUT", "PATCH"]] }
+        { tuple: [':report_uri', 'https://test.com'] },
+        { tuple: [':ct_max_age', 150000] },
+        { tuple: [':sts', true] },
+        { tuple: [':methods', ['POST', 'PUT', 'PATCH']] }
       ]
     }]