From de945ba3e9470b28dd010fb32f658b42053f19d3 Mon Sep 17 00:00:00 2001
From: Shpuld Shpuldson <shpuld@shpposter.club>
Date: Fri, 15 Nov 2019 16:29:25 +0200
Subject: wip commit, add basic popover for emoji reaction select

---
 src/components/react_button/react_button.vue | 78 ++++++++++++++++++++++++++++
 1 file changed, 78 insertions(+)
 create mode 100644 src/components/react_button/react_button.vue

(limited to 'src/components/react_button/react_button.vue')

diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
new file mode 100644
index 00000000..93638770
--- /dev/null
+++ b/src/components/react_button/react_button.vue
@@ -0,0 +1,78 @@
+<template>
+  <v-popover
+    :popper-options="popperOptions"
+    :open="showTooltip"
+    trigger="manual"
+    placement="top"
+    class="react-button-popover"
+    @close-group="closeReactionSelect"
+  >
+    <div slot="popover">
+      <div class="reaction-picker">
+        <span
+          v-for="(emoji, key) in emojis"
+          :key="key"
+          class="emoji-reaction-button"
+        >
+          {{ emoji.replacement }}
+        </span>
+        <div class="reaction-bottom-fader" />
+      </div>
+    </div>
+    <div @click.prevent="openReactionSelect" v-if="loggedIn">
+      <i
+        :class="classes"
+        class="button-icon favorite-button fav-active"
+        :title="$t('tool_tip.add_reaction')"
+      />
+      <span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
+    </div>
+  </v-popover>
+</template>
+
+<script src="./react_button.js" ></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.reaction-picker {
+  width: 10em;
+  height: 8em;
+  font-size: 1.5em;
+  overflow-y: scroll;
+  display: flex;
+  flex-wrap: wrap;
+  padding: 0.5em;
+  text-align:center;
+
+  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;
+}
+
+.emoji-reaction-button {
+  flex-basis: 20%;
+  line-height: 1.5em;
+  align-content: center;
+}
+
+.fav-active {
+  cursor: pointer;
+  animation-duration: 0.6s;
+
+  &:hover {
+    color: $fallback--cOrange;
+    color: var(--cOrange, $fallback--cOrange);
+  }
+}
+
+.favorite-button.icon-star {
+  color: $fallback--cOrange;
+  color: var(--cOrange, $fallback--cOrange);
+}
+</style>
-- 
cgit v1.2.3-70-g09d2


From 33abbed5a1e1d1cf99d21d481b2a22481d7533b2 Mon Sep 17 00:00:00 2001
From: Shpuld Shpuldson <shpuld@shpposter.club>
Date: Mon, 13 Jan 2020 23:34:39 +0200
Subject: usable-but-buggy: picker, adding/removing reaction on click, search,
 styles

---
 src/components/react_button/react_button.js        | 25 +++++----
 src/components/react_button/react_button.vue       | 37 +++++++++++--
 src/components/status/status.js                    | 21 ++++++--
 src/components/status/status.vue                   | 24 +++++++--
 src/modules/statuses.js                            | 61 ++++++++++++++++++++--
 src/services/api/api.service.js                    | 22 ++++++++
 .../backend_interactor_service.js                  |  4 ++
 7 files changed, 166 insertions(+), 28 deletions(-)

(limited to 'src/components/react_button/react_button.vue')

diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index d1d15d93..76a49305 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -6,6 +6,7 @@ const ReactButton = {
     return {
       animated: false,
       showTooltip: false,
+      filterWord: '',
       popperOptions: {
         modifiers: {
           preventOverflow: { padding: { top: 50 }, boundariesElement: 'viewport' }
@@ -14,27 +15,25 @@ const ReactButton = {
     }
   },
   methods: {
-    openReactionSelect () {
-      console.log('test')
-      this.showTooltip = true
+    toggleReactionSelect () {
+      this.showTooltip = !this.showTooltip
     },
     closeReactionSelect () {
       this.showTooltip = false
     },
-    favorite () {
-      if (!this.status.favorited) {
-        this.$store.dispatch('favorite', { id: this.status.id })
-      } else {
-        this.$store.dispatch('unfavorite', { id: this.status.id })
-      }
-      this.animated = true
-      setTimeout(() => {
-        this.animated = false
-      }, 500)
+    addReaction (event, emoji) {
+      this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
+      this.closeReactionSelect()
     }
   },
   computed: {
+    commonEmojis () {
+      return ['💖', '😠', '👀', '😂', '🔥']
+    },
     emojis () {
+      if (this.filterWord !== '') {
+        return this.$store.state.instance.emoji.filter(emoji => emoji.displayText.includes(this.filterWord))
+      }
       return this.$store.state.instance.emoji || []
     },
     classes () {
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index 93638770..f7015316 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -5,21 +5,37 @@
     trigger="manual"
     placement="top"
     class="react-button-popover"
-    @close-group="closeReactionSelect"
+    @hide="closeReactionSelect"
   >
     <div slot="popover">
+      <div class="reaction-picker-filter">
+        <input v-model="filterWord" placeholder="Search...">
+      </div>
       <div class="reaction-picker">
+        <span
+          v-for="emoji in commonEmojis"
+          :key="emoji"
+          class="emoji-reaction-button"
+          @click="addReaction($event, emoji)"
+        >
+          {{ emoji }}
+        </span>
+        <div class="reaction-picker-divider" />
         <span
           v-for="(emoji, key) in emojis"
           :key="key"
           class="emoji-reaction-button"
+          @click="addReaction($event, emoji.replacement)"
         >
           {{ emoji.replacement }}
         </span>
         <div class="reaction-bottom-fader" />
       </div>
     </div>
-    <div @click.prevent="openReactionSelect" v-if="loggedIn">
+    <div
+      v-if="loggedIn"
+      @click.prevent="toggleReactionSelect"
+    >
       <i
         :class="classes"
         class="button-icon favorite-button fav-active"
@@ -35,15 +51,28 @@
 <style lang="scss">
 @import '../../_variables.scss';
 
+.reaction-picker-filter {
+  padding: 0.5em;
+}
+
+.reaction-picker-divider {
+  height: 1px;
+  width: 100%;
+  margin: 0.4em;
+  background-color: var(--border, $fallback--border);
+}
+
 .reaction-picker {
   width: 10em;
-  height: 8em;
+  height: 9em;
   font-size: 1.5em;
   overflow-y: scroll;
   display: flex;
   flex-wrap: wrap;
   padding: 0.5em;
-  text-align:center;
+  text-align: center;
+  align-content: flex-start;
+  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,
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 8c6fc0cf..cc0c9e06 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -280,10 +280,7 @@ const Status = {
       return this.mergedConfig.hidePostStats
     },
     emojiReactions () {
-      return {
-        '🤔': [{ 'id': 'xyz..' }, { 'id': 'zyx...' }],
-        '🐻': [{ 'id': 'abc...' }]
-      }
+      return this.status.emojiReactions
     },
     ...mapGetters(['mergedConfig'])
   },
@@ -385,6 +382,22 @@ const Status = {
     setMedia () {
       const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
       return () => this.$store.dispatch('setMedia', attachments)
+    },
+    reactedWith (emoji) {
+      return this.status.reactedWithEmoji.includes(emoji)
+    },
+    reactWith (emoji) {
+      this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
+    },
+    unreact (emoji) {
+      this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
+    },
+    emojiOnClick (emoji, event) {
+      if (this.reactedWith(emoji)) {
+        this.unreact(emoji)
+      } else {
+        this.reactWith(emoji)
+      }
     }
   },
   watch: {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index d455ccf6..503de98d 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -354,13 +354,15 @@
             </div>
           </transition>
 
-          <div class="emoji-reactions">
+          <div v-if="isFocused" class="emoji-reactions">
             <button
               v-for="(users, emoji) in emojiReactions"
               :key="emoji"
               class="emoji-reaction btn btn-default"
+              :class="{ 'picked-reaction': reactedWith(emoji) }"
+              @click="emojiOnClick(emoji, $event)"
             >
-              <span>{{ users.length }}</span>
+              <span v-if="users">{{ users.length }}</span>
               <span>{{ emoji }}</span>
             </button>
           </div>
@@ -788,19 +790,33 @@ $status-margin: 0.75em;
 
 .emoji-reactions {
   display: flex;
-  margin-top: 0.75em;
+  margin-top: 0.25em;
+  flex-wrap: wrap;
 }
 
 .emoji-reaction {
   padding: 0 0.5em;
   margin-right: 0.5em;
+  margin-top: 0.5em;
   display: flex;
   align-items: center;
   justify-content: center;
-
+  box-sizing: border-box;
   :first-child {
     margin-right: 0.25em;
   }
+  :last-child {
+    width: 1.5em;
+  }
+  &:focus {
+    outline: none;
+  }
+}
+
+.picked-reaction {
+  border: 1px solid var(--link, $fallback--link);
+  margin-left: -1px; // offset the border, can't use inset shadows either
+  margin-right: calc(0.5em - 1px);
 }
 
 .button-icon.icon-reply {
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index c285b452..fcb6d1f3 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -1,4 +1,20 @@
-import { remove, slice, each, findIndex, find, maxBy, minBy, merge, first, last, isArray, omitBy, findKey } from 'lodash'
+import {
+  remove,
+  slice,
+  each,
+  findIndex,
+  find,
+  maxBy,
+  minBy,
+  merge,
+  first,
+  last,
+  isArray,
+  omitBy,
+  flow,
+  filter,
+  keys
+} from 'lodash'
 import { set } from 'vue'
 import apiService from '../services/api/api.service.js'
 // import parse from '../services/status_parser/status_parser.js'
@@ -512,8 +528,29 @@ export const mutations = {
   },
   addEmojiReactions (state, { id, emojiReactions, currentUser }) {
     const status = state.allStatusesObject[id]
-    status.emojiReactions = emojiReactions
-    status.reactedWithEmoji = findKey(emojiReactions, { id: currentUser.id })
+    set(status, 'emojiReactions', emojiReactions)
+    const reactedWithEmoji = flow(keys, filter(reaction => find(reaction, { id: currentUser.id })))(emojiReactions)
+    set(status, 'reactedWithEmoji', reactedWithEmoji)
+  },
+  addOwnReaction (state, { id, emoji, currentUser }) {
+    const status = state.allStatusesObject[id]
+    status.emojiReactions = status.emojiReactions || {}
+    const listOfUsers = (status.emojiReactions && status.emojiReactions[emoji]) || []
+    const hasSelfAlready = !!find(listOfUsers, { id: currentUser.id })
+    if (!hasSelfAlready) {
+      set(status.emojiReactions, emoji, listOfUsers.concat([{ id: currentUser.id }]))
+      set(status, 'reactedWithEmoji', emoji)
+    }
+  },
+  removeOwnReaction (state, { id, emoji, currentUser }) {
+    const status = state.allStatusesObject[id]
+    const listOfUsers = status.emojiReactions[emoji] || []
+    const hasSelfAlready = !!find(listOfUsers, { id: currentUser.id })
+    if (hasSelfAlready) {
+      const newUsers = filter(listOfUsers, user => user.id !== currentUser.id)
+      set(status.emojiReactions, emoji, newUsers)
+      set(status, 'reactedWith', emoji)
+    }
   },
   updateStatusWithPoll (state, { id, poll }) {
     const status = state.allStatusesObject[id]
@@ -616,6 +653,24 @@ const statuses = {
         commit('addRepeats', { id, rebloggedByUsers, currentUser: rootState.users.currentUser })
       })
     },
+    reactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
+      const currentUser = rootState.users.currentUser
+      commit('addOwnReaction', { id, emoji, currentUser })
+      rootState.api.backendInteractor.reactWithEmoji(id, emoji).then(
+        status => {
+          dispatch('fetchEmojiReactions', id)
+        }
+      )
+    },
+    unreactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
+      const currentUser = rootState.users.currentUser
+      commit('removeOwnReaction', { id, emoji, currentUser })
+      rootState.api.backendInteractor.unreactWithEmoji(id, emoji).then(
+        status => {
+          dispatch('fetchEmojiReactions', id)
+        }
+      )
+    },
     fetchEmojiReactions ({ rootState, commit }, id) {
       rootState.api.backendInteractor.fetchEmojiReactions(id).then(
         emojiReactions => {
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 7ef4b74a..2e96264a 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -72,6 +72,8 @@ const MASTODON_UNMUTE_CONVERSATION = id => `/api/v1/statuses/${id}/unmute`
 const MASTODON_SEARCH_2 = `/api/v2/search`
 const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
 const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/emoji_reactions_by`
+const PLEROMA_EMOJI_REACT_URL = id => `/api/v1/pleroma/statuses/${id}/react_with_emoji`
+const PLEROMA_EMOJI_UNREACT_URL = id => `/api/v1/pleroma/statuses/${id}/unreact_with_emoji`
 
 const oldfetch = window.fetch
 
@@ -869,6 +871,24 @@ const fetchEmojiReactions = ({ id }) => {
   return promisedRequest({ url: PLEROMA_EMOJI_REACTIONS_URL(id) })
 }
 
+const reactWithEmoji = ({ id, emoji, credentials }) => {
+  return promisedRequest({
+    url: PLEROMA_EMOJI_REACT_URL(id),
+    method: 'POST',
+    credentials,
+    payload: { emoji }
+  }).then(status => parseStatus(status))
+}
+
+const unreactWithEmoji = ({ id, emoji, credentials }) => {
+  return promisedRequest({
+    url: PLEROMA_EMOJI_UNREACT_URL(id),
+    method: 'POST',
+    credentials,
+    payload: { emoji }
+  }).then(parseStatus)
+}
+
 const reportUser = ({ credentials, userId, statusIds, comment, forward }) => {
   return promisedRequest({
     url: MASTODON_REPORT_USER_URL,
@@ -1003,6 +1023,8 @@ const apiService = {
   fetchFavoritedByUsers,
   fetchRebloggedByUsers,
   fetchEmojiReactions,
+  reactWithEmoji,
+  unreactWithEmoji,
   reportUser,
   updateNotificationSettings,
   search2,
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index 52234fcc..44233a24 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -144,6 +144,8 @@ const backendInteractorService = credentials => {
   const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({ id })
   const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({ id })
   const fetchEmojiReactions = (id) => apiService.fetchEmojiReactions({ id })
+  const reactWithEmoji = (id, emoji) => apiService.reactWithEmoji({ id, emoji, credentials })
+  const unreactWithEmoji = (id, emoji) => apiService.unreactWithEmoji({ id, emoji, credentials })
   const reportUser = (params) => apiService.reportUser({ credentials, ...params })
 
   const favorite = (id) => apiService.favorite({ id, credentials })
@@ -212,6 +214,8 @@ const backendInteractorService = credentials => {
     fetchFavoritedByUsers,
     fetchRebloggedByUsers,
     fetchEmojiReactions,
+    reactWithEmoji,
+    unreactWithEmoji,
     reportUser,
     favorite,
     unfavorite,
-- 
cgit v1.2.3-70-g09d2


From b10b92a876eb185a88e751d028e69063c9117298 Mon Sep 17 00:00:00 2001
From: Shpuld Shpuldson <shpuld@shpposter.club>
Date: Tue, 14 Jan 2020 10:06:14 +0200
Subject: clean up code, fix prediction bug

---
 src/components/emoji_reactions/emoji_reactions.js  | 30 +++++++++++++
 src/components/emoji_reactions/emoji_reactions.vue | 51 ++++++++++++++++++++++
 src/components/react_button/react_button.js        |  5 ++-
 src/components/react_button/react_button.vue       | 42 ++++++++++--------
 src/components/status/status.js                    | 23 ++--------
 src/components/status/status.vue                   | 47 ++------------------
 src/modules/statuses.js                            |  9 ++--
 7 files changed, 120 insertions(+), 87 deletions(-)
 create mode 100644 src/components/emoji_reactions/emoji_reactions.js
 create mode 100644 src/components/emoji_reactions/emoji_reactions.vue

(limited to 'src/components/react_button/react_button.vue')

diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js
new file mode 100644
index 00000000..e81e6e25
--- /dev/null
+++ b/src/components/emoji_reactions/emoji_reactions.js
@@ -0,0 +1,30 @@
+
+const EmojiReactions = {
+  name: 'EmojiReactions',
+  props: ['status'],
+  computed: {
+    emojiReactions () {
+      return this.status.emojiReactions
+    }
+  },
+  methods: {
+    reactedWith (emoji) {
+      return this.status.reactedWithEmoji.includes(emoji)
+    },
+    reactWith (emoji) {
+      this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
+    },
+    unreact (emoji) {
+      this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
+    },
+    emojiOnClick (emoji, event) {
+      if (this.reactedWith(emoji)) {
+        this.unreact(emoji)
+      } else {
+        this.reactWith(emoji)
+      }
+    }
+  }
+}
+
+export default EmojiReactions
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
new file mode 100644
index 00000000..d83f60b6
--- /dev/null
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -0,0 +1,51 @@
+<template>
+  <div class="emoji-reactions">
+    <button
+      v-for="(users, emoji) in emojiReactions"
+      :key="emoji"
+      class="emoji-reaction btn btn-default"
+      :class="{ 'picked-reaction': reactedWith(emoji) }"
+      @click="emojiOnClick(emoji, $event)"
+    >
+      <span v-if="users">{{ users.length }}</span>
+      <span>{{ emoji }}</span>
+    </button>
+  </div>
+</template>
+
+<script src="./emoji_reactions.js" ></script>
+<style lang="scss">
+@import '../../_variables.scss';
+
+.emoji-reactions {
+  display: flex;
+  margin-top: 0.25em;
+  flex-wrap: wrap;
+}
+
+.emoji-reaction {
+  padding: 0 0.5em;
+  margin-right: 0.5em;
+  margin-top: 0.5em;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+  :first-child {
+    margin-right: 0.25em;
+  }
+  :last-child {
+    width: 1.5em;
+  }
+  &:focus {
+    outline: none;
+  }
+}
+
+.picked-reaction {
+  border: 1px solid var(--link, $fallback--link);
+  margin-left: -1px; // offset the border, can't use inset shadows either
+  margin-right: calc(0.5em - 1px);
+}
+
+</style>
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index 76a49305..d1a179bc 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -15,8 +15,9 @@ const ReactButton = {
     }
   },
   methods: {
-    toggleReactionSelect () {
-      this.showTooltip = !this.showTooltip
+    openReactionSelect () {
+      this.showTooltip = true
+      this.filterWord = ''
     },
     closeReactionSelect () {
       this.showTooltip = false
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index f7015316..ae975dee 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -9,13 +9,16 @@
   >
     <div slot="popover">
       <div class="reaction-picker-filter">
-        <input v-model="filterWord" placeholder="Search...">
+        <input
+          v-model="filterWord"
+          :placeholder="$t('emoji.search_emoji')"
+        >
       </div>
       <div class="reaction-picker">
         <span
           v-for="emoji in commonEmojis"
           :key="emoji"
-          class="emoji-reaction-button"
+          class="emoji-button"
           @click="addReaction($event, emoji)"
         >
           {{ emoji }}
@@ -24,7 +27,7 @@
         <span
           v-for="(emoji, key) in emojis"
           :key="key"
-          class="emoji-reaction-button"
+          class="emoji-button"
           @click="addReaction($event, emoji.replacement)"
         >
           {{ emoji.replacement }}
@@ -34,11 +37,11 @@
     </div>
     <div
       v-if="loggedIn"
-      @click.prevent="toggleReactionSelect"
+      @click.prevent="openReactionSelect"
     >
       <i
         :class="classes"
-        class="button-icon favorite-button fav-active"
+        class="button-icon add-reaction-button"
         :title="$t('tool_tip.add_reaction')"
       />
       <span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
@@ -58,7 +61,7 @@
 .reaction-picker-divider {
   height: 1px;
   width: 100%;
-  margin: 0.4em;
+  margin: 0.5em;
   background-color: var(--border, $fallback--border);
 }
 
@@ -82,26 +85,27 @@
   // Autoprefixed seem to ignore this one, and also syntax is different
   -webkit-mask-composite: xor;
   mask-composite: exclude;
-}
 
-.emoji-reaction-button {
-  flex-basis: 20%;
-  line-height: 1.5em;
-  align-content: center;
+  .emoji-button {
+    cursor: pointer;
+
+    flex-basis: 20%;
+    line-height: 1.5em;
+    align-content: center;
+
+    &:hover {
+      transform: scale(1.25);
+    }
+  }
 }
 
-.fav-active {
+.add-reaction-button {
   cursor: pointer;
-  animation-duration: 0.6s;
 
   &:hover {
-    color: $fallback--cOrange;
-    color: var(--cOrange, $fallback--cOrange);
+    color: $fallback--text;
+    color: var(--text, $fallback--text);
   }
 }
 
-.favorite-button.icon-star {
-  color: $fallback--cOrange;
-  color: var(--cOrange, $fallback--cOrange);
-}
 </style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 18617938..81b57667 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -12,6 +12,7 @@ import LinkPreview from '../link-preview/link-preview.vue'
 import AvatarList from '../avatar_list/avatar_list.vue'
 import Timeago from '../timeago/timeago.vue'
 import StatusPopover from '../status_popover/status_popover.vue'
+import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import fileType from 'src/services/file_type/file_type.service'
 import { processHtml } from 'src/services/tiny_post_html_processor/tiny_post_html_processor.service.js'
@@ -311,9 +312,6 @@ const Status = {
     hidePostStats () {
       return this.mergedConfig.hidePostStats
     },
-    emojiReactions () {
-      return this.status.emojiReactions
-    },
     ...mapGetters(['mergedConfig']),
     ...mapState({
       betterShadow: state => state.interface.browserSupport.cssFilter,
@@ -334,7 +332,8 @@ const Status = {
     LinkPreview,
     AvatarList,
     Timeago,
-    StatusPopover
+    StatusPopover,
+    EmojiReactions
   },
   methods: {
     visibilityIcon (visibility) {
@@ -418,22 +417,6 @@ const Status = {
     setMedia () {
       const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
       return () => this.$store.dispatch('setMedia', attachments)
-    },
-    reactedWith (emoji) {
-      return this.status.reactedWithEmoji.includes(emoji)
-    },
-    reactWith (emoji) {
-      this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
-    },
-    unreact (emoji) {
-      this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
-    },
-    emojiOnClick (emoji, event) {
-      if (this.reactedWith(emoji)) {
-        this.unreact(emoji)
-      } else {
-        this.reactWith(emoji)
-      }
     }
   },
   watch: {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 4ea1b74b..87e8b5da 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -354,18 +354,10 @@
             </div>
           </transition>
 
-          <div v-if="isFocused" class="emoji-reactions">
-            <button
-              v-for="(users, emoji) in emojiReactions"
-              :key="emoji"
-              class="emoji-reaction btn btn-default"
-              :class="{ 'picked-reaction': reactedWith(emoji) }"
-              @click="emojiOnClick(emoji, $event)"
-            >
-              <span v-if="users">{{ users.length }}</span>
-              <span>{{ emoji }}</span>
-            </button>
-          </div>
+          <EmojiReactions
+            v-if="isFocused"
+            :status="status"
+          />
 
           <div
             v-if="!noHeading && !isPreview"
@@ -789,37 +781,6 @@ $status-margin: 0.75em;
   }
 }
 
-.emoji-reactions {
-  display: flex;
-  margin-top: 0.25em;
-  flex-wrap: wrap;
-}
-
-.emoji-reaction {
-  padding: 0 0.5em;
-  margin-right: 0.5em;
-  margin-top: 0.5em;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  box-sizing: border-box;
-  :first-child {
-    margin-right: 0.25em;
-  }
-  :last-child {
-    width: 1.5em;
-  }
-  &:focus {
-    outline: none;
-  }
-}
-
-.picked-reaction {
-  border: 1px solid var(--link, $fallback--link);
-  margin-left: -1px; // offset the border, can't use inset shadows either
-  margin-right: calc(0.5em - 1px);
-}
-
 .button-icon.icon-reply {
   &:not(.button-icon-disabled):hover,
   &.button-icon-active {
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index ae6f6853..dbae9d38 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -537,7 +537,10 @@ export const mutations = {
   addEmojiReactions (state, { id, emojiReactions, currentUser }) {
     const status = state.allStatusesObject[id]
     set(status, 'emojiReactions', emojiReactions)
-    const reactedWithEmoji = flow(keys, filter(reaction => find(reaction, { id: currentUser.id })))(emojiReactions)
+    const reactedWithEmoji = flow(
+      keys,
+      filter(reaction => find(reaction, { id: currentUser.id }))
+    )(emojiReactions)
     set(status, 'reactedWithEmoji', reactedWithEmoji)
   },
   addOwnReaction (state, { id, emoji, currentUser }) {
@@ -547,7 +550,7 @@ export const mutations = {
     const hasSelfAlready = !!find(listOfUsers, { id: currentUser.id })
     if (!hasSelfAlready) {
       set(status.emojiReactions, emoji, listOfUsers.concat([{ id: currentUser.id }]))
-      set(status, 'reactedWithEmoji', emoji)
+      set(status, 'reactedWithEmoji', [...status.reactedWithEmoji, emoji])
     }
   },
   removeOwnReaction (state, { id, emoji, currentUser }) {
@@ -557,7 +560,7 @@ export const mutations = {
     if (hasSelfAlready) {
       const newUsers = filter(listOfUsers, user => user.id !== currentUser.id)
       set(status.emojiReactions, emoji, newUsers)
-      set(status, 'reactedWith', emoji)
+      set(status, 'reactedWithEmoji', status.reactedWithEmoji.filter(e => e !== emoji))
     }
   },
   updateStatusWithPoll (state, { id, poll }) {
-- 
cgit v1.2.3-70-g09d2


From 0de627baae53d4d284920c1f6d7daf64769be4a6 Mon Sep 17 00:00:00 2001
From: Shpuld Shpuldson <shpuld@shpposter.club>
Date: Sun, 26 Jan 2020 16:18:57 +0200
Subject: remove favs count from react button

---
 src/components/react_button/react_button.vue | 1 -
 1 file changed, 1 deletion(-)

(limited to 'src/components/react_button/react_button.vue')

diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index ae975dee..7f1bc492 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -44,7 +44,6 @@
         class="button-icon add-reaction-button"
         :title="$t('tool_tip.add_reaction')"
       />
-      <span v-if="!mergedConfig.hidePostStats && status.fave_num > 0">{{ status.fave_num }}</span>
     </div>
   </v-popover>
 </template>
-- 
cgit v1.2.3-70-g09d2


From 6afff4f8c205ec70d3694564c706f6a46a61db9e Mon Sep 17 00:00:00 2001
From: Shpuld Shpuldson <shpuld@shpposter.club>
Date: Tue, 28 Jan 2020 17:09:25 +0200
Subject: review changes

---
 src/components/emoji_reactions/emoji_reactions.vue          | 6 +++---
 src/components/react_button/react_button.js                 | 9 +--------
 src/components/react_button/react_button.vue                | 3 +--
 src/services/entity_normalizer/entity_normalizer.service.js | 2 +-
 4 files changed, 6 insertions(+), 14 deletions(-)

(limited to 'src/components/react_button/react_button.vue')

diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index 8a229240..741fc11e 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -7,8 +7,8 @@
       :class="{ 'picked-reaction': reactedWith(reaction.emoji) }"
       @click="emojiOnClick(reaction.emoji, $event)"
     >
-      <span>{{ reaction.count }}</span>
       <span>{{ reaction.emoji }}</span>
+      <span>{{ reaction.count }}</span>
     </button>
   </div>
 </template>
@@ -31,10 +31,10 @@
   align-items: center;
   justify-content: center;
   box-sizing: border-box;
-  :first-child {
+  &:first-child {
     margin-right: 0.25em;
   }
-  :last-child {
+  &:last-child {
     width: 1.5em;
   }
   &:focus {
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index d1a179bc..6fb2a780 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -4,7 +4,6 @@ const ReactButton = {
   props: ['status', 'loggedIn'],
   data () {
     return {
-      animated: false,
       showTooltip: false,
       filterWord: '',
       popperOptions: {
@@ -29,7 +28,7 @@ const ReactButton = {
   },
   computed: {
     commonEmojis () {
-      return ['💖', '😠', '👀', '😂', '🔥']
+      return ['❤️', '😠', '👀', '😂', '🔥']
     },
     emojis () {
       if (this.filterWord !== '') {
@@ -37,12 +36,6 @@ const ReactButton = {
       }
       return this.$store.state.instance.emoji || []
     },
-    classes () {
-      return {
-        'icon-smile': true,
-        'animate-spin': this.animated
-      }
-    },
     ...mapGetters(['mergedConfig'])
   }
 }
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index 7f1bc492..c925dd71 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -40,8 +40,7 @@
       @click.prevent="openReactionSelect"
     >
       <i
-        :class="classes"
-        class="button-icon add-reaction-button"
+        class="icon-smile button-icon add-reaction-button"
         :title="$t('tool_tip.add_reaction')"
       />
     </div>
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index f66d09ac..a3d0b782 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -233,7 +233,6 @@ export const parseStatus = (data) => {
     output.statusnet_html = addEmojis(data.content, data.emojis)
 
     output.tags = data.tags
-    output.emoji_reactions = data.pleroma.emoji_reactions
 
     if (data.pleroma) {
       const { pleroma } = data
@@ -243,6 +242,7 @@ export const parseStatus = (data) => {
       output.is_local = pleroma.local
       output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
       output.thread_muted = pleroma.thread_muted
+      output.emoji_reactions = pleroma.emoji_reactions
     } else {
       output.text = data.content
       output.summary = data.spoiler_text
-- 
cgit v1.2.3-70-g09d2


From f6b482be515ea4f0281050f71296ffe0ec2ab305 Mon Sep 17 00:00:00 2001
From: Shpuld Shpludson <shp@cock.li>
Date: Tue, 11 Feb 2020 12:24:51 +0000
Subject: Emoji Reactions - fixes and improvements

---
 src/App.scss                                       |   2 +-
 src/_variables.scss                                |   2 +
 src/components/emoji_reactions/emoji_reactions.js  |  48 +++++++++-
 src/components/emoji_reactions/emoji_reactions.vue | 103 +++++++++++++++++++--
 src/components/notification/notification.vue       |   7 ++
 src/components/notifications/notifications.scss    |   4 +
 src/components/react_button/react_button.js        |   7 +-
 src/components/react_button/react_button.vue       |   4 +
 src/components/settings/settings.vue               |  10 ++
 src/components/status/status.vue                   |   1 +
 src/i18n/en.json                                   |   5 +-
 src/i18n/fi.json                                   |   5 +-
 src/modules/config.js                              |   4 +-
 src/modules/statuses.js                            |  36 ++++---
 src/services/api/api.service.js                    |  28 +++---
 .../entity_normalizer/entity_normalizer.service.js |   1 +
 .../notification_utils/notification_utils.js       |   3 +-
 test/unit/specs/modules/statuses.spec.js           |  13 ++-
 18 files changed, 236 insertions(+), 47 deletions(-)

(limited to 'src/components/react_button/react_button.vue')

diff --git a/src/App.scss b/src/App.scss
index 754ca62e..922e39b6 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -75,7 +75,7 @@ button {
   border-radius: $fallback--btnRadius;
   border-radius: var(--btnRadius, $fallback--btnRadius);
   cursor: pointer;
-  box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+  box-shadow: $fallback--buttonShadow;
   box-shadow: var(--buttonShadow);
   font-size: 14px;
   font-family: sans-serif;
diff --git a/src/_variables.scss b/src/_variables.scss
index e18101f0..30dc3e42 100644
--- a/src/_variables.scss
+++ b/src/_variables.scss
@@ -27,3 +27,5 @@ $fallback--tooltipRadius: 5px;
 $fallback--avatarRadius: 4px;
 $fallback--avatarAltRadius: 10px;
 $fallback--attachmentRadius: 10px;
+
+$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js
index 95d52cb6..b799ac9a 100644
--- a/src/components/emoji_reactions/emoji_reactions.js
+++ b/src/components/emoji_reactions/emoji_reactions.js
@@ -1,17 +1,55 @@
+import UserAvatar from '../user_avatar/user_avatar.vue'
+
+const EMOJI_REACTION_COUNT_CUTOFF = 12
 
 const EmojiReactions = {
   name: 'EmojiReactions',
+  components: {
+    UserAvatar
+  },
   props: ['status'],
+  data: () => ({
+    showAll: false,
+    popperOptions: {
+      modifiers: {
+        preventOverflow: { padding: { top: 50 }, boundariesElement: 'viewport' }
+      }
+    }
+  }),
   computed: {
+    tooManyReactions () {
+      return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF
+    },
     emojiReactions () {
-      return this.status.emoji_reactions
+      return this.showAll
+        ? this.status.emoji_reactions
+        : this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF)
+    },
+    showMoreString () {
+      return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}`
+    },
+    accountsForEmoji () {
+      return this.status.emoji_reactions.reduce((acc, reaction) => {
+        acc[reaction.name] = reaction.accounts || []
+        return acc
+      }, {})
+    },
+    loggedIn () {
+      return !!this.$store.state.users.currentUser
     }
   },
   methods: {
+    toggleShowAll () {
+      this.showAll = !this.showAll
+    },
     reactedWith (emoji) {
-      const user = this.$store.state.users.currentUser
-      const reaction = this.status.emoji_reactions.find(r => r.emoji === emoji)
-      return reaction.accounts && reaction.accounts.find(u => u.id === user.id)
+      return this.status.emoji_reactions.find(r => r.name === emoji).me
+    },
+    fetchEmojiReactionsByIfMissing () {
+      const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts)
+      if (hasNoAccounts) {
+        this.$store.dispatch('fetchEmojiReactionsBy', this.status.id)
+      }
     },
     reactWith (emoji) {
       this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
@@ -20,6 +58,8 @@ const EmojiReactions = {
       this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
     },
     emojiOnClick (emoji, event) {
+      if (!this.loggedIn) return
+
       if (this.reactedWith(emoji)) {
         this.unreact(emoji)
       } else {
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index 00d6d2b7..e5b6d9f5 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -1,16 +1,58 @@
 <template>
   <div class="emoji-reactions">
-    <button
+    <v-popover
       v-for="(reaction) in emojiReactions"
-      :key="reaction.emoji"
-      class="emoji-reaction btn btn-default"
-      :class="{ 'picked-reaction': reactedWith(reaction.emoji) }"
-      @click="emojiOnClick(reaction.emoji, $event)"
+      :key="reaction.name"
+      :popper-options="popperOptions"
+      trigger="hover"
+      placement="top"
     >
-      <span class="reaction-emoji">{{ reaction.emoji }}</span>
-      <span>{{ reaction.count }}</span>
-    </button>
+
+      <div
+        slot="popover"
+        class="reacted-users"
+      >
+        <div v-if="accountsForEmoji[reaction.name].length">
+          <div
+            v-for="(account) in accountsForEmoji[reaction.name]"
+            :key="account.id"
+            class="reacted-user"
+          >
+            <UserAvatar
+              :user="account"
+              class="avatar-small"
+              :compact="true"
+            />
+            <div class="reacted-user-names">
+              <span class="reacted-user-name" v-html="account.name_html" />
+              <span class="reacted-user-screen-name">{{ account.screen_name }}</span>
+            </div>
+          </div>
+        </div>
+        <div v-else>
+          <i class="icon-spin4 animate-spin" />
+        </div>
+      </div>
+      <button
+        class="emoji-reaction btn btn-default"
+        :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
+        @click="emojiOnClick(reaction.name, $event)"
+        @mouseenter="fetchEmojiReactionsByIfMissing()"
+      >
+        <span class="reaction-emoji">{{ reaction.name }}</span>
+        <span>{{ reaction.count }}</span>
+      </button>
+    </v-popover>
+    <a
+        v-if="tooManyReactions"
+        @click="toggleShowAll"
+        class="emoji-reaction-expand faint"
+        href='javascript:void(0)'
+      >
+        {{ showAll ? $t('general.show_less') : showMoreString }}
+      </a>
   </div>
+
 </template>
 
 <script src="./emoji_reactions.js" ></script>
@@ -23,6 +65,31 @@
   flex-wrap: wrap;
 }
 
+.reacted-users {
+  padding: 0.5em;
+}
+
+.reacted-user {
+  padding: 0.25em;
+  display: flex;
+  flex-direction: row;
+
+  .reacted-user-names {
+    display: flex;
+    flex-direction: column;
+    margin-left: 0.5em;
+
+    img {
+      width: 1em;
+      height: 1em;
+    }
+  }
+
+  .reacted-user-screen-name {
+    font-size: 9px;
+  }
+}
+
 .emoji-reaction {
   padding: 0 0.5em;
   margin-right: 0.5em;
@@ -38,6 +105,26 @@
   &:focus {
     outline: none;
   }
+
+  &.not-clickable {
+    cursor: default;
+    &:hover {
+      box-shadow: $fallback--buttonShadow;
+      box-shadow: var(--buttonShadow);
+    }
+  }
+}
+
+.emoji-reaction-expand {
+  padding: 0 0.5em;
+  margin-right: 0.5em;
+  margin-top: 0.5em;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  &:hover {
+    text-decoration: underline;
+  }
 }
 
 .picked-reaction {
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 16124e50..411c0271 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -78,6 +78,13 @@
               <i class="fa icon-arrow-curved lit" />
               <small>{{ $t('notifications.migrated_to') }}</small>
             </span>
+            <span v-if="notification.type === 'pleroma:emoji_reaction'">
+              <small>
+                <i18n path="notifications.reacted_with">
+                  <span class="emoji-reaction-emoji">{{ notification.emoji }}</span>
+                </i18n>
+              </small>
+            </span>
           </div>
           <div
             v-if="notification.type === 'follow' || notification.type === 'move'"
diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss
index 148ac7f2..8d819a56 100644
--- a/src/components/notifications/notifications.scss
+++ b/src/components/notifications/notifications.scss
@@ -94,6 +94,10 @@
     min-width: 0;
   }
 
+  .emoji-reaction-emoji {
+    font-size: 16px;
+  }
+
   .notification-details {
     min-width: 0px;
     word-wrap: break-word;
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index 6fb2a780..a6cf5b94 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -22,7 +22,12 @@ const ReactButton = {
       this.showTooltip = false
     },
     addReaction (event, emoji) {
-      this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
+      const existingReaction = this.status.emoji_reactions.find(r => r.name === emoji)
+      if (existingReaction && existingReaction.me) {
+        this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
+      } else {
+        this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
+      }
       this.closeReactionSelect()
     }
   },
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index c925dd71..fb43ebaf 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -54,6 +54,10 @@
 
 .reaction-picker-filter {
   padding: 0.5em;
+  display: flex;
+  input {
+    flex: 1;
+  }
 }
 
 .reaction-picker-divider {
diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue
index cef492f3..60cb8a87 100644
--- a/src/components/settings/settings.vue
+++ b/src/components/settings/settings.vue
@@ -92,6 +92,11 @@
                     {{ $t('settings.reply_link_preview') }}
                   </Checkbox>
                 </li>
+                <li>
+                  <Checkbox v-model="emojiReactionsOnTimeline">
+                    {{ $t('settings.emoji_reactions_on_timeline') }}
+                  </Checkbox>
+                </li>
               </ul>
             </div>
 
@@ -328,6 +333,11 @@
                       {{ $t('settings.notification_visibility_moves') }}
                     </Checkbox>
                   </li>
+                  <li>
+                    <Checkbox v-model="notificationVisibility.emojiReactions">
+                      {{ $t('settings.notification_visibility_emoji_reactions') }}
+                    </Checkbox>
+                  </li>
                 </ul>
               </div>
               <div>
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 0a82dcbe..83f07dac 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -369,6 +369,7 @@
           </transition>
 
           <EmojiReactions
+            v-if="(mergedConfig.emojiReactionsOnTimeline || isFocused) && (!noHeading && !isPreview)"
             :status="status"
           />
 
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 74e71fc8..d0d654d3 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -126,7 +126,8 @@
     "read": "Read!",
     "repeated_you": "repeated your status",
     "no_more_notifications": "No more notifications",
-    "migrated_to": "migrated to"
+    "migrated_to": "migrated to",
+    "reacted_with": "reacted with {0}"
   },
   "polls": {
     "add_poll": "Add Poll",
@@ -283,6 +284,7 @@
     "domain_mutes": "Domains",
     "avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels.",
     "pad_emoji": "Pad emoji with spaces when adding from picker",
+    "emoji_reactions_on_timeline": "Show emoji reactions on timeline",
     "export_theme": "Save preset",
     "filtering": "Filtering",
     "filtering_explanation": "All statuses containing these words will be muted, one per line",
@@ -331,6 +333,7 @@
     "notification_visibility_mentions": "Mentions",
     "notification_visibility_repeats": "Repeats",
     "notification_visibility_moves": "User Migrates",
+    "notification_visibility_emoji_reactions": "Reactions",
     "no_rich_text_description": "Strip rich text formatting from all posts",
     "no_blocks": "No blocks",
     "no_mutes": "No mutes",
diff --git a/src/i18n/fi.json b/src/i18n/fi.json
index e7ed5408..ac8b2ac9 100644
--- a/src/i18n/fi.json
+++ b/src/i18n/fi.json
@@ -53,7 +53,8 @@
     "notifications": "Ilmoitukset",
     "read": "Lue!",
     "repeated_you": "toisti viestisi",
-    "no_more_notifications": "Ei enempää ilmoituksia"
+    "no_more_notifications": "Ei enempää ilmoituksia",
+    "reacted_with": "lisäsi reaktion {0}"
   },
   "polls": {
     "add_poll": "Lisää äänestys",
@@ -140,6 +141,7 @@
     "delete_account_description": "Poista tilisi ja viestisi pysyvästi.",
     "delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.",
     "delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.",
+    "emoji_reactions_on_timeline": "Näytä emojireaktiot aikajanalla",
     "export_theme": "Tallenna teema",
     "filtering": "Suodatus",
     "filtering_explanation": "Kaikki viestit, jotka sisältävät näitä sanoja, suodatetaan. Yksi sana per rivi.",
@@ -183,6 +185,7 @@
     "notification_visibility_likes": "Tykkäykset",
     "notification_visibility_mentions": "Maininnat",
     "notification_visibility_repeats": "Toistot",
+    "notification_visibility_emoji_reactions": "Reaktiot",
     "no_rich_text_description": "Älä näytä tekstin muotoilua.",
     "hide_network_description": "Älä näytä seurauksiani tai seuraajiani",
     "nsfw_clickthrough": "Piilota NSFW liitteet klikkauksen taakse",
diff --git a/src/modules/config.js b/src/modules/config.js
index de9f041b..8381fa53 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -20,6 +20,7 @@ export const defaultState = {
   autoLoad: true,
   streaming: false,
   hoverPreview: true,
+  emojiReactionsOnTimeline: true,
   autohideFloatingPostButton: false,
   pauseOnUnfocused: true,
   stopGifs: false,
@@ -29,7 +30,8 @@ export const defaultState = {
     mentions: true,
     likes: true,
     repeats: true,
-    moves: true
+    moves: true,
+    emojiReactions: false
   },
   webPushNotifications: false,
   muteWords: [],
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index ea0c1749..25b62ac7 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -81,7 +81,8 @@ const visibleNotificationTypes = (rootState) => {
     rootState.config.notificationVisibility.mentions && 'mention',
     rootState.config.notificationVisibility.repeats && 'repeat',
     rootState.config.notificationVisibility.follows && 'follow',
-    rootState.config.notificationVisibility.moves && 'move'
+    rootState.config.notificationVisibility.moves && 'move',
+    rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reactions'
   ].filter(_ => _)
 }
 
@@ -325,6 +326,10 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
       notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item
     }
 
+    if (notification.type === 'pleroma:emoji_reaction') {
+      dispatch('fetchEmojiReactionsBy', notification.status.id)
+    }
+
     // Only add a new notification if we don't have one for the same action
     if (!state.notifications.idStore.hasOwnProperty(notification.id)) {
       state.notifications.maxId = notification.id > state.notifications.maxId
@@ -358,7 +363,9 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
             break
         }
 
-        if (i18nString) {
+        if (notification.type === 'pleroma:emoji_reaction') {
+          notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji])
+        } else if (i18nString) {
           notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
         } else {
           notifObj.body = notification.status.text
@@ -371,10 +378,10 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
         }
 
         if (!notification.seen && !state.notifications.desktopNotificationSilence && visibleNotificationTypes.includes(notification.type)) {
-          let notification = new window.Notification(title, notifObj)
+          let desktopNotification = new window.Notification(title, notifObj)
           // Chrome is known for not closing notifications automatically
           // according to MDN, anyway.
-          setTimeout(notification.close.bind(notification), 5000)
+          setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
         }
       }
     } else if (notification.seen) {
@@ -537,12 +544,13 @@ export const mutations = {
   },
   addOwnReaction (state, { id, emoji, currentUser }) {
     const status = state.allStatusesObject[id]
-    const reactionIndex = findIndex(status.emoji_reactions, { emoji })
-    const reaction = status.emoji_reactions[reactionIndex] || { emoji, count: 0, accounts: [] }
+    const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
+    const reaction = status.emoji_reactions[reactionIndex] || { name: emoji, count: 0, accounts: [] }
 
     const newReaction = {
       ...reaction,
       count: reaction.count + 1,
+      me: true,
       accounts: [
         ...reaction.accounts,
         currentUser
@@ -558,21 +566,23 @@ export const mutations = {
   },
   removeOwnReaction (state, { id, emoji, currentUser }) {
     const status = state.allStatusesObject[id]
-    const reactionIndex = findIndex(status.emoji_reactions, { emoji })
+    const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
     if (reactionIndex < 0) return
 
     const reaction = status.emoji_reactions[reactionIndex]
+    const accounts = reaction.accounts || []
 
     const newReaction = {
       ...reaction,
       count: reaction.count - 1,
-      accounts: reaction.accounts.filter(acc => acc.id === currentUser.id)
+      me: false,
+      accounts: accounts.filter(acc => acc.id !== currentUser.id)
     }
 
     if (newReaction.count > 0) {
       set(status.emoji_reactions, reactionIndex, newReaction)
     } else {
-      set(status, 'emoji_reactions', status.emoji_reactions.filter(r => r.emoji !== emoji))
+      set(status, 'emoji_reactions', status.emoji_reactions.filter(r => r.name !== emoji))
     }
   },
   updateStatusWithPoll (state, { id, poll }) {
@@ -681,18 +691,22 @@ const statuses = {
     },
     reactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
       const currentUser = rootState.users.currentUser
+      if (!currentUser) return
+
       commit('addOwnReaction', { id, emoji, currentUser })
       rootState.api.backendInteractor.reactWithEmoji({ id, emoji }).then(
-        status => {
+        ok => {
           dispatch('fetchEmojiReactionsBy', id)
         }
       )
     },
     unreactWithEmoji ({ rootState, dispatch, commit }, { id, emoji }) {
       const currentUser = rootState.users.currentUser
+      if (!currentUser) return
+
       commit('removeOwnReaction', { id, emoji, currentUser })
       rootState.api.backendInteractor.unreactWithEmoji({ id, emoji }).then(
-        status => {
+        ok => {
           dispatch('fetchEmojiReactionsBy', id)
         }
       )
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index b794fd58..20eaa9a0 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -74,9 +74,9 @@ const MASTODON_SEARCH_2 = `/api/v2/search`
 const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
 const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
 const MASTODON_STREAMING = '/api/v1/streaming'
-const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/emoji_reactions_by`
-const PLEROMA_EMOJI_REACT_URL = id => `/api/v1/pleroma/statuses/${id}/react_with_emoji`
-const PLEROMA_EMOJI_UNREACT_URL = id => `/api/v1/pleroma/statuses/${id}/unreact_with_emoji`
+const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
+const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
+const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
 
 const oldfetch = window.fetch
 
@@ -888,25 +888,27 @@ const fetchRebloggedByUsers = ({ id }) => {
   return promisedRequest({ url: MASTODON_STATUS_REBLOGGEDBY_URL(id) }).then((users) => users.map(parseUser))
 }
 
-const fetchEmojiReactions = ({ id }) => {
-  return promisedRequest({ url: PLEROMA_EMOJI_REACTIONS_URL(id) })
+const fetchEmojiReactions = ({ id, credentials }) => {
+  return promisedRequest({ url: PLEROMA_EMOJI_REACTIONS_URL(id), credentials })
+    .then((reactions) => reactions.map(r => {
+      r.accounts = r.accounts.map(parseUser)
+      return r
+    }))
 }
 
 const reactWithEmoji = ({ id, emoji, credentials }) => {
   return promisedRequest({
-    url: PLEROMA_EMOJI_REACT_URL(id),
-    method: 'POST',
-    credentials,
-    payload: { emoji }
+    url: PLEROMA_EMOJI_REACT_URL(id, emoji),
+    method: 'PUT',
+    credentials
   }).then(parseStatus)
 }
 
 const unreactWithEmoji = ({ id, emoji, credentials }) => {
   return promisedRequest({
-    url: PLEROMA_EMOJI_UNREACT_URL(id),
-    method: 'POST',
-    credentials,
-    payload: { emoji }
+    url: PLEROMA_EMOJI_UNREACT_URL(id, emoji),
+    method: 'DELETE',
+    credentials
   }).then(parseStatus)
 }
 
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 0a8abbbd..84169a7b 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -354,6 +354,7 @@ export const parseNotification = (data) => {
       ? null
       : parseUser(data.target)
     output.from_profile = parseUser(data.account)
+    output.emoji = data.emoji
   } else {
     const parsedNotice = parseStatus(data.notice)
     output.type = data.ntype
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 860620fc..b17bd7bf 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -7,7 +7,8 @@ export const visibleTypes = store => ([
   store.state.config.notificationVisibility.mentions && 'mention',
   store.state.config.notificationVisibility.repeats && 'repeat',
   store.state.config.notificationVisibility.follows && 'follow',
-  store.state.config.notificationVisibility.moves && 'move'
+  store.state.config.notificationVisibility.moves && 'move',
+  store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
 ].filter(_ => _))
 
 const sortById = (a, b) => {
diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js
index e53aa388..fe42e85b 100644
--- a/test/unit/specs/modules/statuses.spec.js
+++ b/test/unit/specs/modules/statuses.spec.js
@@ -245,11 +245,12 @@ describe('Statuses module', () => {
     it('increments count in existing reaction', () => {
       const state = defaultState()
       const status = makeMockStatus({ id: '1' })
-      status.emoji_reactions = [ { emoji: '😂', count: 1, accounts: [] } ]
+      status.emoji_reactions = [ { name: '😂', count: 1, accounts: [] } ]
 
       mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
       mutations.addOwnReaction(state, { id: '1', emoji: '😂', currentUser: { id: 'me' } })
       expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(2)
+      expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true)
       expect(state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id).to.eql('me')
     })
 
@@ -261,27 +262,29 @@ describe('Statuses module', () => {
       mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
       mutations.addOwnReaction(state, { id: '1', emoji: '😂', currentUser: { id: 'me' } })
       expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1)
+      expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true)
       expect(state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id).to.eql('me')
     })
 
     it('decreases count in existing reaction', () => {
       const state = defaultState()
       const status = makeMockStatus({ id: '1' })
-      status.emoji_reactions = [ { emoji: '😂', count: 2, accounts: [{ id: 'me' }] } ]
+      status.emoji_reactions = [ { name: '😂', count: 2, accounts: [{ id: 'me' }] } ]
 
       mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
-      mutations.removeOwnReaction(state, { id: '1', emoji: '😂', currentUser: {} })
+      mutations.removeOwnReaction(state, { id: '1', emoji: '😂', currentUser: { id: 'me' } })
       expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1)
+      expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(false)
       expect(state.allStatusesObject['1'].emoji_reactions[0].accounts).to.eql([])
     })
 
     it('removes a reaction', () => {
       const state = defaultState()
       const status = makeMockStatus({ id: '1' })
-      status.emoji_reactions = [{ emoji: '😂', count: 1, accounts: [{ id: 'me' }] }]
+      status.emoji_reactions = [{ name: '😂', count: 1, accounts: [{ id: 'me' }] }]
 
       mutations.addNewStatuses(state, { statuses: [status], showImmediately: true, timeline: 'public' })
-      mutations.removeOwnReaction(state, { id: '1', emoji: '😂', currentUser: {} })
+      mutations.removeOwnReaction(state, { id: '1', emoji: '😂', currentUser: { id: 'me' } })
       expect(state.allStatusesObject['1'].emoji_reactions.length).to.eql(0)
     })
   })
-- 
cgit v1.2.3-70-g09d2


From 31225f5d142b51d52bed305f25a37288c9188062 Mon Sep 17 00:00:00 2001
From: Shpuld Shpludson <shp@cock.li>
Date: Fri, 28 Feb 2020 16:39:47 +0000
Subject: Fix/popover performance

---
 CHANGELOG.md                                       |   1 +
 package.json                                       |   1 -
 src/components/account_actions/account_actions.js  |   4 +-
 src/components/account_actions/account_actions.vue |  21 +--
 src/components/emoji_reactions/emoji_reactions.js  |  11 +-
 src/components/emoji_reactions/emoji_reactions.vue |  33 +++--
 src/components/extra_buttons/extra_buttons.js      |   3 +
 src/components/extra_buttons/extra_buttons.vue     |  14 +-
 .../moderation_tools/moderation_tools.js           |  11 +-
 .../moderation_tools/moderation_tools.vue          |  17 ++-
 src/components/popover/popover.js                  | 156 ++++++++++++++++++++
 src/components/popover/popover.vue                 | 118 +++++++++++++++
 src/components/popper/popper.scss                  | 164 ---------------------
 src/components/react_button/react_button.js        |  23 +--
 src/components/react_button/react_button.vue       |  32 ++--
 src/components/status/status.vue                   |  11 +-
 src/components/status_popover/status_popover.js    |  12 +-
 src/components/status_popover/status_popover.vue   |  64 +++-----
 src/main.js                                        |   8 -
 yarn.lock                                          |  19 ---
 20 files changed, 393 insertions(+), 330 deletions(-)
 create mode 100644 src/components/popover/popover.js
 create mode 100644 src/components/popover/popover.vue
 delete mode 100644 src/components/popper/popper.scss

(limited to 'src/components/react_button/react_button.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1cdd604b..e77334b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Registration fixed
 - Deactivation of remote accounts from frontend
 - Fixed NSFW unhiding not working with videos when using one-click unhiding/displaying
+- Improved performance of anything that uses popovers (most notably statuses)
 
 ## [1.1.7 and earlier] - 2019-12-14
 ### Added
diff --git a/package.json b/package.json
index 5c7fa31e..542086b4 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,6 @@
     "portal-vue": "^2.1.4",
     "sanitize-html": "^1.13.0",
     "v-click-outside": "^2.1.1",
-    "v-tooltip": "^2.0.2",
     "vue": "^2.5.13",
     "vue-chat-scroll": "^1.2.1",
     "vue-i18n": "^7.3.2",
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index d2153680..5d7ecf7e 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,4 +1,5 @@
 import ProgressButton from '../progress_button/progress_button.vue'
+import Popover from '../popover/popover.vue'
 
 const AccountActions = {
   props: [
@@ -8,7 +9,8 @@ const AccountActions = {
     return { }
   },
   components: {
-    ProgressButton
+    ProgressButton,
+    Popover
   },
   methods: {
     showRepeats () {
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index d3235be1..483783cf 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -1,13 +1,13 @@
 <template>
   <div class="account-actions">
-    <v-popover
+    <Popover
       trigger="click"
-      class="account-tools-popover"
-      :container="false"
-      placement="bottom-end"
-      :offset="5"
+      placement="bottom"
     >
-      <div slot="popover">
+      <div
+        slot="content"
+        class="account-tools-popover"
+      >
         <div class="dropdown-menu">
           <template v-if="user.following">
             <button
@@ -51,10 +51,13 @@
           </button>
         </div>
       </div>
-      <div class="btn btn-default ellipsis-button">
+      <div
+        slot="trigger"
+        class="btn btn-default ellipsis-button"
+      >
         <i class="icon-ellipsis trigger-button" />
       </div>
-    </v-popover>
+    </Popover>
   </div>
 </template>
 
@@ -62,7 +65,6 @@
 
 <style lang="scss">
 @import '../../_variables.scss';
-@import '../popper/popper.scss';
 .account-actions {
   margin: 0 .8em;
 }
@@ -70,6 +72,7 @@
 .account-actions button.dropdown-item {
   margin-left: 0;
 }
+
 .account-actions .trigger-button {
   color: $fallback--lightText;
   color: var(--lightText, $fallback--lightText);
diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js
index b799ac9a..ae7f53be 100644
--- a/src/components/emoji_reactions/emoji_reactions.js
+++ b/src/components/emoji_reactions/emoji_reactions.js
@@ -1,20 +1,17 @@
 import UserAvatar from '../user_avatar/user_avatar.vue'
+import Popover from '../popover/popover.vue'
 
 const EMOJI_REACTION_COUNT_CUTOFF = 12
 
 const EmojiReactions = {
   name: 'EmojiReactions',
   components: {
-    UserAvatar
+    UserAvatar,
+    Popover
   },
   props: ['status'],
   data: () => ({
-    showAll: false,
-    popperOptions: {
-      modifiers: {
-        preventOverflow: { padding: { top: 50 }, boundariesElement: 'viewport' }
-      }
-    }
+    showAll: false
   }),
   computed: {
     tooManyReactions () {
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index b25c9716..bac4c605 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -1,15 +1,14 @@
 <template>
   <div class="emoji-reactions">
-    <v-popover
+    <Popover
       v-for="(reaction) in emojiReactions"
       :key="reaction.name"
-      :popper-options="popperOptions"
       trigger="hover"
       placement="top"
+      :offset="{ y: 5 }"
     >
-
       <div
-        slot="popover"
+        slot="content"
         class="reacted-users"
       >
         <div v-if="accountsForEmoji[reaction.name].length">
@@ -24,7 +23,12 @@
               :compact="true"
             />
             <div class="reacted-user-names">
-              <span class="reacted-user-name" v-html="account.name_html" />
+              <!-- eslint-disable vue/no-v-html -->
+              <span
+                class="reacted-user-name"
+                v-html="account.name_html"
+              />
+              <!-- eslint-enable vue/no-v-html -->
               <span class="reacted-user-screen-name">{{ account.screen_name }}</span>
             </div>
           </div>
@@ -34,6 +38,7 @@
         </div>
       </div>
       <button
+        slot="trigger"
         class="emoji-reaction btn btn-default"
         :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
         @click="emojiOnClick(reaction.name, $event)"
@@ -42,17 +47,16 @@
         <span class="reaction-emoji">{{ reaction.name }}</span>
         <span>{{ reaction.count }}</span>
       </button>
-    </v-popover>
+    </Popover>
     <a
-        v-if="tooManyReactions"
-        @click="toggleShowAll"
-        class="emoji-reaction-expand faint"
-        href='javascript:void(0)'
-      >
-        {{ showAll ? $t('general.show_less') : showMoreString }}
-      </a>
+      v-if="tooManyReactions"
+      class="emoji-reaction-expand faint"
+      href="javascript:void(0)"
+      @click="toggleShowAll"
+    >
+      {{ showAll ? $t('general.show_less') : showMoreString }}
+    </a>
   </div>
-
 </template>
 
 <script src="./emoji_reactions.js" ></script>
@@ -78,6 +82,7 @@
     display: flex;
     flex-direction: column;
     margin-left: 0.5em;
+    min-width: 5em;
 
     img {
       width: 1em;
diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js
index 5ac73e97..37485383 100644
--- a/src/components/extra_buttons/extra_buttons.js
+++ b/src/components/extra_buttons/extra_buttons.js
@@ -1,5 +1,8 @@
+import Popover from '../popover/popover.vue'
+
 const ExtraButtons = {
   props: [ 'status' ],
+  components: { Popover },
   methods: {
     deleteStatus () {
       const confirmed = window.confirm(this.$t('status.delete_confirm'))
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index 746f1c91..3a7f1283 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -1,11 +1,11 @@
 <template>
-  <v-popover
+  <Popover
     v-if="canDelete || canMute || canPin"
     trigger="click"
     placement="top"
     class="extra-button-popover"
   >
-    <div slot="popover">
+    <div slot="content">
       <div class="dropdown-menu">
         <button
           v-if="canMute && !status.thread_muted"
@@ -47,17 +47,17 @@
         </button>
       </div>
     </div>
-    <div class="button-icon">
-      <i class="icon-ellipsis" />
-    </div>
-  </v-popover>
+    <i
+      slot="trigger"
+      class="icon-ellipsis button-icon"
+    />
+  </Popover>
 </template>
 
 <script src="./extra_buttons.js" ></script>
 
 <style lang="scss">
 @import '../../_variables.scss';
-@import '../popper/popper.scss';
 
 .icon-ellipsis {
   cursor: pointer;
diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js
index 757166ed..d4fdc53e 100644
--- a/src/components/moderation_tools/moderation_tools.js
+++ b/src/components/moderation_tools/moderation_tools.js
@@ -1,4 +1,5 @@
 import DialogModal from '../dialog_modal/dialog_modal.vue'
+import Popover from '../popover/popover.vue'
 
 const FORCE_NSFW = 'mrf_tag:media-force-nsfw'
 const STRIP_MEDIA = 'mrf_tag:media-strip'
@@ -14,7 +15,6 @@ const ModerationTools = {
   ],
   data () {
     return {
-      showDropDown: false,
       tags: {
         FORCE_NSFW,
         STRIP_MEDIA,
@@ -24,11 +24,13 @@ const ModerationTools = {
         SANDBOX,
         QUARANTINE
       },
-      showDeleteUserDialog: false
+      showDeleteUserDialog: false,
+      toggled: false
     }
   },
   components: {
-    DialogModal
+    DialogModal,
+    Popover
   },
   computed: {
     tagsSet () {
@@ -89,6 +91,9 @@ const ModerationTools = {
             window.history.back()
           }
         })
+    },
+    setToggled (value) {
+      this.toggled = value
     }
   }
 }
diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue
index e78e05f1..b2d5acc5 100644
--- a/src/components/moderation_tools/moderation_tools.vue
+++ b/src/components/moderation_tools/moderation_tools.vue
@@ -1,13 +1,14 @@
 <template>
   <div>
-    <v-popover
+    <Popover
       trigger="click"
       class="moderation-tools-popover"
-      placement="bottom-end"
-      @show="showDropDown = true"
-      @hide="showDropDown = false"
+      placement="bottom"
+      :offset="{ y: 5 }"
+      @show="setToggled(true)"
+      @close="setToggled(false)"
     >
-      <div slot="popover">
+      <div slot="content">
         <div class="dropdown-menu">
           <span v-if="user.is_local">
             <button
@@ -122,12 +123,13 @@
         </div>
       </div>
       <button
+        slot="trigger"
         class="btn btn-default btn-block"
-        :class="{ toggled: showDropDown }"
+        :class="{ toggled }"
       >
         {{ $t('user_card.admin_menu.moderation') }}
       </button>
-    </v-popover>
+    </Popover>
     <portal to="modal">
       <DialogModal
         v-if="showDeleteUserDialog"
@@ -160,7 +162,6 @@
 
 <style lang="scss">
 @import '../../_variables.scss';
-@import '../popper/popper.scss';
 
 .menu-checkbox {
   float: right;
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
new file mode 100644
index 00000000..5881d266
--- /dev/null
+++ b/src/components/popover/popover.js
@@ -0,0 +1,156 @@
+
+const Popover = {
+  name: 'Popover',
+  props: {
+    // Action to trigger popover: either 'hover' or 'click'
+    trigger: String,
+    // Either 'top' or 'bottom'
+    placement: String,
+    // Takes object with properties 'x' and 'y', values of these can be
+    // 'container' for using offsetParent as boundaries for either axis
+    // or 'viewport'
+    boundTo: Object,
+    // Takes a top/bottom/left/right object, how much space to leave
+    // between boundary and popover element
+    margin: Object,
+    // Takes a x/y object and tells how many pixels to offset from
+    // anchor point on either axis
+    offset: Object,
+    // Additional styles you may want for the popover container
+    popoverClass: String
+  },
+  data () {
+    return {
+      hidden: true,
+      styles: { opacity: 0 },
+      oldSize: { width: 0, height: 0 }
+    }
+  },
+  methods: {
+    updateStyles () {
+      if (this.hidden) {
+        this.styles = {
+          opacity: 0
+        }
+        return
+      }
+
+      // Popover will be anchored around this element, trigger ref is the container, so
+      // its children are what are inside the slot. Expect only one slot="trigger".
+      const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el
+      const screenBox = anchorEl.getBoundingClientRect()
+      // Screen position of the origin point for popover
+      const origin = { x: screenBox.left + screenBox.width * 0.5, y: screenBox.top }
+      const content = this.$refs.content
+      // Minor optimization, don't call a slow reflow call if we don't have to
+      const parentBounds = this.boundTo &&
+        (this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
+        this.$el.offsetParent.getBoundingClientRect()
+      const margin = this.margin || {}
+
+      // What are the screen bounds for the popover? Viewport vs container
+      // when using viewport, using default margin values to dodge the navbar
+      const xBounds = this.boundTo && this.boundTo.x === 'container' ? {
+        min: parentBounds.left + (margin.left || 0),
+        max: parentBounds.right - (margin.right || 0)
+      } : {
+        min: 0 + (margin.left || 10),
+        max: window.innerWidth - (margin.right || 10)
+      }
+
+      const yBounds = this.boundTo && this.boundTo.y === 'container' ? {
+        min: parentBounds.top + (margin.top || 0),
+        max: parentBounds.bottom - (margin.bottom || 0)
+      } : {
+        min: 0 + (margin.top || 50),
+        max: window.innerHeight - (margin.bottom || 5)
+      }
+
+      let horizOffset = 0
+
+      // If overflowing from left, move it so that it doesn't
+      if ((origin.x - content.offsetWidth * 0.5) < xBounds.min) {
+        horizOffset += -(origin.x - content.offsetWidth * 0.5) + xBounds.min
+      }
+
+      // If overflowing from right, move it so that it doesn't
+      if ((origin.x + horizOffset + content.offsetWidth * 0.5) > xBounds.max) {
+        horizOffset -= (origin.x + horizOffset + content.offsetWidth * 0.5) - xBounds.max
+      }
+
+      // Default to whatever user wished with placement prop
+      let usingTop = this.placement !== 'bottom'
+
+      // Handle special cases, first force to displaying on top if there's not space on bottom,
+      // regardless of what placement value was. Then check if there's not space on top, and
+      // force to bottom, again regardless of what placement value was.
+      if (origin.y + content.offsetHeight > yBounds.max) usingTop = true
+      if (origin.y - content.offsetHeight < yBounds.min) usingTop = false
+
+      const yOffset = (this.offset && this.offset.y) || 0
+      const translateY = usingTop
+        ? -anchorEl.offsetHeight - yOffset - content.offsetHeight
+        : yOffset
+
+      const xOffset = (this.offset && this.offset.x) || 0
+      const translateX = (anchorEl.offsetWidth * 0.5) - content.offsetWidth * 0.5 + horizOffset + xOffset
+
+      // Note, separate translateX and translateY avoids blurry text on chromium,
+      // single translate or translate3d resulted in blurry text.
+      this.styles = {
+        opacity: 1,
+        transform: `translateX(${Math.floor(translateX)}px) translateY(${Math.floor(translateY)}px)`
+      }
+    },
+    showPopover () {
+      if (this.hidden) this.$emit('show')
+      this.hidden = false
+      this.$nextTick(this.updateStyles)
+    },
+    hidePopover () {
+      if (!this.hidden) this.$emit('close')
+      this.hidden = true
+      this.styles = { opacity: 0 }
+    },
+    onMouseenter (e) {
+      if (this.trigger === 'hover') this.showPopover()
+    },
+    onMouseleave (e) {
+      if (this.trigger === 'hover') this.hidePopover()
+    },
+    onClick (e) {
+      if (this.trigger === 'click') {
+        if (this.hidden) {
+          this.showPopover()
+        } else {
+          this.hidePopover()
+        }
+      }
+    },
+    onClickOutside (e) {
+      if (this.hidden) return
+      if (this.$el.contains(e.target)) return
+      this.hidePopover()
+    }
+  },
+  updated () {
+    // Monitor changes to content size, update styles only when content sizes have changed,
+    // that should be the only time we need to move the popover box if we don't care about scroll
+    // or resize
+    const content = this.$refs.content
+    if (!content) return
+    if (this.oldSize.width !== content.offsetWidth || this.oldSize.height !== content.offsetHeight) {
+      this.updateStyles()
+      this.oldSize = { width: content.offsetWidth, height: content.offsetHeight }
+    }
+  },
+  created () {
+    document.addEventListener('click', this.onClickOutside)
+  },
+  destroyed () {
+    document.removeEventListener('click', this.onClickOutside)
+    this.hidePopover()
+  }
+}
+
+export default Popover
diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue
new file mode 100644
index 00000000..a271cb1b
--- /dev/null
+++ b/src/components/popover/popover.vue
@@ -0,0 +1,118 @@
+<template>
+  <div
+    @mouseenter="onMouseenter"
+    @mouseleave="onMouseleave"
+  >
+    <div
+      ref="trigger"
+      @click="onClick"
+    >
+      <slot name="trigger" />
+    </div>
+    <div
+      v-if="!hidden"
+      ref="content"
+      :style="styles"
+      class="popover"
+      :class="popoverClass"
+    >
+      <slot
+        name="content"
+        class="popover-inner"
+        :close="hidePopover"
+      />
+    </div>
+  </div>
+</template>
+
+<script src="./popover.js" />
+
+<style lang=scss>
+@import '../../_variables.scss';
+
+.popover {
+  z-index: 8;
+  position: absolute;
+  min-width: 0;
+  transition: opacity 0.3s;
+
+  box-shadow: 1px 1px 4px rgba(0,0,0,.6);
+  box-shadow: var(--panelShadow);
+  border-radius: $fallback--btnRadius;
+  border-radius: var(--btnRadius, $fallback--btnRadius);
+
+  background-color: $fallback--bg;
+  background-color: var(--popover, $fallback--bg);
+  color: $fallback--text;
+  color: var(--popoverText, $fallback--text);
+  --faint: var(--popoverFaintText, $fallback--faint);
+  --faintLink: var(--popoverFaintLink, $fallback--faint);
+  --lightText: var(--popoverLightText, $fallback--lightText);
+  --postLink: var(--popoverPostLink, $fallback--link);
+  --postFaintLink: var(--popoverPostFaintLink, $fallback--link);
+  --icon: var(--popoverIcon, $fallback--icon);
+}
+
+.dropdown-menu {
+  display: block;
+  padding: .5rem 0;
+  font-size: 1rem;
+  text-align: left;
+  list-style: none;
+  max-width: 100vw;
+  z-index: 10;
+  white-space: nowrap;
+
+  .dropdown-divider {
+    height: 0;
+    margin: .5rem 0;
+    overflow: hidden;
+    border-top: 1px solid $fallback--border;
+    border-top: 1px solid var(--border, $fallback--border);
+  }
+
+  .dropdown-item {
+    line-height: 21px;
+    margin-right: 5px;
+    overflow: auto;
+    display: block;
+    padding: .25rem 1.0rem .25rem 1.5rem;
+    clear: both;
+    font-weight: 400;
+    text-align: inherit;
+    white-space: nowrap;
+    border: none;
+    border-radius: 0px;
+    background-color: transparent;
+    box-shadow: none;
+    width: 100%;
+    height: 100%;
+
+    --btnText: var(--popoverText, $fallback--text);
+
+    &-icon {
+      padding-left: 0.5rem;
+
+      i {
+        margin-right: 0.25rem;
+        color: var(--menuPopoverIcon, $fallback--icon)
+      }
+    }
+
+    &:active, &:hover {
+      background-color: $fallback--lightBg;
+      background-color: var(--selectedMenuPopover, $fallback--lightBg);
+      color: $fallback--link;
+      color: var(--selectedMenuPopoverText, $fallback--link);
+      --faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
+      --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
+      --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
+      --icon: var(--selectedMenuPopoverIcon, $fallback--icon);
+      i {
+        color: var(--selectedMenuPopoverIcon, $fallback--icon);
+      }
+    }
+
+  }
+}
+</style>
diff --git a/src/components/popper/popper.scss b/src/components/popper/popper.scss
deleted file mode 100644
index 99b7e6fc..00000000
--- a/src/components/popper/popper.scss
+++ /dev/null
@@ -1,164 +0,0 @@
-@import '../../_variables.scss';
-
-.tooltip.popover {
-  z-index: 8;
-
-  .popover-inner {
-    box-shadow: 1px 1px 4px rgba(0,0,0,.6);
-    box-shadow: var(--panelShadow);
-    border-radius: $fallback--btnRadius;
-    border-radius: var(--btnRadius, $fallback--btnRadius);
-    background-color: $fallback--bg;
-    background-color: var(--popover, $fallback--bg);
-    color: $fallback--text;
-    color: var(--popoverText, $fallback--text);
-    --faint: var(--popoverFaintText, $fallback--faint);
-    --faintLink: var(--popoverFaintLink, $fallback--faint);
-    --lightText: var(--popoverLightText, $fallback--lightText);
-    --postLink: var(--popoverPostLink, $fallback--link);
-    --postFaintLink: var(--popoverPostFaintLink, $fallback--link);
-    --icon: var(--popoverIcon, $fallback--icon);
-  }
-
-  .popover-arrow {
-    width: 0;
-    height: 0;
-    border-style: solid;
-    position: absolute;
-    margin: 5px;
-    border-color: $fallback--bg;
-    border-color: var(--bg, $fallback--bg);
-  }
-
-  &[x-placement^="top"] {
-    margin-bottom: 5px;
-
-    .popover-arrow {
-      border-width: 5px 5px 0 5px;
-      border-left-color: transparent !important;
-      border-right-color: transparent !important;
-      border-bottom-color: transparent !important;
-      bottom: -4px;
-      left: calc(50% - 5px);
-      margin-top: 0;
-      margin-bottom: 0;
-    }
-  }
-
-  &[x-placement^="bottom"] {
-    margin-top: 5px;
-
-    .popover-arrow {
-      border-width: 0 5px 5px 5px;
-      border-left-color: transparent !important;
-      border-right-color: transparent !important;
-      border-top-color: transparent !important;
-      top: -4px;
-      left: calc(50% - 5px);
-      margin-top: 0;
-      margin-bottom: 0;
-    }
-  }
-
-  &[x-placement^="right"] {
-    margin-left: 5px;
-
-    .popover-arrow {
-      border-width: 5px 5px 5px 0;
-      border-left-color: transparent !important;
-      border-top-color: transparent !important;
-      border-bottom-color: transparent !important;
-      left: -4px;
-      top: calc(50% - 5px);
-      margin-left: 0;
-      margin-right: 0;
-    }
-  }
-
-  &[x-placement^="left"] {
-    margin-right: 5px;
-
-    .popover-arrow {
-      border-width: 5px 0 5px 5px;
-      border-top-color: transparent !important;
-      border-right-color: transparent !important;
-      border-bottom-color: transparent !important;
-      right: -4px;
-      top: calc(50% - 5px);
-      margin-left: 0;
-      margin-right: 0;
-    }
-  }
-
-  &[aria-hidden='true'] {
-    visibility: hidden;
-    opacity: 0;
-    transition: opacity .15s, visibility .15s;
-  }
-
-  &[aria-hidden='false'] {
-    visibility: visible;
-    opacity: 1;
-    transition: opacity .15s;
-  }
-}
-
-.dropdown-menu {
-  display: block;
-  padding: .5rem 0;
-  font-size: 1rem;
-  text-align: left;
-  list-style: none;
-  max-width: 100vw;
-  z-index: 10;
-
-  .dropdown-divider {
-    height: 0;
-    margin: .5rem 0;
-    overflow: hidden;
-    border-top: 1px solid $fallback--border;
-    border-top: 1px solid var(--border, $fallback--border);
-  }
-
-  .dropdown-item {
-    line-height: 21px;
-    margin-right: 5px;
-    overflow: auto;
-    display: block;
-    padding: .25rem 1.0rem .25rem 1.5rem;
-    clear: both;
-    font-weight: 400;
-    text-align: inherit;
-    white-space: normal;
-    border: none;
-    border-radius: 0px;
-    background-color: transparent;
-    box-shadow: none;
-    width: 100%;
-    height: 100%;
-
-    --btnText: var(--popoverText, $fallback--text);
-
-    &-icon {
-      padding-left: 0.5rem;
-
-      i {
-        margin-right: 0.25rem;
-      }
-    }
-
-    &:active, &:hover {
-      background-color: $fallback--lightBg;
-      background-color: var(--selectedMenuPopover, $fallback--lightBg);
-      color: $fallback--link;
-      color: var(--selectedMenuPopoverText, $fallback--link);
-      --faint: var(--selectedMenuPopoverFaintText, $fallback--faint);
-      --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint);
-      --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText);
-      --icon: var(--selectedMenuPopoverIcon, $fallback--icon);
-      i {
-        color: var(--selectedMenuPopoverIcon, $fallback--icon);
-      }
-    }
-  }
-}
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index a6cf5b94..19949563 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -1,34 +1,25 @@
+import Popover from '../popover/popover.vue'
 import { mapGetters } from 'vuex'
 
 const ReactButton = {
   props: ['status', 'loggedIn'],
   data () {
     return {
-      showTooltip: false,
-      filterWord: '',
-      popperOptions: {
-        modifiers: {
-          preventOverflow: { padding: { top: 50 }, boundariesElement: 'viewport' }
-        }
-      }
+      filterWord: ''
     }
   },
+  components: {
+    Popover
+  },
   methods: {
-    openReactionSelect () {
-      this.showTooltip = true
-      this.filterWord = ''
-    },
-    closeReactionSelect () {
-      this.showTooltip = false
-    },
-    addReaction (event, emoji) {
+    addReaction (event, emoji, close) {
       const existingReaction = this.status.emoji_reactions.find(r => r.name === emoji)
       if (existingReaction && existingReaction.me) {
         this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
       } else {
         this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
       }
-      this.closeReactionSelect()
+      close()
     }
   },
   computed: {
diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue
index fb43ebaf..ab4b4fcd 100644
--- a/src/components/react_button/react_button.vue
+++ b/src/components/react_button/react_button.vue
@@ -1,13 +1,14 @@
 <template>
-  <v-popover
-    :popper-options="popperOptions"
-    :open="showTooltip"
-    trigger="manual"
+  <Popover
+    trigger="click"
     placement="top"
+    :offset="{ y: 5 }"
     class="react-button-popover"
-    @hide="closeReactionSelect"
   >
-    <div slot="popover">
+    <div
+      slot="content"
+      slot-scope="{close}"
+    >
       <div class="reaction-picker-filter">
         <input
           v-model="filterWord"
@@ -19,7 +20,7 @@
           v-for="emoji in commonEmojis"
           :key="emoji"
           class="emoji-button"
-          @click="addReaction($event, emoji)"
+          @click="addReaction($event, emoji, close)"
         >
           {{ emoji }}
         </span>
@@ -28,23 +29,20 @@
           v-for="(emoji, key) in emojis"
           :key="key"
           class="emoji-button"
-          @click="addReaction($event, emoji.replacement)"
+          @click="addReaction($event, emoji.replacement, close)"
         >
           {{ emoji.replacement }}
         </span>
         <div class="reaction-bottom-fader" />
       </div>
     </div>
-    <div
+    <i
       v-if="loggedIn"
-      @click.prevent="openReactionSelect"
-    >
-      <i
-        class="icon-smile button-icon add-reaction-button"
-        :title="$t('tool_tip.add_reaction')"
-      />
-    </div>
-  </v-popover>
+      slot="trigger"
+      class="icon-smile button-icon add-reaction-button"
+      :title="$t('tool_tip.add_reaction')"
+    />
+  </Popover>
 </template>
 
 <script src="./react_button.js" ></script>
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 76b038d9..ca295640 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -177,6 +177,8 @@
                 <StatusPopover
                   v-if="!isPreview"
                   :status-id="status.in_reply_to_status_id"
+                  class="reply-to-popover"
+                  style="min-width: 0"
                 >
                   <a
                     class="reply-to"
@@ -572,11 +574,10 @@ $status-margin: 0.75em;
       align-items: stretch;
 
       > .reply-to-and-accountname > a {
+        overflow: hidden;
         max-width: 100%;
         text-overflow: ellipsis;
-        overflow: hidden;
         white-space: nowrap;
-        display: inline-block;
         word-break: break-all;
       }
     }
@@ -585,7 +586,6 @@ $status-margin: 0.75em;
       display: flex;
       height: 18px;
       margin-right: 0.5em;
-      overflow: hidden;
       max-width: 100%;
       .icon-reply {
         transform: scaleX(-1);
@@ -596,6 +596,10 @@ $status-margin: 0.75em;
       display: flex;
     }
 
+    .reply-to-popover {
+      min-width: 0;
+    }
+
     .reply-to {
       display: flex;
     }
@@ -603,6 +607,7 @@ $status-margin: 0.75em;
     .reply-to-text {
       overflow: hidden;
       text-overflow: ellipsis;
+      white-space: nowrap;
       margin: 0 0.4em 0 0.2em;
     }
 
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index 19f16bd9..cb55f67e 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -5,22 +5,14 @@ const StatusPopover = {
   props: [
     'statusId'
   ],
-  data () {
-    return {
-      popperOptions: {
-        modifiers: {
-          preventOverflow: { padding: { top: 50 }, boundariesElement: 'viewport' }
-        }
-      }
-    }
-  },
   computed: {
     status () {
       return find(this.$store.state.statuses.allStatuses, { id: this.statusId })
     }
   },
   components: {
-    Status: () => import('../status/status.vue')
+    Status: () => import('../status/status.vue'),
+    Popover: () => import('../popover/popover.vue')
   },
   methods: {
     enter () {
diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index eacf4c06..11f6cb5a 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -1,11 +1,16 @@
 <template>
-  <v-popover
+  <Popover
+    trigger="hover"
     popover-class="status-popover"
-    placement="top-start"
-    :popper-options="popperOptions"
-    @show="enter()"
+    :bound-to="{ x: 'container' }"
+    @show="enter"
   >
-    <template slot="popover">
+    <template slot="trigger">
+      <slot />
+    </template>
+    <div
+      slot="content"
+    >
       <Status
         v-if="status"
         :is-preview="true"
@@ -18,10 +23,8 @@
       >
         <i class="icon-spin4 animate-spin" />
       </div>
-    </template>
-
-    <slot />
-  </v-popover>
+    </div>
+  </Popover>
 </template>
 
 <script src="./status_popover.js" ></script>
@@ -29,44 +32,19 @@
 <style lang="scss">
 @import '../../_variables.scss';
 
-.tooltip.popover.status-popover {
+.status-popover {
   font-size: 1rem;
   min-width: 15em;
   max-width: 95%;
-  margin-left: 0.5em;
-
-  .popover-inner {
-    border-color: $fallback--border;
-    border-color: var(--border, $fallback--border);
-    border-style: solid;
-    border-width: 1px;
-    border-radius: $fallback--tooltipRadius;
-    border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
-    box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
-    box-shadow: var(--popupShadow);
-  }
 
-  .popover-arrow::before {
-    position: absolute;
-    content: '';
-    left: -7px;
-    border: solid 7px transparent;
-    z-index: -1;
-  }
-
-  &[x-placement^="bottom-start"] .popover-arrow::before {
-    top: -2px;
-    border-top-width: 0;
-    border-bottom-color: $fallback--border;
-    border-bottom-color: var(--border, $fallback--border);
-  }
-
-  &[x-placement^="top-start"] .popover-arrow::before {
-    bottom: -2px;
-    border-bottom-width: 0;
-    border-top-color: $fallback--border;
-    border-top-color: var(--border, $fallback--border);
-  }
+  border-color: $fallback--border;
+  border-color: var(--border, $fallback--border);
+  border-style: solid;
+  border-width: 1px;
+  border-radius: $fallback--tooltipRadius;
+  border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+  box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
+  box-shadow: var(--popupShadow);
 
   .status-el.status-el {
     border: none;
diff --git a/src/main.js b/src/main.js
index baf73ac8..be4213fa 100644
--- a/src/main.js
+++ b/src/main.js
@@ -31,7 +31,6 @@ import VueChatScroll from 'vue-chat-scroll'
 import VueClickOutside from 'v-click-outside'
 import PortalVue from 'portal-vue'
 import VBodyScrollLock from './directives/body_scroll_lock'
-import VTooltip from 'v-tooltip'
 
 import afterStoreSetup from './boot/after_store.js'
 
@@ -44,13 +43,6 @@ Vue.use(VueChatScroll)
 Vue.use(VueClickOutside)
 Vue.use(PortalVue)
 Vue.use(VBodyScrollLock)
-Vue.use(VTooltip, {
-  popover: {
-    defaultTrigger: 'hover click',
-    defaultContainer: false,
-    defaultOffset: 5
-  }
-})
 
 const i18n = new VueI18n({
   // By default, use the browser locale, we will update it if neccessary
diff --git a/yarn.lock b/yarn.lock
index b794042f..0defefcb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5941,11 +5941,6 @@ pngjs@^3.3.0:
   version "3.3.3"
   resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.3.3.tgz#85173703bde3edac8998757b96e5821d0966a21b"
 
-popper.js@^1.15.0:
-  version "1.15.0"
-  resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2"
-  integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA==
-
 portal-vue@^2.1.4:
   version "2.1.4"
   resolved "https://registry.yarnpkg.com/portal-vue/-/portal-vue-2.1.4.tgz#1fc679d77e294dc8d026f1eb84aa467de11b392e"
@@ -7823,15 +7818,6 @@ v-click-outside@^2.1.1:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/v-click-outside/-/v-click-outside-2.1.3.tgz#b7297abe833a439dc0895e6418a494381e64b5e7"
 
-v-tooltip@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/v-tooltip/-/v-tooltip-2.0.2.tgz#8610d9eece2cc44fd66c12ef2f12eec6435cab9b"
-  integrity sha512-xQ+qzOFfywkLdjHknRPgMMupQNS8yJtf9Utd5Dxiu/0n4HtrxqsgDtN2MLZ0LKbburtSAQgyypuE/snM8bBZhw==
-  dependencies:
-    lodash "^4.17.11"
-    popper.js "^1.15.0"
-    vue-resize "^0.4.5"
-
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
@@ -7906,11 +7892,6 @@ vue-loader@^14.0.0:
     vue-style-loader "^4.0.1"
     vue-template-es2015-compiler "^1.6.0"
 
-vue-resize@^0.4.5:
-  version "0.4.5"
-  resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-0.4.5.tgz#4777a23042e3c05620d9cbda01c0b3cc5e32dcea"
-  integrity sha512-bhP7MlgJQ8TIkZJXAfDf78uJO+mEI3CaLABLjv0WNzr4CcGRGPIAItyWYnP6LsPA4Oq0WE+suidNs6dgpO4RHg==
-
 vue-router@^3.0.1:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.2.tgz#dedc67afe6c4e2bc25682c8b1c2a8c0d7c7e56be"
-- 
cgit v1.2.3-70-g09d2

