From 046c60cb61bd585e2fb2850320d229ab0487d9ba Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 20 Oct 2020 21:18:23 +0300
Subject: cancel -> times

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

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

diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index e923d694..7973e5ef 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -56,7 +56,7 @@
                       class="dropdown-item dropdown-item-icon"
                       @click="deleteMessage"
                     >
-                      <i class="icon-cancel" /> {{ $t("chats.delete") }}
+                      <FAIcon icon="times" /> {{ $t("chats.delete") }}
                     </button>
                   </div>
                 </div>
-- 
cgit v1.2.3-70-g09d2


From b19c2eb0fb45aca4f4a59c4afacaffddf695987d Mon Sep 17 00:00:00 2001
From: Henry Jameson <me@hjkos.com>
Date: Tue, 20 Oct 2020 22:13:19 +0300
Subject: More stuff. Buttons in user card's corner now have MUCH bigger
 hitboxes

---
 src/App.scss                                       |  6 ++--
 src/components/account_actions/account_actions.js  |  8 ++++++
 src/components/account_actions/account_actions.vue | 32 +++++++++++-----------
 src/components/chat/chat.js                        |  6 ++--
 src/components/chat/chat.scss                      |  8 ++----
 src/components/chat/chat.vue                       |  2 +-
 src/components/chat_message/chat_message.js        |  6 ++--
 src/components/chat_message/chat_message.scss      |  2 +-
 src/components/chat_message/chat_message.vue       |  3 +-
 src/components/chat_new/chat_new.js                | 10 +++++++
 src/components/chat_new/chat_new.scss              |  6 ++--
 src/components/chat_new/chat_new.vue               |  4 +--
 src/components/media_modal/media_modal.js          | 10 +++++++
 src/components/media_modal/media_modal.vue         |  4 +--
 src/components/mobile_nav/mobile_nav.js            |  8 ++++--
 src/components/mobile_nav/mobile_nav.vue           | 11 ++++----
 .../mobile_post_status_button.js                   |  8 ++++++
 .../mobile_post_status_button.vue                  |  4 +--
 src/components/search/search.js                    |  8 ++++--
 src/components/search/search.vue                   |  2 +-
 src/components/search_bar/search_bar.js            |  6 ++--
 src/components/search_bar/search_bar.vue           |  2 +-
 .../settings_modal/settings_modal_content.scss     |  2 +-
 src/components/settings_modal/tabs/general_tab.js  |  6 ++--
 src/components/settings_modal/tabs/general_tab.vue |  2 +-
 src/components/settings_modal/tabs/profile_tab.js  |  6 ++--
 src/components/settings_modal/tabs/profile_tab.vue |  2 +-
 src/components/side_drawer/side_drawer.js          | 28 +++++++++++++++++++
 src/components/side_drawer/side_drawer.vue         | 24 ++++++++--------
 src/components/user_card/user_card.vue             | 20 ++++++++++----
 src/hocs/with_load_more/with_load_more.js          | 12 +++++++-
 src/hocs/with_subscription/with_subscription.js    | 12 +++++++-
 32 files changed, 188 insertions(+), 82 deletions(-)

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

diff --git a/src/App.scss b/src/App.scss
index 10969abb..126a3297 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -85,7 +85,7 @@ button {
   font-family: sans-serif;
   font-family: var(--interfaceFont, sans-serif);
 
-  i[class*=icon-] {
+  i[class*=icon-], .svg-inline--fa {
     color: $fallback--text;
     color: var(--btnText, $fallback--text);
   }
@@ -389,7 +389,7 @@ i[class*=icon-], .svg-inline--fa  {
   box-sizing: border-box;
 
   button {
-    &, i[class*=icon-] {
+    &, i[class*=icon-], svg {
       color: $fallback--text;
       color: var(--btnTopBarText, $fallback--text);
     }
@@ -463,7 +463,7 @@ i[class*=icon-], .svg-inline--fa  {
     flex-basis: 970px;
     height: 50px;
 
-    a, a i {
+    a, a i, a svg {
       color: $fallback--link;
       color: var(--topBarLink, $fallback--link);
     }
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 6d345bc7..395d6685 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,6 +1,14 @@
 import { mapState } from 'vuex'
 import ProgressButton from '../progress_button/progress_button.vue'
 import Popover from '../popover/popover.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faEllipsisV
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faEllipsisV
+)
 
 const AccountActions = {
   props: [
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 987e94b7..61099d4f 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -1,5 +1,5 @@
 <template>
-  <div class="account-actions">
+  <div class="AccountActions">
     <Popover
       trigger="click"
       placement="bottom"
@@ -63,7 +63,7 @@
         slot="trigger"
         class="btn btn-default ellipsis-button"
       >
-        <i class="icon-ellipsis trigger-button" />
+        <FAIcon class="icon" icon="ellipsis-v" />
       </div>
     </Popover>
   </div>
@@ -73,22 +73,22 @@
 
 <style lang="scss">
 @import '../../_variables.scss';
-.account-actions {
-  margin: 0 .8em;
-}
+.AccountActions {
+  button.dropdown-item {
+    margin-left: 0;
+  }
 
-.account-actions button.dropdown-item {
-  margin-left: 0;
-}
+  .ellipsis-button {
+    cursor: pointer;
+    width: 2.5em;
+    margin: -0.5em 0;
+    padding: 0.5em 0;
+    text-align: center;
 
-.account-actions .trigger-button {
-  color: $fallback--lightText;
-  color: var(--lightText, $fallback--lightText);
-  opacity: .8;
-  cursor: pointer;
-  &:hover {
-    color: $fallback--text;
-    color: var(--text, $fallback--text);
+    &:not(:hover) .icon {
+      color: $fallback--lightText;
+      color: var(--lightText, $fallback--lightText);
+    }
   }
 }
 </style>
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index 1630ba80..083f850f 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -9,11 +9,13 @@ import { promiseInterval } from '../../services/promise_interval/promise_interva
 import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
-  faChevronDown
+  faChevronDown,
+  faChevronLeft
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
-  faChevronDown
+  faChevronDown,
+  faChevronLeft
 )
 
 const BOTTOMED_OUT_OFFSET = 10
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
index 012a1b1d..b7b0d377 100644
--- a/src/components/chat/chat.scss
+++ b/src/components/chat/chat.scss
@@ -58,12 +58,8 @@
 
   .go-back-button {
     cursor: pointer;
-    margin-right: 1.4em;
-
-    i {
-      display: flex;
-      align-items: center;
-    }
+    margin-right: 1.7em;
+    margin-left: 0.3em;
   }
 
   .jump-to-bottom-button {
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
index 0d44c920..0670f1ac 100644
--- a/src/components/chat/chat.vue
+++ b/src/components/chat/chat.vue
@@ -14,7 +14,7 @@
             class="go-back-button"
             @click="goBack"
           >
-            <i class="button-icon icon-left-open" />
+            <FAIcon size="lg" icon="chevron-left" />
           </a>
           <div class="title text-center">
             <ChatTitle
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index 4ad993e3..bb380f87 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -9,11 +9,13 @@ import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
-  faTimes
+  faTimes,
+  faEllipsisH
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
-  faTimes
+  faTimes,
+  faEllipsisH
 )
 
 const ChatMessage = {
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index 7d4ff60c..53ca7cce 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -24,7 +24,7 @@
     }
   }
 
-  .icon-ellipsis {
+  .menu-icon {
     cursor: pointer;
 
     &:hover, .extra-button-popover.open & {
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index 7973e5ef..d5b8bb9e 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -62,9 +62,10 @@
                 </div>
                 <button
                   slot="trigger"
+                  class="menu-icon"
                   :title="$t('chats.more')"
                 >
-                  <i class="icon-ellipsis" />
+                  <FAIcon icon="ellipsis-h" />
                 </button>
               </Popover>
             </div>
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
index d023efc0..71585995 100644
--- a/src/components/chat_new/chat_new.js
+++ b/src/components/chat_new/chat_new.js
@@ -1,6 +1,16 @@
 import { mapState, mapGetters } from 'vuex'
 import BasicUserCard from '../basic_user_card/basic_user_card.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faSearch,
+  faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faSearch,
+  faChevronLeft
+)
 
 const chatNew = {
   components: {
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
index 11305444..716172b0 100644
--- a/src/components/chat_new/chat_new.scss
+++ b/src/components/chat_new/chat_new.scss
@@ -8,9 +8,7 @@
     }
   }
 
-  .icon-search {
-    font-size: 1.5em;
-    float: right;
+  .search-icon {
     margin-right: 0.3em;
   }
 
@@ -25,5 +23,7 @@
 
   .go-back-button {
     cursor: pointer;
+    margin-right: 1.7em;
+    margin-left: 0.3em;
   }
 }
diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue
index 3333dbf9..95eebe6b 100644
--- a/src/components/chat_new/chat_new.vue
+++ b/src/components/chat_new/chat_new.vue
@@ -11,12 +11,12 @@
         class="go-back-button"
         @click="goBack"
       >
-        <i class="button-icon icon-left-open" />
+        <FAIcon size="lg" icon="chevron-left" />
       </a>
     </div>
     <div class="input-wrap">
       <div class="input-search">
-        <i class="button-icon icon-search" />
+        <FAIcon size="lg" class="search-icon button-icon" icon="search" />
       </div>
       <input
         ref="search"
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index 24764e80..e7384c93 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -3,6 +3,16 @@ import VideoAttachment from '../video_attachment/video_attachment.vue'
 import Modal from '../modal/modal.vue'
 import fileTypeService from '../../services/file_type/file_type.service.js'
 import GestureService from '../../services/gesture_service/gesture_service'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faChevronLeft,
+  faChevronRight
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faChevronLeft,
+  faChevronRight
+)
 
 const MediaModal = {
   components: {
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 46931667..cbcfc6d2 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -34,7 +34,7 @@
       class="modal-view-button-arrow modal-view-button-arrow--prev"
       @click.stop.prevent="goPrev"
     >
-      <i class="icon-left-open arrow-icon" />
+      <FAIcon class="arrow-icon" icon="chevron-left" />
     </button>
     <button
       v-if="canNavigate"
@@ -42,7 +42,7 @@
       class="modal-view-button-arrow modal-view-button-arrow--next"
       @click.stop.prevent="goNext"
     >
-      <i class="icon-right-open arrow-icon" />
+      <FAIcon class="arrow-icon" icon="chevron-right" />
     </button>
   </Modal>
 </template>
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index bd32b266..9e736cfb 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -5,11 +5,15 @@ import GestureService from '../../services/gesture_service/gesture_service'
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
-  faTimes
+  faTimes,
+  faBell,
+  faBars
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
-  faTimes
+  faTimes,
+  faBell,
+  faBars
 )
 
 const MobileNav = {
diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue
index e5664dc5..4d91af77 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -15,7 +15,7 @@
             class="mobile-nav-button"
             @click.stop.prevent="toggleMobileSidebar()"
           >
-            <i class="button-icon icon-menu" />
+            <FAIcon size="lg" class="button-icon" icon="bars" />
             <div
               v-if="unreadChatCount"
               class="alert-dot"
@@ -37,7 +37,7 @@
             href="#"
             @click.stop.prevent="openMobileNotifications()"
           >
-            <i class="button-icon icon-bell-alt" />
+            <FAIcon size="lg" class="button-icon" icon="bell" />
             <div
               v-if="unseenNotificationsCount"
               class="alert-dot"
@@ -59,7 +59,7 @@
           class="mobile-nav-button"
           @click.stop.prevent="closeMobileNotifications()"
         >
-          <FAIcon class="button-icon" icon="times" />
+          <FAIcon size="lg" class="button-icon" icon="times" />
         </a>
       </div>
       <div
@@ -91,9 +91,8 @@
 }
 
 .mobile-nav-button {
-  display: flex;
-  justify-content: center;
-  width: 50px;
+  text-align: center;
+  margin: 0 1em;
   position: relative;
   cursor: pointer;
 }
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.js b/src/components/mobile_post_status_button/mobile_post_status_button.js
index 6348277b..366ea89c 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.js
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.js
@@ -1,4 +1,12 @@
 import { debounce } from 'lodash'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faPen
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faPen
+)
 
 const HIDDEN_FOR_PAGES = new Set([
   'chats',
diff --git a/src/components/mobile_post_status_button/mobile_post_status_button.vue b/src/components/mobile_post_status_button/mobile_post_status_button.vue
index 9cf45de3..50529878 100644
--- a/src/components/mobile_post_status_button/mobile_post_status_button.vue
+++ b/src/components/mobile_post_status_button/mobile_post_status_button.vue
@@ -5,7 +5,7 @@
       :class="{ 'hidden': isHidden }"
       @click="openPostForm"
     >
-      <i class="icon-edit" />
+      <FAIcon icon="pen" />
     </button>
   </div>
 </template>
@@ -39,7 +39,7 @@
     transform: translateY(150%);
   }
 
-  i {
+  svg {
     font-size: 1.5em;
     color: $fallback--text;
     color: var(--text, $fallback--text);
diff --git a/src/components/search/search.js b/src/components/search/search.js
index 3eb92fc1..b62bc2c5 100644
--- a/src/components/search/search.js
+++ b/src/components/search/search.js
@@ -3,10 +3,14 @@ import Conversation from '../conversation/conversation.vue'
 import Status from '../status/status.vue'
 import map from 'lodash/map'
 import { library } from '@fortawesome/fontawesome-svg-core'
-import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
+import {
+  faCircleNotch,
+  faSearch
+} from '@fortawesome/free-solid-svg-icons'
 
 library.add(
-  faCircleNotch
+  faCircleNotch,
+  faSearch
 )
 
 const Search = {
diff --git a/src/components/search/search.vue b/src/components/search/search.vue
index a6677e4b..d32f48d9 100644
--- a/src/components/search/search.vue
+++ b/src/components/search/search.vue
@@ -17,7 +17,7 @@
         class="btn search-button"
         @click="newQuery(searchTerm)"
       >
-        <i class="icon-search" />
+        <FAIcon icon="search" />
       </button>
     </div>
     <div
diff --git a/src/components/search_bar/search_bar.js b/src/components/search_bar/search_bar.js
index 3699e262..7ae8b21b 100644
--- a/src/components/search_bar/search_bar.js
+++ b/src/components/search_bar/search_bar.js
@@ -1,10 +1,12 @@
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
-  faTimes
+  faTimes,
+  faSearch
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
-  faTimes
+  faTimes,
+  faSearch
 )
 
 const SearchBar = {
diff --git a/src/components/search_bar/search_bar.vue b/src/components/search_bar/search_bar.vue
index 6a08ebe5..ecc0febf 100644
--- a/src/components/search_bar/search_bar.vue
+++ b/src/components/search_bar/search_bar.vue
@@ -27,7 +27,7 @@
           class="btn search-button"
           @click="find(searchTerm)"
         >
-          <i class="icon-search" />
+          <FAIcon icon="search" />
         </button>
         <FAIcon
           class="button-icon" icon="times"
diff --git a/src/components/settings_modal/settings_modal_content.scss b/src/components/settings_modal/settings_modal_content.scss
index a3fef1cf..f066234c 100644
--- a/src/components/settings_modal/settings_modal_content.scss
+++ b/src/components/settings_modal/settings_modal_content.scss
@@ -31,7 +31,7 @@
     }
 
     .unavailable,
-    .unavailable i {
+    .unavailable svg {
       color: var(--cRed, $fallback--cRed);
       color: $fallback--cRed;
     }
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index a39d7071..df592a10 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -4,11 +4,13 @@ import InterfaceLanguageSwitcher from 'src/components/interface_language_switche
 import SharedComputedObject from '../helpers/shared_computed_object.js'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
-  faChevronDown
+  faChevronDown,
+  faGlobe
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
-  faChevronDown
+  faChevronDown,
+  faGlobe
 )
 
 const GeneralTab = {
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 9fc1470e..2ab6b314 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -222,7 +222,7 @@
                 v-if="!loopSilentAvailable"
                 class="unavailable"
               >
-                <i class="icon-globe" />! {{ $t('settings.limited_availability') }}
+                <FAIcon icon="globe" />! {{ $t('settings.limited_availability') }}
               </div>
             </li>
           </ul>
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 37e829bb..22037218 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -10,11 +10,13 @@ import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
 import Checkbox from 'src/components/checkbox/checkbox.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
-  faTimes
+  faTimes,
+  faPlus
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
-  faTimes
+  faTimes,
+  faPlus
 )
 
 const ProfileTab = {
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index df54551c..7013b65d 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -139,7 +139,7 @@
           class="add-field faint"
           @click="addField"
         >
-          <i class="icon-plus" />
+          <FAIcon icon="plus" />
           {{ $t("settings.profile_fields.add_field") }}
         </a>
       </div>
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 281052e5..fe736168 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -2,6 +2,34 @@ import { mapState, mapGetters } from 'vuex'
 import UserCard from '../user_card/user_card.vue'
 import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
 import GestureService from '../../services/gesture_service/gesture_service'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faSignInAlt,
+  faSignOutAlt,
+  faHome,
+  faComments,
+  faBell,
+  faUserPlus,
+  faBullhorn,
+  faSearch,
+  faTachometerAlt,
+  faCog,
+  faInfoCircle
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faSignInAlt,
+  faSignOutAlt,
+  faHome,
+  faComments,
+  faBell,
+  faUserPlus,
+  faBullhorn,
+  faSearch,
+  faTachometerAlt,
+  faCog,
+  faInfoCircle
+)
 
 const SideDrawer = {
   props: [ 'logout' ],
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index eda5a68c..fbdb2441 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -36,7 +36,7 @@
           @click="toggleDrawer"
         >
           <router-link :to="{ name: 'login' }">
-            <i class="button-icon icon-login" /> {{ $t("login.login") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="sign-in-alt" /> {{ $t("login.login") }}
           </router-link>
         </li>
         <li
@@ -44,7 +44,7 @@
           @click="toggleDrawer"
         >
           <router-link :to="{ name: timelinesRoute }">
-            <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="home" /> {{ $t("nav.timelines") }}
           </router-link>
         </li>
         <li
@@ -55,7 +55,7 @@
             :to="{ name: 'chats', params: { username: currentUser.screen_name } }"
             style="position: relative"
           >
-            <i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="comments" /> {{ $t("nav.chats") }}
             <span
               v-if="unreadChatCount"
               class="badge badge-notification unread-chat-count"
@@ -68,7 +68,7 @@
       <ul v-if="currentUser">
         <li @click="toggleDrawer">
           <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
-            <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="bell" /> {{ $t("nav.interactions") }}
           </router-link>
         </li>
         <li
@@ -76,7 +76,7 @@
           @click="toggleDrawer"
         >
           <router-link to="/friend-requests">
-            <i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="user-plus" /> {{ $t("nav.friend_requests") }}
             <span
               v-if="followRequestCount > 0"
               class="badge follow-request-count"
@@ -90,7 +90,7 @@
           @click="toggleDrawer"
         >
           <router-link :to="{ name: 'chat' }">
-            <i class="button-icon icon-megaphone" /> {{ $t("shoutbox.title") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="bullhorn" /> {{ $t("shoutbox.title") }}
           </router-link>
         </li>
       </ul>
@@ -100,7 +100,7 @@
           @click="toggleDrawer"
         >
           <router-link :to="{ name: 'search' }">
-            <i class="button-icon icon-search" /> {{ $t("nav.search") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="search" /> {{ $t("nav.search") }}
           </router-link>
         </li>
         <li
@@ -108,7 +108,7 @@
           @click="toggleDrawer"
         >
           <router-link :to="{ name: 'who-to-follow' }">
-            <i class="button-icon icon-user-plus" /> {{ $t("nav.who_to_follow") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="user-plus" /> {{ $t("nav.who_to_follow") }}
           </router-link>
         </li>
         <li @click="toggleDrawer">
@@ -116,12 +116,12 @@
             href="#"
             @click="openSettingsModal"
           >
-            <i class="button-icon icon-cog" /> {{ $t("settings.settings") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="cog" /> {{ $t("settings.settings") }}
           </a>
         </li>
         <li @click="toggleDrawer">
           <router-link :to="{ name: 'about'}">
-            <i class="button-icon icon-info-circled" /> {{ $t("nav.about") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="info-circle" /> {{ $t("nav.about") }}
           </router-link>
         </li>
         <li
@@ -132,7 +132,7 @@
             href="/pleroma/admin/#/login-pleroma"
             target="_blank"
           >
-            <i class="button-icon icon-gauge" /> {{ $t("nav.administration") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="tachometer-alt" /> {{ $t("nav.administration") }}
           </a>
         </li>
         <li
@@ -143,7 +143,7 @@
             href="#"
             @click="doLogout"
           >
-            <i class="button-icon icon-logout" /> {{ $t("login.logout") }}
+            <FAIcon size="lg" fixed-width class="button-icon" icon="sign-out-alt" /> {{ $t("login.logout") }}
           </a>
         </li>
       </ul>
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index cfdeaa17..4f7df789 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -54,8 +54,9 @@
                 v-if="isOtherUser && !user.is_local"
                 :href="user.statusnet_profile_url"
                 target="_blank"
+                class="external-link-button"
               >
-                <FAIcon class="usersettings" icon="external-link-alt" />
+                <FAIcon class="icon" icon="external-link-alt" />
               </a>
               <AccountActions
                 v-if="isOtherUser && loggedIn"
@@ -289,7 +290,7 @@
     mask: linear-gradient(to top, white, transparent) bottom no-repeat,
           linear-gradient(to top, white, white);
     // Autoprefixed seem to ignore this one, and also syntax is different
-    -webkit-mask-composite: xor;
+                                                 -webkit-mask-composite: xor;
     mask-composite: exclude;
     background-size: cover;
     mask-size: 100% 60%;
@@ -404,10 +405,17 @@
     }
   }
 
-  .usersettings {
-    color: $fallback--lightText;
-    color: var(--lightText, $fallback--lightText);
-    opacity: .8;
+  .external-link-button {
+    cursor: pointer;
+    width: 2.5em;
+    text-align: center;
+    margin: -0.5em 0;
+    padding: 0.5em 0;
+
+    &:not(:hover) .icon {
+      color: $fallback--lightText;
+      color: var(--lightText, $fallback--lightText);
+    }
   }
 
   .user-summary {
diff --git a/src/hocs/with_load_more/with_load_more.js b/src/hocs/with_load_more/with_load_more.js
index 6142f513..afb51a0f 100644
--- a/src/hocs/with_load_more/with_load_more.js
+++ b/src/hocs/with_load_more/with_load_more.js
@@ -3,6 +3,16 @@ import isEmpty from 'lodash/isEmpty'
 import { getComponentProps } from '../../services/component_utils/component_utils'
 import './with_load_more.scss'
 
+import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faCircleNotch
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faCircleNotch
+)
+
 const withLoadMore = ({
   fetch, // function to fetch entries and return a promise
   select, // function to select data from store
@@ -82,7 +92,7 @@ const withLoadMore = ({
           </WrappedComponent>
           <div class="with-load-more-footer">
             {this.error && <a onClick={this.fetchEntries} class="alert error">{this.$t('general.generic_error')}</a>}
-            {!this.error && this.loading && <i class="icon-spin3 animate-spin"/>}
+            {!this.error && this.loading && <FAIcon spin icon="circle-notch"/>}
             {!this.error && !this.loading && !this.bottomedOut && <a onClick={this.fetchEntries}>{this.$t('general.more')}</a>}
           </div>
         </div>
diff --git a/src/hocs/with_subscription/with_subscription.js b/src/hocs/with_subscription/with_subscription.js
index 1775adcb..b1244276 100644
--- a/src/hocs/with_subscription/with_subscription.js
+++ b/src/hocs/with_subscription/with_subscription.js
@@ -3,6 +3,16 @@ import isEmpty from 'lodash/isEmpty'
 import { getComponentProps } from '../../services/component_utils/component_utils'
 import './with_subscription.scss'
 
+import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faCircleNotch
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faCircleNotch
+)
+
 const withSubscription = ({
   fetch, // function to fetch entries and return a promise
   select, // function to select data from store
@@ -72,7 +82,7 @@ const withSubscription = ({
           <div class="with-subscription-loading">
             {this.error
               ? <a onClick={this.fetchData} class="alert error">{this.$t('general.generic_error')}</a>
-              : <i class="icon-spin3 animate-spin"/>
+              : <FAIcon spin icon="circle-notch"/>
             }
           </div>
         )
-- 
cgit v1.2.3-70-g09d2


From e798e9a4177f025dda2b40d109fa40c2ebfd814e Mon Sep 17 00:00:00 2001
From: eugenijm <eugenijm@protonmail.com>
Date: Thu, 29 Oct 2020 13:33:06 +0300
Subject: Optimistic message sending for chat

---
 src/components/chat/chat.js                        | 76 ++++++++++++++++------
 src/components/chat/chat.vue                       |  1 +
 src/components/chat_message/chat_message.scss      | 13 ++++
 src/components/chat_message/chat_message.vue       |  2 +-
 .../post_status_form/post_status_form.js           |  7 +-
 .../post_status_form/post_status_form.vue          |  4 +-
 src/modules/api.js                                 | 18 +++--
 src/modules/chats.js                               | 22 +++++--
 src/services/api/api.service.js                    | 17 ++++-
 src/services/chat_service/chat_service.js          | 63 ++++++++++++++++--
 src/services/chat_utils/chat_utils.js              | 21 ++++++
 .../entity_normalizer/entity_normalizer.service.js |  3 +
 .../services/chat_service/chat_service.spec.js     |  3 +
 13 files changed, 206 insertions(+), 44 deletions(-)

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

diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index c0c9ad6c..2887afb5 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -12,6 +12,7 @@ import {
   faChevronDown,
   faChevronLeft
 } from '@fortawesome/free-solid-svg-icons'
+import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
 
 library.add(
   faChevronDown,
@@ -22,6 +23,7 @@ const BOTTOMED_OUT_OFFSET = 10
 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
 const SAFE_RESIZE_TIME_OFFSET = 100
 const MARK_AS_READ_DELAY = 1500
+const MAX_RETRIES = 10
 
 const Chat = {
   components: {
@@ -35,7 +37,8 @@ const Chat = {
       hoveredMessageChainId: undefined,
       lastScrollPosition: {},
       scrollableContainerHeight: '100%',
-      errorLoadingChat: false
+      errorLoadingChat: false,
+      messageRetriers: {}
     }
   },
   created () {
@@ -219,7 +222,10 @@ const Chat = {
       if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return }
       if (document.hidden) { return }
       const lastReadId = this.currentChatMessageService.maxId
-      this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
+      this.$store.dispatch('readChat', {
+        id: this.currentChat.id,
+        lastReadId
+      })
     },
     bottomedOut (offset) {
       return isBottomedOut(this.$refs.scrollable, offset)
@@ -309,42 +315,74 @@ const Chat = {
       })
       this.fetchChat({ isFirstFetch: true })
     },
-    sendMessage ({ status, media }) {
+    handleAttachmentPosting () {
+      this.$nextTick(() => {
+        this.handleResize()
+        // When the posting form size changes because of a media attachment, we need an extra resize
+        // to account for the potential delay in the DOM update.
+        setTimeout(() => {
+          this.updateScrollableContainerHeight()
+        }, SAFE_RESIZE_TIME_OFFSET)
+        this.scrollDown({ forceRead: true })
+      })
+    },
+    sendMessage ({ status, media, idempotencyKey }) {
       const params = {
         id: this.currentChat.id,
-        content: status
+        content: status,
+        idempotencyKey
       }
 
       if (media[0]) {
         params.mediaId = media[0].id
       }
 
-      return this.backendInteractor.sendChatMessage(params)
+      const fakeMessage = buildFakeMessage({
+        attachments: media,
+        chatId: this.currentChat.id,
+        content: status,
+        userId: this.currentUser.id,
+        idempotencyKey
+      })
+
+      this.$store.dispatch('addChatMessages', {
+        chatId: this.currentChat.id,
+        messages: [fakeMessage]
+      }).then(() => {
+        this.handleAttachmentPosting()
+      })
+
+      return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES })
+    },
+    doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
+      if (retriesLeft <= 0) return
+
+      this.backendInteractor.sendChatMessage(params)
         .then(data => {
           this.$store.dispatch('addChatMessages', {
             chatId: this.currentChat.id,
-            messages: [data],
-            updateMaxId: false
-          }).then(() => {
-            this.$nextTick(() => {
-              this.handleResize()
-              // When the posting form size changes because of a media attachment, we need an extra resize
-              // to account for the potential delay in the DOM update.
-              setTimeout(() => {
-                this.updateScrollableContainerHeight()
-              }, SAFE_RESIZE_TIME_OFFSET)
-              this.scrollDown({ forceRead: true })
-            })
+            updateMaxId: false,
+            messages: [{ ...data, fakeId: fakeMessage.id }]
           })
 
           return data
         })
         .catch(error => {
           console.error('Error sending message', error)
-          return {
-            error: this.$t('chats.error_sending_message')
+          this.$store.dispatch('handleMessageError', {
+            chatId: this.currentChat.id,
+            fakeId: fakeMessage.id,
+            isRetry: retriesLeft !== MAX_RETRIES
+          })
+          if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') {
+            this.messageRetriers[fakeMessage.id] = setTimeout(() => {
+              this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 })
+            }, 1000 * (2 ** (MAX_RETRIES - retriesLeft)))
           }
+          return {}
         })
+
+      return Promise.resolve(fakeMessage)
     },
     goBack () {
       this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
index 5f58b9a6..94a0097c 100644
--- a/src/components/chat/chat.vue
+++ b/src/components/chat/chat.vue
@@ -80,6 +80,7 @@
               :disable-sensitivity-checkbox="true"
               :disable-submit="errorLoadingChat || !currentChat"
               :disable-preview="true"
+              :optimistic-posting="true"
               :post-handler="sendMessage"
               :submit-on-enter="!mobileLayout"
               :preserve-focus="!mobileLayout"
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index 53ca7cce..5af744a3 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -101,6 +101,19 @@
     }
   }
 
+  .pending {
+    .status-content.media-body, .created-at {
+      color: var(--faint);
+    }
+  }
+
+  .error {
+    .status-content.media-body, .created-at {
+      color: $fallback--cRed;
+      color: var(--badgeNotification, $fallback--cRed);
+    }
+  }
+
   .incoming {
     a {
       color: var(--chatMessageIncomingLink, $fallback--link);
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index d5b8bb9e..3849ab6e 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -32,7 +32,7 @@
         >
           <div
             class="media status"
-            :class="{ 'without-attachment': !hasAttachment }"
+            :class="{ 'without-attachment': !hasAttachment, 'pending': chatViewItem.data.pending, 'error': chatViewItem.data.error }"
             style="position: relative"
             @mouseenter="hovered = true"
             @mouseleave="hovered = false"
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 1bdf9833..de583269 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -75,7 +75,8 @@ const PostStatusForm = {
     'autoFocus',
     'fileLimit',
     'submitOnEnter',
-    'emojiPickerPlacement'
+    'emojiPickerPlacement',
+    'optimisticPosting'
   ],
   components: {
     MediaUpload,
@@ -272,7 +273,7 @@ const PostStatusForm = {
       if (this.preview) this.previewStatus()
     },
     async postStatus (event, newStatus, opts = {}) {
-      if (this.posting) { return }
+      if (this.posting && !this.optimisticPosting) { return }
       if (this.disableSubmit) { return }
       if (this.emojiInputShown) { return }
       if (this.submitOnEnter) {
@@ -280,6 +281,8 @@ const PostStatusForm = {
         event.preventDefault()
       }
 
+      if (this.optimisticPosting && (this.emptyStatus || this.isOverLengthLimit)) { return }
+
       if (this.emptyStatus) {
         this.error = this.$t('post_status.empty_status_error')
         return
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 284ce8df..83b60bc7 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -124,7 +124,7 @@
             v-model="newStatus.spoilerText"
             type="text"
             :placeholder="$t('post_status.content_warning')"
-            :disabled="posting"
+            :disabled="posting && !optimisticPosting"
             size="1"
             class="form-post-subject"
           >
@@ -150,7 +150,7 @@
             :placeholder="placeholder || $t('post_status.default')"
             rows="1"
             cols="1"
-            :disabled="posting"
+            :disabled="posting && !optimisticPosting"
             class="form-post-body"
             :class="{ 'scrollable-form': !!maxHeight }"
             @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
diff --git a/src/modules/api.js b/src/modules/api.js
index 0a354c3f..08485a30 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -75,12 +75,18 @@ const api = {
               } else if (message.event === 'delete') {
                 dispatch('deleteStatusById', message.id)
               } else if (message.event === 'pleroma:chat_update') {
-                dispatch('addChatMessages', {
-                  chatId: message.chatUpdate.id,
-                  messages: [message.chatUpdate.lastMessage]
-                })
-                dispatch('updateChat', { chat: message.chatUpdate })
-                maybeShowChatNotification(store, message.chatUpdate)
+                // The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
+                // The cause of the duplicates is the WS event arriving earlier than the HTTP response.
+                // This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
+                // (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
+                setTimeout(() => {
+                  dispatch('addChatMessages', {
+                    chatId: message.chatUpdate.id,
+                    messages: [message.chatUpdate.lastMessage]
+                  })
+                  dispatch('updateChat', { chat: message.chatUpdate })
+                  maybeShowChatNotification(store, message.chatUpdate)
+                }, 100)
               }
             }
           )
diff --git a/src/modules/chats.js b/src/modules/chats.js
index 21e30933..0a373d88 100644
--- a/src/modules/chats.js
+++ b/src/modules/chats.js
@@ -16,7 +16,8 @@ const defaultState = {
   openedChats: {},
   openedChatMessageServices: {},
   fetcher: undefined,
-  currentChatId: null
+  currentChatId: null,
+  lastReadMessageId: null
 }
 
 const getChatById = (state, id) => {
@@ -92,9 +93,14 @@ const chats = {
       commit('setCurrentChatFetcher', { fetcher: undefined })
     },
     readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
+      const isNewMessage = rootState.chats.lastReadMessageId !== lastReadId
+
       dispatch('resetChatNewMessageCount')
-      commit('readChat', { id })
-      rootState.api.backendInteractor.readChat({ id, lastReadId })
+      commit('readChat', { id, lastReadId })
+
+      if (isNewMessage) {
+        rootState.api.backendInteractor.readChat({ id, lastReadId })
+      }
     },
     deleteChatMessage ({ rootState, commit }, value) {
       rootState.api.backendInteractor.deleteChatMessage(value)
@@ -106,6 +112,9 @@ const chats = {
     },
     clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
       commit('clearOpenedChats', { commit })
+    },
+    handleMessageError ({ commit }, value) {
+      commit('handleMessageError', { commit, ...value })
     }
   },
   mutations: {
@@ -208,11 +217,16 @@ const chats = {
         }
       }
     },
-    readChat (state, { id }) {
+    readChat (state, { id, lastReadId }) {
+      state.lastReadMessageId = lastReadId
       const chat = getChatById(state, id)
       if (chat) {
         chat.unread = 0
       }
+    },
+    handleMessageError (state, { chatId, fakeId, isRetry }) {
+      const chatMessageService = state.openedChatMessageServices[chatId]
+      chatService.handleMessageError(chatMessageService, fakeId, isRetry)
     }
   }
 }
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 1a3495d4..22b5e8ba 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -129,7 +129,11 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
             return reject(new StatusCodeError(response.status, json, { url, options }, response))
           }
           return resolve(json)
-        }))
+        })
+        .catch((error) => {
+          return reject(new StatusCodeError(response.status, error, { url, options }, response))
+        })
+      )
     })
 }
 
@@ -1210,7 +1214,7 @@ const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
   })
 }
 
-const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
+const sendChatMessage = ({ id, content, mediaId = null, idempotencyKey, credentials }) => {
   const payload = {
     'content': content
   }
@@ -1219,11 +1223,18 @@ const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
     payload['media_id'] = mediaId
   }
 
+  const headers = {}
+
+  if (idempotencyKey) {
+    headers['idempotency-key'] = idempotencyKey
+  }
+
   return promisedRequest({
     url: PLEROMA_CHAT_MESSAGES_URL(id),
     method: 'POST',
     payload: payload,
-    credentials
+    credentials,
+    headers
   })
 }
 
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
index 95c69482..815af82e 100644
--- a/src/services/chat_service/chat_service.js
+++ b/src/services/chat_service/chat_service.js
@@ -3,6 +3,7 @@ import _ from 'lodash'
 const empty = (chatId) => {
   return {
     idIndex: {},
+    idempotencyKeyIndex: {},
     messages: [],
     newMessageCount: 0,
     lastSeenTimestamp: 0,
@@ -13,8 +14,18 @@ const empty = (chatId) => {
 }
 
 const clear = (storage) => {
-  storage.idIndex = {}
-  storage.messages.splice(0, storage.messages.length)
+  const failedMessageIds = []
+
+  for (const message of storage.messages) {
+    if (message.error) {
+      failedMessageIds.push(message.id)
+    } else {
+      delete storage.idIndex[message.id]
+      delete storage.idempotencyKeyIndex[message.id]
+    }
+  }
+
+  storage.messages = storage.messages.filter(m => failedMessageIds.includes(m.id))
   storage.newMessageCount = 0
   storage.lastSeenTimestamp = 0
   storage.minId = undefined
@@ -37,6 +48,25 @@ const deleteMessage = (storage, messageId) => {
   }
 }
 
+const handleMessageError = (storage, fakeId, isRetry) => {
+  if (!storage) { return }
+  const fakeMessage = storage.idIndex[fakeId]
+  if (fakeMessage) {
+    fakeMessage.error = true
+    fakeMessage.pending = false
+    if (!isRetry) {
+      // Ensure the failed message doesn't stay at the bottom of the list.
+      const lastPersistedMessage = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'desc'])[0]
+      if (lastPersistedMessage) {
+        const oldId = fakeMessage.id
+        fakeMessage.id = `${lastPersistedMessage.id}-${new Date().getTime()}`
+        storage.idIndex[fakeMessage.id] = fakeMessage
+        delete storage.idIndex[oldId]
+      }
+    }
+  }
+}
+
 const add = (storage, { messages: newMessages, updateMaxId = true }) => {
   if (!storage) { return }
   for (let i = 0; i < newMessages.length; i++) {
@@ -45,7 +75,19 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
     // sanity check
     if (message.chat_id !== storage.chatId) { return }
 
-    if (!storage.minId || message.id < storage.minId) {
+    if (message.fakeId) {
+      const fakeMessage = storage.idIndex[message.fakeId]
+      if (fakeMessage) {
+        Object.assign(fakeMessage, message, { error: false })
+        delete fakeMessage['fakeId']
+        storage.idIndex[fakeMessage.id] = fakeMessage
+        delete storage.idIndex[message.fakeId]
+
+        return
+      }
+    }
+
+    if (!storage.minId || (!message.pending && message.id < storage.minId)) {
       storage.minId = message.id
     }
 
@@ -55,16 +97,22 @@ const add = (storage, { messages: newMessages, updateMaxId = true }) => {
       }
     }
 
-    if (!storage.idIndex[message.id]) {
+    if (!storage.idIndex[message.id] && !isConfirmation(storage, message)) {
       if (storage.lastSeenTimestamp < message.created_at) {
         storage.newMessageCount++
       }
-      storage.messages.push(message)
       storage.idIndex[message.id] = message
+      storage.messages.push(storage.idIndex[message.id])
+      storage.idempotencyKeyIndex[message.idempotency_key] = true
     }
   }
 }
 
+const isConfirmation = (storage, message) => {
+  if (!message.idempotency_key) return
+  return storage.idempotencyKeyIndex[message.idempotency_key]
+}
+
 const resetNewMessageCount = (storage) => {
   if (!storage) { return }
   storage.newMessageCount = 0
@@ -76,7 +124,7 @@ const getView = (storage) => {
   if (!storage) { return [] }
 
   const result = []
-  const messages = _.sortBy(storage.messages, ['id', 'desc'])
+  const messages = _.orderBy(storage.messages, ['pending', 'id'], ['asc', 'asc'])
   const firstMessage = messages[0]
   let previousMessage = messages[messages.length - 1]
   let currentMessageChainId
@@ -148,7 +196,8 @@ const ChatService = {
   getView,
   deleteMessage,
   resetNewMessageCount,
-  clear
+  clear,
+  handleMessageError
 }
 
 export default ChatService
diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js
index 86fe1af9..de6e0625 100644
--- a/src/services/chat_utils/chat_utils.js
+++ b/src/services/chat_utils/chat_utils.js
@@ -18,3 +18,24 @@ export const maybeShowChatNotification = (store, chat) => {
 
   showDesktopNotification(store.rootState, opts)
 }
+
+export const buildFakeMessage = ({ content, chatId, attachments, userId, idempotencyKey }) => {
+  const fakeMessage = {
+    content,
+    chat_id: chatId,
+    created_at: new Date(),
+    id: `${new Date().getTime()}`,
+    attachments: attachments,
+    account_id: userId,
+    idempotency_key: idempotencyKey,
+    emojis: [],
+    pending: true,
+    isNormalized: true
+  }
+
+  if (attachments[0]) {
+    fakeMessage.attachment = attachments[0]
+  }
+
+  return fakeMessage
+}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 1884478a..9d09b8d0 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -429,6 +429,9 @@ export const parseChatMessage = (message) => {
   } else {
     output.attachments = []
   }
+  output.pending = !!message.pending
+  output.error = false
+  output.idempotency_key = message.idempotency_key
   output.isNormalized = true
   return output
 }
diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js
index 2eb89a2d..15e64bb5 100644
--- a/test/unit/specs/services/chat_service/chat_service.spec.js
+++ b/test/unit/specs/services/chat_service/chat_service.spec.js
@@ -2,17 +2,20 @@ import chatService from '../../../../../src/services/chat_service/chat_service.j
 
 const message1 = {
   id: '9wLkdcmQXD21Oy8lEX',
+  idempotency_key: '1',
   created_at: (new Date('2020-06-22T18:45:53.000Z'))
 }
 
 const message2 = {
   id: '9wLkdp6ihaOVdNj8Wu',
+  idempotency_key: '2',
   account_id: '9vmRb29zLQReckr5ay',
   created_at: (new Date('2020-06-22T18:45:56.000Z'))
 }
 
 const message3 = {
   id: '9wLke9zL4Dy4OZR2RM',
+  idempotency_key: '3',
   account_id: '9vmRb29zLQReckr5ay',
   created_at: (new Date('2020-07-22T18:45:59.000Z'))
 }
-- 
cgit v1.2.3-70-g09d2

