From 830e8fdb453c01f091b63dddbc634deb3cdd5d43 Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Sat, 10 Dec 2022 21:03:12 +0200
Subject: [PATCH 01/29] Fix user moderation dropdown clipping

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

diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss
index 07ab7bec..2cae1c35 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;

From 6e1ba218df450f2a7e900e794bd6275002f0650e Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Sat, 10 Dec 2022 21:17:41 +0200
Subject: [PATCH 02/29] Don't show timeline links if disabled and logged out

---
 src/components/desktop_nav/desktop_nav.js              | 4 ++++
 src/components/desktop_nav/desktop_nav.vue             | 2 ++
 src/components/timeline_menu/timeline_menu_content.js  | 3 ++-
 src/components/timeline_menu/timeline_menu_content.vue | 4 ++--
 4 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index f4900c38..2fb8a5ac 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -98,11 +98,15 @@ export default {
     logoLeft () { return this.$store.state.instance.logoLeft },
     currentUser () { return this.$store.state.users.currentUser },
     privateMode () { return this.$store.state.instance.private },
+    federating () { return this.$store.state.instance.federating },
     shouldConfirmLogout () {
       return this.$store.getters.mergedConfig.modalOnLogout
     },
     showBubbleTimeline () {
       return this.$store.state.instance.localBubbleInstances.length > 0
+    },
+    restrictedTimelines () {
+      return this.$store.state.instance.restrict_unauthenticated.timelines
     }
   },
   methods: {
diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue
index 92d3fa5b..a52989a5 100644
--- a/src/components/desktop_nav/desktop_nav.vue
+++ b/src/components/desktop_nav/desktop_nav.vue
@@ -44,6 +44,7 @@
             />
           </router-link>
           <router-link
+            v-if="currentUser || !(privateMode || restrictedTimelines.public)"
             :to="{ name: 'public-timeline' }"
             class="nav-icon"
           >
@@ -67,6 +68,7 @@
             />
           </router-link>
           <router-link
+            v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))"
             :to="{ name: 'public-external-timeline' }"
             class="nav-icon"
           >
diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js
index df15030b..25bd30d9 100644
--- a/src/components/timeline_menu/timeline_menu_content.js
+++ b/src/components/timeline_menu/timeline_menu_content.js
@@ -24,7 +24,8 @@ const TimelineMenuContent = {
       currentUser: state => state.users.currentUser,
       privateMode: state => state.instance.private,
       federating: state => state.instance.federating,
-      showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0)
+      showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0),
+      restrictedTimelines: state => state.instance.restrict_unauthenticated.timelines
     })
   }
 }
diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue
index 27aece22..220b0278 100644
--- a/src/components/timeline_menu/timeline_menu_content.vue
+++ b/src/components/timeline_menu/timeline_menu_content.vue
@@ -32,7 +32,7 @@
         >{{ $t("nav.bubble_timeline") }}</span>
       </router-link>
     </li>
-    <li v-if="currentUser || !privateMode">
+    <li v-if="currentUser || !(privateMode || restrictedTimelines.public)">
       <router-link
         class="menu-item"
         :to="{ name: 'public-timeline' }"
@@ -48,7 +48,7 @@
         >{{ $t("nav.public_tl") }}</span>
       </router-link>
     </li>
-    <li v-if="federating && (currentUser || !privateMode)">
+    <li v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))">
       <router-link
         class="menu-item"
         :to="{ name: 'public-external-timeline' }"

From 413acbc7dd9a544ea94faa328180c7d86d39a47a Mon Sep 17 00:00:00 2001
From: fef <owo@fef.moe>
Date: Mon, 12 Dec 2022 18:59:57 +0100
Subject: [PATCH 03/29] fix 404 when reacting with Keycap Number Sign

The Unicode sequence for the Keycap Number Sign
emoji starts with an ASCII "#" character, which
the browser's URL parser will interpret as a URI
fragment and truncate it before sending the
request to the backend.
---
 src/services/api/api.service.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 9e6f39f2..3edda6e1 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1335,7 +1335,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 +1343,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)

From 909271c764f07861b0e77a7909d2d9b7e524e6ad Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Wed, 14 Dec 2022 09:38:07 +0000
Subject: [PATCH 04/29] use v1 urls

---
 src/modules/instance.js         |  2 +-
 src/services/api/api.service.js | 12 ++++++------
 2 files changed, 7 insertions(+), 7 deletions(-)

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/services/api/api.service.js b/src/services/api/api.service.js
index 9e6f39f2..afa443f5 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -11,11 +11,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'
@@ -246,7 +246,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) {

From 8c6cf86de3b4314bbed4b7ad9147cbf96584cb76 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Wed, 14 Dec 2022 09:38:46 +0000
Subject: [PATCH 05/29] Revert "use v1 urls"

This reverts commit 909271c764f07861b0e77a7909d2d9b7e524e6ad.
---
 src/modules/instance.js         |  2 +-
 src/services/api/api.service.js | 12 ++++++------
 2 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/modules/instance.js b/src/modules/instance.js
index 02cbe1f8..c8c718d0 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/v1/pleroma/emoji')
+        const res = await window.fetch('/api/pleroma/emoji.json')
         if (res.ok) {
           const result = await res.json()
           const values = Array.isArray(result) ? Object.assign({}, ...result) : result
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index afa443f5..9e6f39f2 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -11,11 +11,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/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 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 SUGGESTIONS_URL = '/api/v1/suggestions'
 const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
 const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
@@ -246,7 +246,7 @@ const register = ({ params, credentials }) => {
     })
 }
 
-const getCaptcha = () => fetch('/api/v1/pleroma/captcha').then(resp => resp.json())
+const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json())
 
 const authHeaders = (accessToken) => {
   if (accessToken) {

From c39332c1bfb410a5f20907d75e739dc5cda15ce8 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Wed, 14 Dec 2022 09:39:01 +0000
Subject: [PATCH 06/29] Revert "Revert "use v1 urls""

This reverts commit 8c6cf86de3b4314bbed4b7ad9147cbf96584cb76.
---
 src/modules/instance.js         |  2 +-
 src/services/api/api.service.js | 12 ++++++------
 2 files changed, 7 insertions(+), 7 deletions(-)

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/services/api/api.service.js b/src/services/api/api.service.js
index 9e6f39f2..afa443f5 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -11,11 +11,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'
@@ -246,7 +246,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) {

From 7ff17ab72238ed66a45c512e7cd77ac46adc0b7d Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Tue, 20 Dec 2022 13:20:13 +0000
Subject: [PATCH 07/29] don't crash out if notification status is null

---
 src/services/entity_normalizer/entity_normalizer.service.js | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 465d6fad..df6c03b5 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -408,8 +408,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)

From d00e28d5e9b8a51d2f36c7d0238f8cd5a7ef4934 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Thu, 22 Dec 2022 05:43:01 +0000
Subject: [PATCH 08/29] fix emoji picker in replies in notifications

---
 src/components/emoji_picker/emoji_picker.scss | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss
index ac7b8b5d..119da7c4 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%;
@@ -18,6 +29,10 @@
       min-width: 50%;
       max-width: 130%;
     }
+
+    .Status > .emoji-picker {
+      z-index: 1000;
+    }
   }
 }
 .emoji-picker {

From da491f3278214fd56e3485d0ac162e92bf1b89ae Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Thu, 29 Dec 2022 15:17:35 +0000
Subject: [PATCH 09/29] add verification of links

---
 src/components/user_profile/user_profile.js        |  6 ++++--
 src/components/user_profile/user_profile.vue       | 14 ++++++++++++++
 src/i18n/en.json                                   |  3 ++-
 .../entity_normalizer/entity_normalizer.service.js |  7 +++++--
 4 files changed, 25 insertions(+), 5 deletions(-)

diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 42c5ec9e..8fba1a28 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -10,11 +10,13 @@ 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'
 
 library.add(
-  faCircleNotch
+  faCircleNotch,
+  faCircleCheck
 )
 
 const FollowerList = withLoadMore({
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index d16483e2..4e0d0b0f 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>
@@ -225,6 +234,11 @@
         padding: 0.5em 1.5em;
         box-sizing: border-box;
       }
+
+      .user-profile-field-validated {
+        margin-left: 1rem;
+        color: green;
+      }
     }
   }
 
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 32785561..6fbac399 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1183,7 +1183,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/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index df6c03b5..eb85418e 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -68,13 +68,16 @@ 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
       }
     })
+    console.log(output.fields_html)
     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
       }
     })
 

From bb243168b378a931be78c642030c9e89a0b8197b Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Thu, 29 Dec 2022 15:18:13 +0000
Subject: [PATCH 10/29] Revert "Merge pull request 'Don't show timeline links
 if disabled and logged out' (#250) from sfr/pleroma-fe:fix/hide-timelines
 into develop"

This reverts commit 0b5793c1e0fc3d311ffb54784299442f2fa45967, reversing
changes made to 72ef2e7454d654ceb34fc736c3f8080536b80b40.
---
 src/components/desktop_nav/desktop_nav.js              | 4 ----
 src/components/desktop_nav/desktop_nav.vue             | 2 --
 src/components/timeline_menu/timeline_menu_content.js  | 3 +--
 src/components/timeline_menu/timeline_menu_content.vue | 4 ++--
 4 files changed, 3 insertions(+), 10 deletions(-)

diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index 2fb8a5ac..f4900c38 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -98,15 +98,11 @@ export default {
     logoLeft () { return this.$store.state.instance.logoLeft },
     currentUser () { return this.$store.state.users.currentUser },
     privateMode () { return this.$store.state.instance.private },
-    federating () { return this.$store.state.instance.federating },
     shouldConfirmLogout () {
       return this.$store.getters.mergedConfig.modalOnLogout
     },
     showBubbleTimeline () {
       return this.$store.state.instance.localBubbleInstances.length > 0
-    },
-    restrictedTimelines () {
-      return this.$store.state.instance.restrict_unauthenticated.timelines
     }
   },
   methods: {
diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue
index a52989a5..92d3fa5b 100644
--- a/src/components/desktop_nav/desktop_nav.vue
+++ b/src/components/desktop_nav/desktop_nav.vue
@@ -44,7 +44,6 @@
             />
           </router-link>
           <router-link
-            v-if="currentUser || !(privateMode || restrictedTimelines.public)"
             :to="{ name: 'public-timeline' }"
             class="nav-icon"
           >
@@ -68,7 +67,6 @@
             />
           </router-link>
           <router-link
-            v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))"
             :to="{ name: 'public-external-timeline' }"
             class="nav-icon"
           >
diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js
index 25bd30d9..df15030b 100644
--- a/src/components/timeline_menu/timeline_menu_content.js
+++ b/src/components/timeline_menu/timeline_menu_content.js
@@ -24,8 +24,7 @@ const TimelineMenuContent = {
       currentUser: state => state.users.currentUser,
       privateMode: state => state.instance.private,
       federating: state => state.instance.federating,
-      showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0),
-      restrictedTimelines: state => state.instance.restrict_unauthenticated.timelines
+      showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0)
     })
   }
 }
diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue
index 220b0278..27aece22 100644
--- a/src/components/timeline_menu/timeline_menu_content.vue
+++ b/src/components/timeline_menu/timeline_menu_content.vue
@@ -32,7 +32,7 @@
         >{{ $t("nav.bubble_timeline") }}</span>
       </router-link>
     </li>
-    <li v-if="currentUser || !(privateMode || restrictedTimelines.public)">
+    <li v-if="currentUser || !privateMode">
       <router-link
         class="menu-item"
         :to="{ name: 'public-timeline' }"
@@ -48,7 +48,7 @@
         >{{ $t("nav.public_tl") }}</span>
       </router-link>
     </li>
-    <li v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))">
+    <li v-if="federating && (currentUser || !privateMode)">
       <router-link
         class="menu-item"
         :to="{ name: 'public-external-timeline' }"

From 401dfa8fa6ac908b2909f1a3c281e85361c8b038 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Thu, 29 Dec 2022 15:22:06 +0000
Subject: [PATCH 11/29] update readme

---
 README.md | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 9be1c3bd..6c2c9376 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
 

From 5a4315384ee84ec5cdaffc2010648b410268eaf5 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Thu, 29 Dec 2022 15:25:03 +0000
Subject: [PATCH 12/29] force CI build

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

diff --git a/README.md b/README.md
index 6c2c9376..43e177b4 100644
--- a/README.md
+++ b/README.md
@@ -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. 

From 9cd62fe08ddce8759620a1d15a6d934a0305c447 Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Fri, 30 Dec 2022 01:03:31 +0200
Subject: [PATCH 13/29] Remove stray debug log

---
 src/services/entity_normalizer/entity_normalizer.service.js | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index eb85418e..5d129556 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -72,7 +72,6 @@ export const parseUser = (data) => {
         verified_at: field.verified_at
       }
     })
-    console.log(output.fields_html)
     output.fields_text = data.fields.map(field => {
       return {
         name: unescape(field.name.replace(/<[^>]*>/g, '')),

From 014f8b0dd274cd4fc185335679f3aab0c3acb17d Mon Sep 17 00:00:00 2001
From: Norm <normandy@biribiri.dev>
Date: Fri, 30 Dec 2022 03:01:17 +0000
Subject: [PATCH 14/29] Make minimum width for 3-column layout 1280px (#255)
 (#256)

1280px is a pretty common screen width for several resolutions
(1280x720, 1280x800, 1280x1024, etc.). Since it is only 20px less than
the current 1300px minimum, this shouldn't be a big issue to lower the
minimum screen width for the 3-column layout to 1280px.

Closes: https://akkoma.dev/AkkomaGang/pleroma-fe/issues/255

Co-authored-by: Francis Dinh <normandy@biribiri.dev>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma-fe/pulls/256
Co-authored-by: Norm <normandy@biribiri.dev>
Co-committed-by: Norm <normandy@biribiri.dev>
---
 src/components/emoji_picker/emoji_picker.scss | 2 +-
 src/modules/interface.js                      | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss
index 119da7c4..82fc831c 100644
--- a/src/components/emoji_picker/emoji_picker.scss
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -18,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%;
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)
       }
     },

From 2e5001e5de164be825fc065e0425725e4fe81219 Mon Sep 17 00:00:00 2001
From: Beefox <bee@beefox.xyz>
Date: Fri, 30 Dec 2022 03:04:15 +0000
Subject: [PATCH 15/29] Allow follow(er) lists to be acessible by account owner
 even if follower counts are disabled (#246)

Currently, if a user has their follower/follow counts hidden, they cannot access their own list of followers/follows. This makes no real sense and means that they cannot modify those lists without disabling their privacy options.

This fix simply allows those tabs to be accessed no matter if the counts are hidden or not.

Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma-fe/pulls/246
Co-authored-by: Beefox <bee@beefox.xyz>
Co-committed-by: Beefox <bee@beefox.xyz>
---
 src/components/user_profile/user_profile.vue | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 4e0d0b0f..87bbf679 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -104,10 +104,9 @@
           v-if="followsTabVisible"
           key="followees"
           :label="$t('user_card.followees')"
-          :disabled="!user.friends_count"
         >
           <FriendList :user-id="userId">
-            <template v-slot:item="{item}">
+            <template #item="{item}">
               <FollowCard :user="item" />
             </template>
           </FriendList>
@@ -116,10 +115,9 @@
           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"

From ea941d7cfa69258949df7342e43b0ecbff68dd66 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Fri, 30 Dec 2022 03:20:12 +0000
Subject: [PATCH 16/29] remove IHBA assets

---
 static/.tos     |  45 ---------------------------------------------
 static/logo.png | Bin 5976 -> 0 bytes
 2 files changed, 45 deletions(-)
 delete mode 100644 static/.tos
 delete mode 100644 static/logo.png

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/logo.png b/static/logo.png
deleted file mode 100644
index 14aaf4a3f180ca6809611f1e65279f263f3ff92e..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001

literal 5976
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%0DDrN|2
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
T#mwR&9jDQHsHa|vu#5OV`@ECO


From 313ddcebcb9524a0496050dc2b0bea4bedab78e0 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Fri, 30 Dec 2022 04:57:23 +0000
Subject: [PATCH 17/29] Add blurhash support

---
 package.json                                  |   1 +
 src/components/attachment/attachment.js       |   7 +-
 src/components/attachment/attachment.vue      |   8 +++
 src/components/blurhash/Blurhash.vue          |  66 ++++++++++++++++++
 .../settings_modal/tabs/general_tab.vue       |   9 +++
 src/i18n/en.json                              |   1 +
 src/modules/config.js                         |   3 +-
 .../entity_normalizer.service.js              |   3 +-
 static/blurhash-overlay.png                   | Bin 0 -> 27640 bytes
 yarn.lock                                     |   5 ++
 10 files changed, 100 insertions(+), 3 deletions(-)
 create mode 100644 src/components/blurhash/Blurhash.vue
 create mode 100755 static/blurhash-overlay.png

diff --git a/package.json b/package.json
index e712cffd..297a9379 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
     "@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",
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..f9c4f1c8 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"
+          :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/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/i18n/en.json b/src/i18n/en.json
index 6fbac399..46b5bad3 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"
     },
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/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 5d129556..c54ce3e2 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -234,13 +234,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
diff --git a/static/blurhash-overlay.png b/static/blurhash-overlay.png
new file mode 100755
index 0000000000000000000000000000000000000000..209a9438ffad2f53209b202de7da5c25153782f2
GIT binary patch
literal 27640
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
Z$+kaX?lR29sGW6{;g^js6<>6W_%CA}ZrK0;

literal 0
HcmV?d00001

diff --git a/yarn.lock b/yarn.lock
index bbceba0b..10533a18 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"

From dfba8be1349a633160313a619da167323c2c57f4 Mon Sep 17 00:00:00 2001
From: floatingghost <hannah@coffee-and-dreams.uk>
Date: Fri, 30 Dec 2022 05:03:25 +0000
Subject: [PATCH 18/29] Fall back to nsfw image if no blurhash

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

diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index f9c4f1c8..0ccdb776 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -65,7 +65,7 @@
         @click.prevent.stop="toggleHidden"
       >
         <Blurhash
-          v-if="useBlurhash"
+          v-if="useBlurhash && attachment.blurhash"
           :height="512"
           :width="1024"
           :hash="attachment.blurhash"

From e9f16af82d04d6f800053c98baf3fca0df453f4e Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Sun, 1 Jan 2023 20:11:07 +0000
Subject: [PATCH 19/29] Add list of followed hashtags to profile

---
 .../followed_tag_card/FollowedTagCard.vue     | 27 ++++++++++
 src/components/user_profile/user_profile.js   | 13 ++++-
 src/components/user_profile/user_profile.vue  | 35 ++++++++++--
 src/hocs/with_load_more/with_load_more.jsx    |  3 +-
 src/i18n/en.json                              |  2 +
 src/modules/users.js                          | 53 +++++++++++++++++--
 src/services/api/api.service.js               | 27 +++++++++-
 7 files changed, 147 insertions(+), 13 deletions(-)
 create mode 100644 src/components/followed_tag_card/FollowedTagCard.vue

diff --git a/src/components/followed_tag_card/FollowedTagCard.vue b/src/components/followed_tag_card/FollowedTagCard.vue
new file mode 100644
index 00000000..3ce5d8e9
--- /dev/null
+++ b/src/components/followed_tag_card/FollowedTagCard.vue
@@ -0,0 +1,27 @@
+<template>
+  <div class="followed-tag-card">
+    <h3>
+      <router-link :to="{ name: 'tag-timeline', params: {tag: tag.name}}">
+        #{{ tag.name }}
+      </router-link>
+    </h3>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'FollowedTagCard',
+  props: {
+    tag: {
+      type: Object,
+      required: true
+    }
+  },
+}
+</script>
+
+<style scoped>
+.followed-tag-card {
+  margin-left: 1rem;
+}
+</style>
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 8fba1a28..702148c1 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -13,6 +13,7 @@ import {
   faCircleNotch,
   faCircleCheck
 } from '@fortawesome/free-solid-svg-icons'
+import FollowedTagCard from '../followed_tag_card/FollowedTagCard.vue'
 
 library.add(
   faCircleNotch,
@@ -35,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 = {
@@ -202,6 +211,7 @@ const UserProfile = {
     }
   },
   components: {
+    FollowedTagCard,
     UserCard,
     Timeline,
     FollowerList,
@@ -209,7 +219,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 87bbf679..e657b8a4 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -105,11 +105,36 @@
           key="followees"
           :label="$t('user_card.followees')"
         >
-          <FriendList :user-id="userId">
-            <template #item="{item}">
-              <FollowCard :user="item" />
-            </template>
-          </FriendList>
+          <tab-switcher
+            :active-tab="users"
+            :render-only-focused="true"
+          >
+            <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"
+                :following="false"
+                :get-key="(item) => item.name"
+              >
+                <template #item="{item}">
+                  <FollowedTagCard :tag="item" />
+                </template>
+              </FollowedTagList>
+            </div>
+          </tab-switcher>
         </div>
         <div
           v-if="followersTabVisible"
diff --git a/src/hocs/with_load_more/with_load_more.jsx b/src/hocs/with_load_more/with_load_more.jsx
index c0ae1856..7960663b 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
             })
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 46b5bad3..8d9c6444 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1139,6 +1139,8 @@
         "follow_unfollow": "Unfollow",
         "followees": "Following",
         "followers": "Followers",
+        "followed_tags": "Followed hashtags",
+        "followed_users": "Followed users",
         "following": "Following!",
         "follows_you": "Follows you!",
         "hidden": "Hidden",
diff --git a/src/modules/users.js b/src/modules/users.js
index 022cc1dc..456aa746 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) {
@@ -179,6 +193,11 @@ export const mutations = {
       mergeOrAdd(state.users, state.usersObject, user)
     })
   },
+  addNewTags (state, tags) {
+    each(tags, (tag) => {
+      mergeOrAdd(state.tags, state.tagsObject, tag, 'name')
+    })
+  },
   updateUserRelationship (state, relationships) {
     relationships.forEach((relationship) => {
       state.relationships[relationship.id] = relationship
@@ -271,7 +290,11 @@ export const getters = {
   relationship: state => id => {
     const rel = id && state.relationships[id]
     return rel || { id, loading: true }
-  }
+  },
+  findTag: state => query => {
+    const result = state.tagsObject[query]
+    return result
+  },
 }
 
 export const defaultState = {
@@ -282,7 +305,9 @@ export const defaultState = {
   usersObject: {},
   signUpPending: false,
   signUpErrors: [],
-  relationships: {}
+  relationships: {},
+  tags: [],
+  tagsObject: {}
 }
 
 const users = {
@@ -402,12 +427,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 }) => {
+          commit('addNewTags', tags)
+          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]))
@@ -437,6 +477,9 @@ const users = {
     addNewUsers ({ commit }, users) {
       commit('addNewUsers', users)
     },
+    addNewTags ({ commit }, tags) {
+      commit('addNewTags', tags)
+    },
     addNewStatuses (store, { statuses }) {
       const users = map(statuses, 'user')
       const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 21c4b25b..b7174b68 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'
@@ -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
 
@@ -1575,6 +1577,28 @@ 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
+    }
+  });
+}
+
 export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
   return Object.entries({
     ...(credentials
@@ -1813,7 +1837,8 @@ const apiService = {
   deleteNoteFromReport,
   getHashtag,
   followHashtag,
-  unfollowHashtag
+  unfollowHashtag,
+  getFollowedHashtags,
 }
 
 export default apiService

From 62287fffae456164f52c3ebcaf2062445e549745 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Sun, 1 Jan 2023 21:05:25 +0000
Subject: [PATCH 20/29] add follow/unfollow to followed tags list

---
 .../followed_tag_card/FollowedTagCard.vue     | 64 +++++++++++++++++--
 src/components/user_profile/user_profile.js   |  4 ++
 src/components/user_profile/user_profile.vue  |  7 +-
 src/hocs/with_load_more/with_load_more.jsx    |  2 +-
 src/i18n/en.json                              |  4 ++
 src/modules/tags.js                           | 21 ++++--
 src/modules/users.js                          | 14 +---
 7 files changed, 86 insertions(+), 30 deletions(-)

diff --git a/src/components/followed_tag_card/FollowedTagCard.vue b/src/components/followed_tag_card/FollowedTagCard.vue
index 3ce5d8e9..d9394ddc 100644
--- a/src/components/followed_tag_card/FollowedTagCard.vue
+++ b/src/components/followed_tag_card/FollowedTagCard.vue
@@ -1,10 +1,28 @@
 <template>
   <div class="followed-tag-card">
-    <h3>
+    <span>
       <router-link :to="{ name: 'tag-timeline', params: {tag: tag.name}}">
-        #{{ tag.name }}
+        <span class="tag-link">#{{ tag.name }}</span>
       </router-link>
-    </h3>
+      <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>
 
@@ -15,13 +33,45 @@ export default {
     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;
-}
+  .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/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 702148c1..1cadddda 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -52,6 +52,7 @@ const UserProfile = {
       error: false,
       userId: null,
       tab: 'statuses',
+      followsTab: 'users',
       footerRef: null,
       note: null,
       noteLoading: false
@@ -176,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
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index e657b8a4..5465778a 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -106,8 +106,9 @@
           :label="$t('user_card.followees')"
         >
           <tab-switcher
-            :active-tab="users"
+            :active-tab="followsTab"
             :render-only-focused="true"
+            :on-switch="onFollowsTabSwitch"
           >
             <div
               key="users"
@@ -126,12 +127,14 @@
             >
               <FollowedTagList
                 :user-id="userId"
-                :following="false"
                 :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>
diff --git a/src/hocs/with_load_more/with_load_more.jsx b/src/hocs/with_load_more/with_load_more.jsx
index 7960663b..c55eccf5 100644
--- a/src/hocs/with_load_more/with_load_more.jsx
+++ b/src/hocs/with_load_more/with_load_more.jsx
@@ -89,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 8d9c6444..534f1c1c 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1057,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"
     },
@@ -1179,6 +1180,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"
diff --git a/src/modules/tags.js b/src/modules/tags.js
index cff54b7d..312f7d93 100644
--- a/src/modules/tags.js
+++ b/src/modules/tags.js
@@ -2,11 +2,18 @@ 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 }) {
+      console.log("Setting", name, {...data})
       state.tags[name] = data
     }
   },
@@ -17,17 +24,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 456aa746..c63b93de 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -193,11 +193,6 @@ export const mutations = {
       mergeOrAdd(state.users, state.usersObject, user)
     })
   },
-  addNewTags (state, tags) {
-    each(tags, (tag) => {
-      mergeOrAdd(state.tags, state.tagsObject, tag, 'name')
-    })
-  },
   updateUserRelationship (state, relationships) {
     relationships.forEach((relationship) => {
       state.relationships[relationship.id] = relationship
@@ -291,10 +286,6 @@ export const getters = {
     const rel = id && state.relationships[id]
     return rel || { id, loading: true }
   },
-  findTag: state => query => {
-    const result = state.tagsObject[query]
-    return result
-  },
 }
 
 export const defaultState = {
@@ -433,7 +424,7 @@ const users = {
 
       return rootState.api.backendInteractor.getFollowedHashtags({ pagination })
         .then(({ data: tags, pagination }) => {
-          commit('addNewTags', tags)
+          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
@@ -477,9 +468,6 @@ const users = {
     addNewUsers ({ commit }, users) {
       commit('addNewUsers', users)
     },
-    addNewTags ({ commit }, tags) {
-      commit('addNewTags', tags)
-    },
     addNewStatuses (store, { statuses }) {
       const users = map(statuses, 'user')
       const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))

From d973396c9672fa27beac31bf921073363d800228 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Sun, 1 Jan 2023 21:06:02 +0000
Subject: [PATCH 21/29] Remove console.log

---
 src/modules/tags.js | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/modules/tags.js b/src/modules/tags.js
index 312f7d93..63f6888d 100644
--- a/src/modules/tags.js
+++ b/src/modules/tags.js
@@ -13,7 +13,6 @@ const tags = {
   },
   mutations: {
     setTag (state, { name, data }) {
-      console.log("Setting", name, {...data})
       state.tags[name] = data
     }
   },

From f288d0c219beafcf98146056ea3cdc22adeeb15a Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Mon, 2 Jan 2023 15:16:42 +0000
Subject: [PATCH 22/29] Make everything work with a strict CSP

---
 index.html                                |  1 +
 package.json                              |  2 +-
 src/App.scss                              |  3 ++-
 src/boot/after_store.js                   |  2 ++
 src/components/pinch_zoom/pinch_zoom.js   |  2 +-
 src/services/style_setter/style_setter.js |  4 +---
 static/theme-holder.css                   |  1 +
 yarn.lock                                 | 14 +++++++-------
 8 files changed, 16 insertions(+), 13 deletions(-)
 create mode 100644 static/theme-holder.css

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 297a9379..efafb67b 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
     "@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",
+    "@floatingghost/pinch-zoom-element": "^1.3.1",
     "@vuelidate/core": "^2.0.0",
     "@vuelidate/validators": "^2.0.0",
     "blurhash": "^2.0.4",
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/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/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/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 10533a18..ebe66732 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"

From e9f47509ae99b14a0392c906d186b5a45feaadbf Mon Sep 17 00:00:00 2001
From: Sol Fisher Romanoff <sol@solfisher.com>
Date: Tue, 3 Jan 2023 16:04:26 +0200
Subject: [PATCH 23/29] Only show "keep open" emoji checkbox on post form

---
 src/components/emoji_input/emoji_input.vue   | 1 +
 src/components/emoji_picker/emoji_picker.js  | 5 +++++
 src/components/emoji_picker/emoji_picker.vue | 5 ++++-
 3 files changed, 10 insertions(+), 1 deletion(-)

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..fbd59946 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -27,6 +27,11 @@ const EmojiPicker = {
       required: false,
       type: Boolean,
       default: false
+    },
+    showKeepOpen: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   data () {
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
index 00ffb9d2..3ec694f0 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -84,7 +84,10 @@
             <span :ref="'group-end-' + group.id" />
           </div>
         </div>
-        <div class="keep-open">
+        <div
+          v-if="showKeepOpen"
+          class="keep-open"
+        >
           <Checkbox v-model="keepOpen">
             {{ $t('emoji.keep_open') }}
           </Checkbox>

From 42dc1a027ad826a32b0e79dcceba7196d5b3acc6 Mon Sep 17 00:00:00 2001
From: FloatingGhost <hannah@coffee-and-dreams.uk>
Date: Sun, 15 Jan 2023 17:59:32 +0000
Subject: [PATCH 24/29] add language input

---
 package.json                                    |  4 ++--
 src/components/extra_buttons/extra_buttons.js   |  1 +
 .../post_status_form/post_status_form.js        | 14 ++++++++++++--
 .../post_status_form/post_status_form.vue       | 17 +++++++++++++++++
 src/services/api/api.service.js                 |  4 +++-
 .../status_poster/status_poster.service.js      |  6 ++++--
 yarn.lock                                       |  4 ++--
 7 files changed, 41 insertions(+), 9 deletions(-)

diff --git a/package.json b/package.json
index efafb67b..4bb94fcd 100644
--- a/package.json
+++ b/package.json
@@ -18,11 +18,11 @@
   "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",
-    "@floatingghost/pinch-zoom-element": "^1.3.1",
     "@vuelidate/core": "^2.0.0",
     "@vuelidate/validators": "^2.0.0",
     "blurhash": "^2.0.4",
@@ -32,6 +32,7 @@
     "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",
@@ -83,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/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/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/services/api/api.service.js b/src/services/api/api.service.js
index b7174b68..0f8b75a4 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -880,7 +880,8 @@ const postStatus = ({
   quoteId,
   contentType,
   preview,
-  idempotencyKey
+  idempotencyKey,
+  language
 }) => {
   const form = new FormData()
   const pollOptions = poll.options || []
@@ -891,6 +892,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)
   })
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/yarn.lock b/yarn.lock
index ebe66732..7027b7d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5095,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:

From a9a95e9120292f428a2e6abbff2cfffd0f427fb8 Mon Sep 17 00:00:00 2001
From: eris <femmediscord@gmail.com>
Date: Thu, 26 Jan 2023 20:49:07 +0000
Subject: [PATCH 25/29] Add indicator if user blocks you

---
 src/components/user_card/user_card.scss | 2 +-
 src/components/user_card/user_card.vue  | 6 ++++++
 src/i18n/en.json                        | 1 +
 3 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss
index 2cae1c35..0a5e744e 100644
--- a/src/components/user_card/user_card.scss
+++ b/src/components/user_card/user_card.scss
@@ -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..6a9d093d 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"
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 534f1c1c..46890345 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1123,6 +1123,7 @@
         "block_confirm_title": "Block user",
         "block_progress": "Blocking…",
         "blocked": "Blocked!",
+        "blocks_you": "Blocks you!",
         "bot": "Bot",
         "deactivated": "Deactivated",
         "deny": "Deny",

From 7f4dd9ff037a98192c546426e05492a98c536d51 Mon Sep 17 00:00:00 2001
From: eris <femmediscord@gmail.com>
Date: Fri, 27 Jan 2023 00:26:50 +0000
Subject: [PATCH 26/29] Disable follow button if blocked by user

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

diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 6a9d093d..289db15b 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -193,6 +193,7 @@
             <FollowButton
               :relationship="relationship"
               :user="user"
+              :disabled="relationship.blocked_by"
             />
             <template v-if="relationship.following">
               <ProgressButton

From 88d5149db5948e19dbe66406d00e0e5167237a4f Mon Sep 17 00:00:00 2001
From: floatingghost <hannah@coffee-and-dreams.uk>
Date: Sat, 4 Feb 2023 21:09:09 +0000
Subject: [PATCH 27/29] paginate-follow-requests (#277)

Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma-fe/pulls/277
---
 .../follow_request_card.js                    |  7 ++++
 .../follow_request_card.vue                   |  2 +-
 .../follow_requests/follow_requests.js        | 18 +++++++-
 .../follow_requests/follow_requests.vue       | 11 +++--
 src/components/nav_panel/nav_panel.js         | 11 ++---
 src/components/user_profile/user_profile.js   |  2 +-
 src/modules/api.js                            | 41 +++++++++++--------
 src/modules/users.js                          | 13 +++++-
 src/services/api/api.service.js               | 30 ++++++++++----
 .../backend_interactor_service.js             |  5 ---
 .../entity_normalizer.service.js              |  1 +
 .../follow_request_fetcher.service.js         | 23 -----------
 12 files changed, 92 insertions(+), 72 deletions(-)
 delete mode 100644 src/services/follow_request_fetcher/follow_request_fetcher.service.js

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/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/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 1cadddda..9ea8c2a7 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -224,7 +224,7 @@ const UserProfile = {
     TabSwitcher,
     Conversation,
     RichContent,
-    FollowedTagList,
+    FollowedTagList
   }
 }
 
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/users.js b/src/modules/users.js
index c63b93de..bc1943c8 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -265,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++
   }
 }
 
@@ -504,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) => {
@@ -567,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 0f8b75a4..947e9da9 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -406,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) })
@@ -1601,6 +1593,26 @@ const getFollowedHashtags = ({ credentials, pagination: savedPagination }) => {
   });
 }
 
+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
@@ -1790,7 +1802,6 @@ const apiService = {
   mfaConfirmOTP,
   addBackup,
   listBackups,
-  fetchFollowRequests,
   fetchLists,
   createList,
   getList,
@@ -1841,6 +1852,7 @@ const apiService = {
   followHashtag,
   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 c54ce3e2..e330ca8c 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -90,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
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

From 9e04e4fd80c9396897df26e50261e41b25a15ffc Mon Sep 17 00:00:00 2001
From: yanchan09 <yan@omg.lol>
Date: Sat, 4 Feb 2023 21:10:06 +0000
Subject: [PATCH 28/29] Improve emoji picker performance (#275)

A simple virtual scroller is now used for the emoji grid. This avoids loading all emoji images at once, saving network bandwidth and reducing load on the server, while also putting less work on the browser's DOM and layout engine.

Co-authored-by: yan <yan@omg.lol>
Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma-fe/pulls/275
Co-authored-by: yanchan09 <yan@omg.lol>
Co-committed-by: yanchan09 <yan@omg.lol>
---
 src/components/emoji_grid/emoji_grid.js       | 133 ++++++++++++++++++
 src/components/emoji_grid/emoji_grid.scss     |  60 ++++++++
 src/components/emoji_grid/emoji_grid.vue      |  48 +++++++
 src/components/emoji_input/emoji_input.js     |   2 -
 src/components/emoji_picker/emoji_picker.js   |  97 ++-----------
 src/components/emoji_picker/emoji_picker.scss |  78 +---------
 src/components/emoji_picker/emoji_picker.vue  |  41 +-----
 7 files changed, 261 insertions(+), 198 deletions(-)
 create mode 100644 src/components/emoji_grid/emoji_grid.js
 create mode 100644 src/components/emoji_grid/emoji_grid.scss
 create mode 100644 src/components/emoji_grid/emoji_grid.vue

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_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index fbd59946..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,13 +15,6 @@ 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: {
@@ -39,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) {
@@ -61,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)
@@ -74,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
@@ -151,13 +80,6 @@ const EmojiPicker = {
       })
     }
   },
-  watch: {
-    keyword () {
-      this.customEmojiLoadAllConfirmed = false
-      this.onScroll()
-      this.startEmojiLoad(true)
-    }
-  },
   computed: {
     activeGroupView () {
       return this.showingStickers ? '' : this.activeGroup
@@ -173,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 82fc831c..6ce8cbd8 100644
--- a/src/components/emoji_picker/emoji_picker.scss
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -85,10 +85,6 @@
     flex-grow: 1;
   }
 
-  .emoji-groups {
-    min-height: 200px;
-  }
-
   .additional-tabs {
     border-left: 1px solid;
     border-left-color: $fallback--icon;
@@ -167,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 3ec694f0..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,39 +51,12 @@
             @input="$event.target.composing = false"
           >
         </div>
-        <div
-          ref="emoji-groups"
-          class="emoji-groups"
-          :class="groupsScrolledClass"
-          @scroll="onScroll"
-        >
-          <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>
+        <EmojiGrid
+          ref="emojiGrid"
+          :groups="emojisView"
+          @emoji="onEmoji"
+          @active-group="onActiveGroup"
+        />
         <div
           v-if="showKeepOpen"
           class="keep-open"

From 581c53a15ea16334b3668d83b24e2fce54b8b321 Mon Sep 17 00:00:00 2001
From: a1batross <a1ba.omarov@gmail.com>
Date: Fri, 10 Feb 2023 23:28:46 +0000
Subject: [PATCH 29/29] components: emoji_reactions: force custom emoji
 reaction height

Prevents the usage of too long emoji reactions
---
 src/components/emoji_reactions/emoji_reactions.vue | 2 ++
 1 file changed, 2 insertions(+)

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 {