From d30b0b28c9371e56ffe54b5a8b56087718221c1d Mon Sep 17 00:00:00 2001
From: Shpuld Shpuldson <shp@cock.li>
Date: Wed, 1 Jul 2020 19:15:28 +0300
Subject: catch localforage error and let the application work, add an alert
 for user to dismiss

---
 src/App.vue | 10 ++++++++++
 1 file changed, 10 insertions(+)

(limited to 'src/App.vue')

diff --git a/src/App.vue b/src/App.vue
index 7b9ad3dc..23991eac 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -101,6 +101,16 @@
         </div>
       </div>
       <div class="main">
+        <div
+          v-if="showStorageError"
+          class="alert error storage-error-notice"
+        >
+          {{ $t("errors.storage_unavailable") }}
+          <i
+            class="icon-cancel"
+            @click="hideStorageError"
+          />
+        </div>
         <div
           v-if="!currentUser"
           class="login-hint panel panel-default"
-- 
cgit v1.2.3-70-g09d2


From 1293bec77e5137acf64d3536c286f8ba3df284f4 Mon Sep 17 00:00:00 2001
From: Shpuld Shpuldson <shp@cock.li>
Date: Thu, 2 Jul 2020 10:40:41 +0300
Subject: change storage error one-off into a global notice system

---
 src/App.js                                         | 10 +--
 src/App.scss                                       |  9 ---
 src/App.vue                                        | 11 +---
 .../global_notice_list/global_notice_list.js       | 15 +++++
 .../global_notice_list/global_notice_list.vue      | 77 ++++++++++++++++++++++
 src/main.js                                        | 16 +++--
 src/modules/interface.js                           | 33 ++++++++--
 src/services/theme_data/pleromafe.js               | 36 +++++++++-
 8 files changed, 167 insertions(+), 40 deletions(-)
 create mode 100644 src/components/global_notice_list/global_notice_list.js
 create mode 100644 src/components/global_notice_list/global_notice_list.vue

(limited to 'src/App.vue')

diff --git a/src/App.js b/src/App.js
index da66fe21..92c4e2f5 100644
--- a/src/App.js
+++ b/src/App.js
@@ -13,6 +13,7 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
 import MobileNav from './components/mobile_nav/mobile_nav.vue'
 import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
 import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
+import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
 import { windowWidth } from './services/window_utils/window_utils'
 
 export default {
@@ -32,7 +33,8 @@ export default {
     MobileNav,
     SettingsModal,
     UserReportingModal,
-    PostStatusModal
+    PostStatusModal,
+    GlobalNoticeList
   },
   data: () => ({
     mobileActivePanel: 'timeline',
@@ -107,9 +109,6 @@ export default {
       return {
         'order': this.$store.state.instance.sidebarRight ? 99 : 0
       }
-    },
-    showStorageError () {
-      return this.$store.state.interface.storageError === 'show'
     }
   },
   methods: {
@@ -132,9 +131,6 @@ export default {
       if (changed) {
         this.$store.dispatch('setMobileLayout', mobileLayout)
       }
-    },
-    hideStorageError () {
-      this.$store.dispatch('setStorageError', 'hide')
     }
   }
 }
diff --git a/src/App.scss b/src/App.scss
index db447f1c..f2972eda 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -806,15 +806,6 @@ nav {
   }
 }
 
-.storage-error-notice {
-  text-align: center;
-  i {
-    cursor: pointer;
-    color: $fallback--text;
-    color: var(--alertErrorText, $fallback--text);
-  }
-}
-
 .button-icon {
   font-size: 1.2em;
 }
diff --git a/src/App.vue b/src/App.vue
index 23991eac..03b632ec 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -101,16 +101,6 @@
         </div>
       </div>
       <div class="main">
-        <div
-          v-if="showStorageError"
-          class="alert error storage-error-notice"
-        >
-          {{ $t("errors.storage_unavailable") }}
-          <i
-            class="icon-cancel"
-            @click="hideStorageError"
-          />
-        </div>
         <div
           v-if="!currentUser"
           class="login-hint panel panel-default"
@@ -138,6 +128,7 @@
     <PostStatusModal />
     <SettingsModal />
     <portal-target name="modal" />
+    <GlobalNoticeList />
   </div>
 </template>
 
diff --git a/src/components/global_notice_list/global_notice_list.js b/src/components/global_notice_list/global_notice_list.js
new file mode 100644
index 00000000..3af29c23
--- /dev/null
+++ b/src/components/global_notice_list/global_notice_list.js
@@ -0,0 +1,15 @@
+
+const GlobalNoticeList = {
+  computed: {
+    notices () {
+      return this.$store.state.interface.globalNotices
+    }
+  },
+  methods: {
+    closeNotice (notice) {
+      this.$store.dispatch('removeGlobalNotice', notice)
+    }
+  }
+}
+
+export default GlobalNoticeList
diff --git a/src/components/global_notice_list/global_notice_list.vue b/src/components/global_notice_list/global_notice_list.vue
new file mode 100644
index 00000000..0e4285cc
--- /dev/null
+++ b/src/components/global_notice_list/global_notice_list.vue
@@ -0,0 +1,77 @@
+<template>
+  <div class="global-notice-list">
+    <div
+      v-for="(notice, index) in notices"
+      :key="index"
+      class="alert global-notice"
+      :class="{ ['global-' + notice.level]: true }"
+    >
+      <div class="notice-message">
+        {{ $t(notice.messageKey, notice.messageArgs) }}
+      </div>
+      <i
+        class="button-icon icon-cancel"
+        @click="closeNotice(notice)"
+      />
+    </div>
+  </div>
+</template>
+
+<script src="./global_notice_list.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.global-notice-list {
+  position: fixed;
+  top: 50px;
+  width: 100%;
+  pointer-events: none;
+  z-index: 1001;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+
+  .global-notice {
+    pointer-events: auto;
+    text-align: center;
+    width: 40em;
+    max-width: calc(100% - 3em);
+    display: flex;
+    padding-left: 1.5em;
+    line-height: 2em;
+    .notice-message {
+      flex: 1 1 100%;
+    }
+    i {
+      flex: 0 0;
+      width: 1.5em;
+      cursor: pointer;
+    }
+  }
+
+  .global-error {
+    background-color: var(--alertPopupError, $fallback--cRed);
+    color: var(--alertPopupErrorText, $fallback--text);
+    i {
+      color: var(--alertPopupErrorText, $fallback--text);
+    }
+  }
+
+  .global-warning {
+    background-color: var(--alertPopupWarning, $fallback--cOrange);
+    color: var(--alertPopupWarningText, $fallback--text);
+    i {
+      color: var(--alertPopupWarningText, $fallback--text);
+    }
+  }
+
+  .global-info {
+    background-color: var(--alertPopupNeutral, $fallback--fg);
+    color: var(--alertPopupNeutralText, $fallback--text);
+    i {
+      color: var(--alertPopupNeutralText, $fallback--text);
+    }
+  }
+}
+</style>
diff --git a/src/main.js b/src/main.js
index a7294ea0..5bddc76e 100644
--- a/src/main.js
+++ b/src/main.js
@@ -62,14 +62,14 @@ const persistedStateOptions = {
 };
 
 (async () => {
-  let persistedState
-  let storageError = 'none'
+  let storageError = false
+  const plugins = [pushNotifications]
   try {
-    persistedState = await createPersistedState(persistedStateOptions)
+    const persistedState = await createPersistedState(persistedStateOptions)
+    plugins.push(persistedState)
   } catch (e) {
     console.error(e)
-    storageError = 'show'
-    persistedState = _ => _
+    storageError = true
   }
   const store = new Vuex.Store({
     modules: {
@@ -93,11 +93,13 @@ const persistedStateOptions = {
       polls: pollsModule,
       postStatus: postStatusModule
     },
-    plugins: [persistedState, pushNotifications],
+    plugins,
     strict: false // Socket modifies itself, let's ignore this for now.
     // strict: process.env.NODE_ENV !== 'production'
   })
-  store.dispatch('setStorageError', storageError)
+  if (storageError) {
+    store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
+  }
   afterStoreSetup({ store, i18n })
 })()
 
diff --git a/src/modules/interface.js b/src/modules/interface.js
index 4b5b5b5d..338ef651 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -8,14 +8,14 @@ const defaultState = {
     noticeClearTimeout: null,
     notificationPermission: null
   },
-  storageError: 'none',
   browserSupport: {
     cssFilter: window.CSS && window.CSS.supports && (
       window.CSS.supports('filter', 'drop-shadow(0 0)') ||
       window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
     )
   },
-  mobileLayout: false
+  mobileLayout: false,
+  globalNotices: []
 }
 
 const interfaceMod = {
@@ -60,8 +60,11 @@ const interfaceMod = {
         state.settingsModalLoaded = true
       }
     },
-    setStorageError (state, value) {
-      state.storageError = value
+    pushGlobalNotice (state, notice) {
+      state.globalNotices.push(notice)
+    },
+    removeGlobalNotice (state, notice) {
+      state.globalNotices = state.globalNotices.filter(n => n !== notice)
     }
   },
   actions: {
@@ -86,8 +89,26 @@ const interfaceMod = {
     togglePeekSettingsModal ({ commit }) {
       commit('togglePeekSettingsModal')
     },
-    setStorageError ({ commit }, value) {
-      commit('setStorageError', value)
+    pushGlobalNotice (
+      { commit, dispatch },
+      {
+        messageKey,
+        messageArgs = {},
+        level = 'error',
+        timeout = 0
+      }) {
+      const notice = {
+        messageKey,
+        messageArgs,
+        level
+      }
+      if (timeout) {
+        setTimeout(() => dispatch('removeGlobalNotice', notice), timeout)
+      }
+      commit('pushGlobalNotice', notice)
+    },
+    removeGlobalNotice ({ commit }, notice) {
+      commit('removeGlobalNotice', notice)
     }
   }
 }
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
index b577cfab..83c878ed 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -34,7 +34,8 @@ export const DEFAULT_OPACITY = {
   alert: 0.5,
   input: 0.5,
   faint: 0.5,
-  underlay: 0.15
+  underlay: 0.15,
+  alertPopup: 0.85
 }
 
 /**  SUBJECT TO CHANGE IN THE FUTURE, this is all beta
@@ -627,6 +628,39 @@ export const SLOT_INHERITANCE = {
     textColor: true
   },
 
+  alertPopupError: {
+    depends: ['alertError'],
+    opacity: 'alertPopup'
+  },
+  alertPopupErrorText: {
+    depends: ['alertErrorText'],
+    layer: 'popover',
+    variant: 'alertPopupError',
+    textColor: true
+  },
+
+  alertPopupWarning: {
+    depends: ['alertWarning'],
+    opacity: 'alertPopup'
+  },
+  alertPopupWarningText: {
+    depends: ['alertWarningText'],
+    layer: 'popover',
+    variant: 'alertPopupWarning',
+    textColor: true
+  },
+
+  alertPopupNeutral: {
+    depends: ['alertNeutral'],
+    opacity: 'alertPopup'
+  },
+  alertPopupNeutralText: {
+    depends: ['alertNeutralText'],
+    layer: 'popover',
+    variant: 'alertPopupNeutral',
+    textColor: true
+  },
+
   badgeNotification: '--cRed',
   badgeNotificationText: {
     depends: ['text', 'badgeNotification'],
-- 
cgit v1.2.3-70-g09d2


From aa2cf51c05ebdf411d74af5debbbc8fa4d3cf457 Mon Sep 17 00:00:00 2001
From: eugenijm <eugenijm@protonmail.com>
Date: Thu, 7 May 2020 16:10:53 +0300
Subject: Add Chats

---
 src/App.js                                         |  16 +-
 src/App.scss                                       |  14 +
 src/App.vue                                        |   3 +-
 src/_variables.scss                                |   1 +
 src/boot/after_store.js                            |   1 +
 src/boot/routes.js                                 |  15 +-
 src/components/account_actions/account_actions.js  |  12 +
 src/components/account_actions/account_actions.vue |   7 +
 src/components/chat/chat.js                        | 304 +++++++++++++++++++++
 src/components/chat/chat.scss                      | 161 +++++++++++
 src/components/chat/chat.vue                       |  99 +++++++
 src/components/chat/chat_layout.js                 | 100 +++++++
 src/components/chat/chat_layout_utils.js           |  27 ++
 src/components/chat_avatar/chat_avatar.js          |  23 ++
 src/components/chat_avatar/chat_avatar.vue         |  53 ++++
 src/components/chat_list/chat_list.js              |  37 +++
 src/components/chat_list/chat_list.vue             |  48 ++++
 src/components/chat_list_item/chat_list_item.js    |  65 +++++
 src/components/chat_list_item/chat_list_item.scss  |  94 +++++++
 src/components/chat_list_item/chat_list_item.vue   |  49 ++++
 src/components/chat_message/chat_message.js        | 109 ++++++++
 src/components/chat_message/chat_message.scss      | 157 +++++++++++
 src/components/chat_message/chat_message.vue       |  99 +++++++
 .../chat_message_date/chat_message_date.vue        |  24 ++
 src/components/chat_new/chat_new.js                |  74 +++++
 src/components/chat_new/chat_new.scss              |  29 ++
 src/components/chat_new/chat_new.vue               |  46 ++++
 src/components/chat_panel/chat_panel.vue           |  80 +++---
 src/components/chat_title/chat_title.js            |  20 ++
 src/components/chat_title/chat_title.vue           |  56 ++++
 src/components/emoji_input/emoji_input.js          |  38 ++-
 src/components/emoji_input/emoji_input.vue         |   5 +-
 src/components/features_panel/features_panel.js    |   1 +
 src/components/features_panel/features_panel.vue   |   3 +
 src/components/media_upload/media_upload.js        |   3 +-
 src/components/media_upload/media_upload.vue       |  21 +-
 src/components/mobile_nav/mobile_nav.js            |   5 +-
 src/components/mobile_nav/mobile_nav.vue           |   1 +
 .../mobile_post_status_button.js                   |   7 +
 src/components/nav_panel/nav_panel.js              |  20 +-
 src/components/nav_panel/nav_panel.vue             |  11 +
 src/components/notification/notification.js        |   6 +-
 src/components/notifications/notifications.js      |   9 +-
 .../post_status_form/post_status_form.js           | 138 +++++++---
 .../post_status_form/post_status_form.vue          |  50 +++-
 .../settings_modal/tabs/theme_tab/theme_tab.js     |   6 +-
 .../settings_modal/tabs/theme_tab/theme_tab.vue    |  67 +++++
 src/components/side_drawer/side_drawer.js          |   7 +-
 src/components/side_drawer/side_drawer.vue         |  22 +-
 src/components/status_content/status_content.js    |   2 +-
 src/components/status_content/status_content.vue   |   2 +-
 src/hocs/with_load_more/with_load_more.scss        |   4 +
 src/i18n/en.json                                   |  33 ++-
 src/main.js                                        |   4 +-
 src/modules/api.js                                 |  24 +-
 src/modules/chats.js                               | 228 ++++++++++++++++
 src/modules/config.js                              |   3 +-
 src/modules/instance.js                            |   1 +
 src/modules/interface.js                           |   9 +-
 src/modules/statuses.js                            |   5 +-
 src/modules/users.js                               |   5 +
 src/services/api/api.service.js                    | 117 +++++++-
 src/services/chat_service/chat_service.js          | 150 ++++++++++
 .../entity_normalizer/entity_normalizer.service.js |  34 ++-
 src/services/style_setter/style_setter.js          |   3 +-
 src/services/theme_data/pleromafe.js               |  53 +++-
 src/services/window_utils/window_utils.js          |   5 +
 static/fontello.json                               |   6 +
 test/unit/specs/boot/routes.spec.js                |  10 +-
 69 files changed, 2787 insertions(+), 154 deletions(-)
 create mode 100644 src/components/chat/chat.js
 create mode 100644 src/components/chat/chat.scss
 create mode 100644 src/components/chat/chat.vue
 create mode 100644 src/components/chat/chat_layout.js
 create mode 100644 src/components/chat/chat_layout_utils.js
 create mode 100644 src/components/chat_avatar/chat_avatar.js
 create mode 100644 src/components/chat_avatar/chat_avatar.vue
 create mode 100644 src/components/chat_list/chat_list.js
 create mode 100644 src/components/chat_list/chat_list.vue
 create mode 100644 src/components/chat_list_item/chat_list_item.js
 create mode 100644 src/components/chat_list_item/chat_list_item.scss
 create mode 100644 src/components/chat_list_item/chat_list_item.vue
 create mode 100644 src/components/chat_message/chat_message.js
 create mode 100644 src/components/chat_message/chat_message.scss
 create mode 100644 src/components/chat_message/chat_message.vue
 create mode 100644 src/components/chat_message_date/chat_message_date.vue
 create mode 100644 src/components/chat_new/chat_new.js
 create mode 100644 src/components/chat_new/chat_new.scss
 create mode 100644 src/components/chat_new/chat_new.vue
 create mode 100644 src/components/chat_title/chat_title.js
 create mode 100644 src/components/chat_title/chat_title.vue
 create mode 100644 src/modules/chats.js
 create mode 100644 src/services/chat_service/chat_service.js

(limited to 'src/App.vue')

diff --git a/src/App.js b/src/App.js
index 92c4e2f5..84300e00 100644
--- a/src/App.js
+++ b/src/App.js
@@ -14,7 +14,7 @@ import MobileNav from './components/mobile_nav/mobile_nav.vue'
 import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
 import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
 import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
-import { windowWidth } from './services/window_utils/window_utils'
+import { windowWidth, windowHeight } from './services/window_utils/window_utils'
 
 export default {
   name: 'app',
@@ -45,7 +45,8 @@ export default {
         window.CSS.supports('-moz-mask-size', 'contain') ||
         window.CSS.supports('-ms-mask-size', 'contain') ||
         window.CSS.supports('-o-mask-size', 'contain')
-    )
+    ),
+    transitionName: 'fade'
   }),
   created () {
     // Load the locale from the storage
@@ -127,10 +128,21 @@ export default {
     },
     updateMobileState () {
       const mobileLayout = windowWidth() <= 800
+      const layoutHeight = windowHeight()
       const changed = mobileLayout !== this.isMobileLayout
       if (changed) {
         this.$store.dispatch('setMobileLayout', mobileLayout)
       }
+      this.$store.dispatch('setLayoutHeight', layoutHeight)
+    }
+  },
+  watch: {
+    '$route' (to, from) {
+      if ((to.name === 'chat' && from.name === 'chats') || (to.name === 'chats' && from.name === 'chat')) {
+        this.transitionName = 'none'
+      } else {
+        this.transitionName = 'fade'
+      }
     }
   }
 }
diff --git a/src/App.scss b/src/App.scss
index 6597b6f4..29ce73a8 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -56,6 +56,7 @@ body {
   overflow-x: hidden;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
+  overscroll-behavior: none;
 
   &.hidden {
     display: none;
@@ -928,3 +929,16 @@ nav {
   background-color: $fallback--fg;
   background-color: var(--panel, $fallback--fg);
 }
+
+.unread-chat-count {
+  font-size: 0.9em;
+  font-weight: bolder;
+  font-style: normal;
+  position: absolute;
+  right: 0.6rem;
+  padding: 0 0.3em;
+  min-width: 1.3rem;
+  min-height: 1.3rem;
+  max-height: 1.3rem;
+  line-height: 1.3rem;
+}
diff --git a/src/App.vue b/src/App.vue
index 03b632ec..5d429934 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -77,6 +77,7 @@
         </div>
       </div>
     </nav>
+    <div class="app-bg-wrapper app-container-wrapper" />
     <div
       id="content"
       class="container underlay"
@@ -112,7 +113,7 @@
             {{ $t("login.hint") }}
           </router-link>
         </div>
-        <transition name="fade">
+        <transition :name="transitionName">
           <router-view />
         </transition>
       </div>
diff --git a/src/_variables.scss b/src/_variables.scss
index 30dc3e42..9004d551 100644
--- a/src/_variables.scss
+++ b/src/_variables.scss
@@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px;
 $fallback--avatarRadius: 4px;
 $fallback--avatarAltRadius: 10px;
 $fallback--attachmentRadius: 10px;
+$fallback--chatMessageRadius: 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/boot/after_store.js b/src/boot/after_store.js
index 302b278c..7160434f 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -230,6 +230,7 @@ const getNodeInfo = async ({ store }) => {
       store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
       store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
       store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
+      store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
       store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
       store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
       store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
diff --git a/src/boot/routes.js b/src/boot/routes.js
index f63d8adf..b5d3c631 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -6,6 +6,8 @@ import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue
 import ConversationPage from 'components/conversation-page/conversation-page.vue'
 import Interactions from 'components/interactions/interactions.vue'
 import DMs from 'components/dm_timeline/dm_timeline.vue'
+import ChatList from 'components/chat_list/chat_list.vue'
+import Chat from 'components/chat/chat.vue'
 import UserProfile from 'components/user_profile/user_profile.vue'
 import Search from 'components/search/search.vue'
 import Registration from 'components/registration/registration.vue'
@@ -28,7 +30,7 @@ export default (store) => {
     }
   }
 
-  return [
+  let routes = [
     { name: 'root',
       path: '/',
       redirect: _to => {
@@ -62,11 +64,20 @@ export default (store) => {
     { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
     { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
     { name: 'login', path: '/login', component: AuthForm },
-    { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
+    { name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) },
     { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
     { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
     { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
     { name: 'about', path: '/about', component: About },
     { name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
   ]
+
+  if (store.state.instance.pleromaChatMessagesAvailable) {
+    routes = routes.concat([
+      { name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
+      { name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
+    ])
+  }
+
+  return routes
 }
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 0826c275..6d345bc7 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,3 +1,4 @@
+import { mapState } from 'vuex'
 import ProgressButton from '../progress_button/progress_button.vue'
 import Popover from '../popover/popover.vue'
 
@@ -27,7 +28,18 @@ const AccountActions = {
     },
     reportUser () {
       this.$store.dispatch('openUserReportingModal', this.user.id)
+    },
+    openChat () {
+      this.$router.push({
+        name: 'chat',
+        params: { recipient_id: this.user.id }
+      })
     }
+  },
+  computed: {
+    ...mapState({
+      pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+    })
   }
 }
 
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 029e7096..987e94b7 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -50,6 +50,13 @@
           >
             {{ $t('user_card.report') }}
           </button>
+          <button
+            v-if="pleromaChatMessagesAvailable"
+            class="btn btn-default btn-block dropdown-item"
+            @click="openChat"
+          >
+            {{ $t('user_card.message') }}
+          </button>
         </div>
       </div>
       <div
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
new file mode 100644
index 00000000..6e23c20c
--- /dev/null
+++ b/src/components/chat/chat.js
@@ -0,0 +1,304 @@
+import _ from 'lodash'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
+import ChatMessage from '../chat_message/chat_message.vue'
+import ChatAvatar from '../chat_avatar/chat_avatar.vue'
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import ChatTitle from '../chat_title/chat_title.vue'
+import chatService from '../../services/chat_service/chat_service.js'
+import ChatLayout from './chat_layout.js'
+import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
+
+const BOTTOMED_OUT_OFFSET = 10
+const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
+
+const Chat = {
+  components: {
+    ChatMessage,
+    ChatTitle,
+    ChatAvatar,
+    PostStatusForm
+  },
+  mixins: [ChatLayout],
+  data () {
+    return {
+      jumpToBottomButtonVisible: false,
+      hoveredMessageChainId: undefined,
+      scrollPositionBeforeResize: {},
+      scrollableContainerHeight: '100%',
+      errorLoadingChat: false
+    }
+  },
+  created () {
+    this.startFetching()
+    window.addEventListener('resize', this.handleLayoutChange)
+  },
+  mounted () {
+    window.addEventListener('scroll', this.handleScroll)
+    if (typeof document.hidden !== 'undefined') {
+      document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
+    }
+
+    this.$nextTick(() => {
+      this.updateScrollableContainerHeight()
+      this.handleResize()
+    })
+    this.setChatLayout()
+  },
+  destroyed () {
+    window.removeEventListener('scroll', this.handleScroll)
+    window.removeEventListener('resize', this.handleLayoutChange)
+    this.unsetChatLayout()
+    if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
+    this.$store.dispatch('clearCurrentChat')
+  },
+  computed: {
+    recipient () {
+      return this.currentChat && this.currentChat.account
+    },
+    recipientId () {
+      return this.$route.params.recipient_id
+    },
+    formPlaceholder () {
+      if (this.recipient) {
+        return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
+      } else {
+        return ''
+      }
+    },
+    chatViewItems () {
+      return chatService.getView(this.currentChatMessageService)
+    },
+    newMessageCount () {
+      return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
+    },
+    streamingEnabled () {
+      return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+    },
+    ...mapGetters([
+      'currentChat',
+      'currentChatMessageService',
+      'findOpenedChatByRecipientId',
+      'mergedConfig'
+    ]),
+    ...mapState({
+      backendInteractor: state => state.api.backendInteractor,
+      mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
+      mobileLayout: state => state.interface.mobileLayout,
+      layoutHeight: state => state.interface.layoutHeight,
+      currentUser: state => state.users.currentUser
+    })
+  },
+  watch: {
+    chatViewItems () {
+      // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
+      // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
+      const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
+      this.$nextTick(() => {
+        if (bottomedOutBeforeUpdate) {
+          this.scrollDown({ forceRead: !document.hidden })
+        }
+      })
+    },
+    '$route': function () {
+      this.startFetching()
+    },
+    layoutHeight () {
+      this.handleResize({ expand: true })
+    },
+    mastoUserSocketStatus (newValue) {
+      if (newValue === WSConnectionStatus.JOINED) {
+        this.fetchChat({ isFirstFetch: true })
+      }
+    }
+  },
+  methods: {
+    // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
+    onMessageHover ({ isHovered, messageChainId }) {
+      this.hoveredMessageChainId = isHovered ? messageChainId : undefined
+    },
+    onFilesDropped () {
+      this.$nextTick(() => {
+        this.updateScrollableContainerHeight()
+      })
+    },
+    handleVisibilityChange () {
+      this.$nextTick(() => {
+        if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
+          this.scrollDown({ forceRead: true })
+        }
+      })
+    },
+    handleLayoutChange () {
+      this.updateScrollableContainerHeight()
+      if (this.mobileLayout) {
+        this.setMobileChatLayout()
+      } else {
+        this.unsetMobileChatLayout()
+      }
+      this.$nextTick(() => {
+        this.updateScrollableContainerHeight()
+        this.scrollDown()
+      })
+    },
+    // Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
+    updateScrollableContainerHeight () {
+      const header = this.$refs.header
+      const footer = this.$refs.footer
+      const inner = this.mobileLayout ? window.document.body : this.$refs.inner
+      this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
+    },
+    // Preserves the scroll position when OSK appears or the posting form changes its height.
+    handleResize (opts) {
+      this.$nextTick(() => {
+        this.updateScrollableContainerHeight()
+
+        const { offsetHeight = undefined } = this.scrollPositionBeforeResize
+        this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable)
+
+        const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight
+        if (diff < 0 || (!this.bottomedOut() && opts && opts.expand)) {
+          this.$nextTick(() => {
+            this.updateScrollableContainerHeight()
+            this.$refs.scrollable.scrollTo({
+              top: this.$refs.scrollable.scrollTop - diff,
+              left: 0
+            })
+          })
+        }
+      })
+    },
+    scrollDown (options = {}) {
+      const { behavior = 'auto', forceRead = false } = options
+      const scrollable = this.$refs.scrollable
+      if (!scrollable) { return }
+      this.$nextTick(() => {
+        scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
+      })
+      if (forceRead || this.newMessageCount > 0) {
+        this.readChat()
+      }
+    },
+    readChat () {
+      if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return }
+      if (document.hidden) { return }
+      const lastReadId = this.currentChatMessageService.lastMessage.id
+      this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
+    },
+    bottomedOut (offset) {
+      return isBottomedOut(this.$refs.scrollable, offset)
+    },
+    reachedTop () {
+      const scrollable = this.$refs.scrollable
+      return scrollable && scrollable.scrollTop <= 0
+    },
+    handleScroll: _.throttle(function () {
+      if (!this.currentChat) { return }
+
+      if (this.reachedTop()) {
+        this.fetchChat({ maxId: this.currentChatMessageService.minId })
+      } else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
+        this.jumpToBottomButtonVisible = false
+        if (this.newMessageCount > 0) {
+          this.readChat()
+        }
+      } else {
+        this.jumpToBottomButtonVisible = true
+      }
+    }, 100),
+    handleScrollUp (positionBeforeLoading) {
+      const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
+      this.$refs.scrollable.scrollTo({
+        top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
+        left: 0
+      })
+    },
+    fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
+      const chatMessageService = this.currentChatMessageService
+      if (!chatMessageService) { return }
+      if (fetchLatest && this.streamingEnabled) { return }
+
+      const chatId = chatMessageService.chatId
+      const fetchOlderMessages = !!maxId
+      const sinceId = fetchLatest && chatMessageService.lastMessage && chatMessageService.lastMessage.id
+
+      this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
+        .then((messages) => {
+          // Clear the current chat in case we're recovering from a ws connection loss.
+          if (isFirstFetch) {
+            chatService.clear(chatMessageService)
+          }
+
+          const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
+          this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
+            this.$nextTick(() => {
+              if (fetchOlderMessages) {
+                this.handleScrollUp(positionBeforeUpdate)
+              }
+
+              if (isFirstFetch) {
+                this.updateScrollableContainerHeight()
+              }
+            })
+          })
+        })
+    },
+    async startFetching () {
+      let chat = this.findOpenedChatByRecipientId(this.recipientId)
+      if (!chat) {
+        try {
+          chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
+        } catch (e) {
+          console.error('Error creating or getting a chat', e)
+          this.errorLoadingChat = true
+        }
+      }
+      if (chat) {
+        this.$nextTick(() => {
+          this.scrollDown({ forceRead: true })
+        })
+        this.$store.dispatch('addOpenedChat', { chat })
+        this.doStartFetching()
+      }
+    },
+    doStartFetching () {
+      this.$store.dispatch('startFetchingCurrentChat', {
+        fetcher: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
+      })
+      this.fetchChat({ isFirstFetch: true })
+    },
+    sendMessage ({ status, media }) {
+      const params = {
+        id: this.currentChat.id,
+        content: status
+      }
+
+      if (media[0]) {
+        params.mediaId = media[0].id
+      }
+
+      return this.backendInteractor.sendChatMessage(params)
+        .then(data => {
+          this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
+            this.$nextTick(() => {
+              this.updateScrollableContainerHeight()
+              this.scrollDown({ forceRead: true })
+            })
+          })
+
+          return data
+        })
+        .catch(error => {
+          console.error('Error sending message', error)
+          return {
+            error: this.$t('chats.error_sending_message')
+          }
+        })
+    },
+    goBack () {
+      this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
+    }
+  }
+}
+
+export default Chat
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
new file mode 100644
index 00000000..13c52ea3
--- /dev/null
+++ b/src/components/chat/chat.scss
@@ -0,0 +1,161 @@
+.chat-view {
+  display: flex;
+  height: calc(100vh - 60px);
+  width: 100%;
+
+  .chat-view-inner {
+    height: auto;
+    width: 100%;
+    overflow: visible;
+    display: flex;
+    margin-top: 0.5em;
+    margin-left: 0.5em;
+    margin-right: 0.5em;
+  }
+
+  .chat-view-body {
+    background-color: var(--chatBg, $fallback--bg);
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    overflow: visible;
+    border-radius: none;
+    min-height: 100%;
+    margin-left: 0;
+    margin-right: 0;
+    margin-bottom: 0em;
+    margin-top: 0em;
+    border-radius: 10px 10px 0 0;
+    border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
+
+    &::after {
+      border-radius: none;
+      box-shadow: none;
+    }
+  }
+
+  .scrollable-message-list {
+    padding: 0 10px;
+    height: 100%;
+    overflow-y: scroll;
+    overflow-x: hidden;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .footer {
+    position: sticky;
+    bottom: 0px;
+  }
+
+  .chat-view-heading {
+    align-items: center;
+    justify-content: space-between;
+    top: 50px;
+    display: flex;
+    z-index: 2;
+    border-radius: none;
+    position: sticky;
+    display: flex;
+    overflow: hidden;
+  }
+
+  .go-back-button {
+    margin-right: 1.2em;
+    cursor: pointer;
+  }
+
+  .jump-to-bottom-button {
+    width: 2.5em;
+    height: 2.5em;
+    border-radius: 100%;
+    position: absolute;
+    right: 1.3em;
+    top: -3.2em;
+    background-color: $fallback--fg;
+    background-color: var(--btn, $fallback--fg);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3);
+    z-index: 10;
+    transition: 0.35s all;
+    transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+    opacity: 0;
+    visibility: hidden;
+    cursor: pointer;
+
+    &.visible {
+      opacity: 1;
+      visibility: visible;
+    }
+
+    i {
+      font-size: 1em;
+      color: $fallback--text;
+      color: var(--text, $fallback--text);
+    }
+
+    .unread-message-count {
+      font-size: 0.8em;
+      left: 50%;
+      transform: translate(-50%, 0);
+      border-radius: 100%;
+      margin-top: -1rem;
+      padding: 0;
+    }
+
+    .chat-loading-error {
+      width: 100%;
+      display: flex;
+      align-items: flex-end;
+      height: 100%;
+
+      .error {
+        width: 100%;
+      }
+    }
+  }
+
+  @media all and (max-width: 800px) {
+    height: 100%;
+    overflow: hidden;
+
+    .chat-view-inner {
+      overflow: hidden;
+      height: 100%;
+      margin-top: 0;
+      margin-left: 0;
+      margin-right: 0;
+    }
+
+    .chat-view-body {
+      display: flex;
+      min-height: auto;
+      overflow: hidden;
+      height: 100%;
+      margin: 0;
+      border-radius: 0 !important;
+    }
+
+    .chat-view-heading {
+      position: static;
+      z-index: 9999;
+      top: 0;
+      margin-top: 0;
+      border-radius: 0;
+    }
+
+    .scrollable-message-list {
+      display: unset;
+      overflow-y: scroll;
+      overflow-x: hidden;
+      -webkit-overflow-scrolling: touch;
+    }
+
+    .footer {
+      position: sticky;
+      bottom: auto;
+    }
+  }
+}
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
new file mode 100644
index 00000000..d8c91dbe
--- /dev/null
+++ b/src/components/chat/chat.vue
@@ -0,0 +1,99 @@
+<template>
+  <div class="chat-view">
+    <div class="chat-view-inner">
+      <div
+        id="nav"
+        ref="inner"
+        class="panel-default panel chat-view-body"
+      >
+        <div
+          ref="header"
+          class="panel-heading chat-view-heading mobile-hidden"
+        >
+          <a
+            class="go-back-button"
+            @click="goBack"
+          >
+            <i class="button-icon icon-left-open" />
+          </a>
+          <div class="title text-center">
+            <ChatTitle
+              :user="recipient"
+              :with-avatar="true"
+            />
+          </div>
+        </div>
+        <template>
+          <div
+            ref="scrollable"
+            class="scrollable-message-list"
+            :style="{ height: scrollableContainerHeight }"
+            @scroll="handleScroll"
+          >
+            <template v-if="!errorLoadingChat">
+              <ChatMessage
+                v-for="chatViewItem in chatViewItems"
+                :key="chatViewItem.id"
+                :author="recipient"
+                :chat-view-item="chatViewItem"
+                :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
+                @hover="onMessageHover"
+              />
+            </template>
+            <div
+              v-else
+              class="chat-loading-error"
+            >
+              <div class="alert error">
+                {{ $t('chats.error_loading_chat') }}
+              </div>
+            </div>
+          </div>
+          <div
+            ref="footer"
+            class="panel-body footer"
+          >
+            <div
+              class="jump-to-bottom-button"
+              :class="{ 'visible': jumpToBottomButtonVisible }"
+              @click="scrollDown({ behavior: 'smooth' })"
+            >
+              <i class="icon-down-open">
+                <div
+                  v-if="newMessageCount"
+                  class="badge badge-notification unread-chat-count unread-message-count"
+                >
+                  {{ newMessageCount }}
+                </div>
+              </i>
+            </div>
+            <PostStatusForm
+              :disable-subject="true"
+              :disable-scope-selector="true"
+              :disable-notice="true"
+              :disable-lock-warning="true"
+              :disable-polls="true"
+              :disable-sensitivity-checkbox="true"
+              :disable-submit="errorLoadingChat || !currentChat"
+              :request="sendMessage"
+              :submit-on-enter="!mobileLayout"
+              :preserve-focus="!mobileLayout"
+              :auto-focus="!mobileLayout"
+              :placeholder="formPlaceholder"
+              :file-limit="1"
+              max-height="160"
+              emoji-picker-placement="top"
+              @resize="handleResize"
+            />
+          </div>
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script src="./chat.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat.scss';
+</style>
diff --git a/src/components/chat/chat_layout.js b/src/components/chat/chat_layout.js
new file mode 100644
index 00000000..07ae3abf
--- /dev/null
+++ b/src/components/chat/chat_layout.js
@@ -0,0 +1,100 @@
+const ChatLayout = {
+  methods: {
+    setChatLayout () {
+      if (this.mobileLayout) {
+        this.setMobileChatLayout()
+      }
+    },
+    unsetChatLayout () {
+      this.unsetMobileChatLayout()
+    },
+    setMobileChatLayout () {
+      // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
+      // This layout prevents empty spaces from being visible at the bottom
+      // of the chat on iOS Safari (`safe-area-inset`) when
+      // - the on-screen keyboard appears and the user starts typing
+      // - the user selects the text inside the input area
+      // - the user selects and deletes the text that is multiple lines long
+      // TODO: unify the chat layout with the global layout.
+
+      let html = document.querySelector('html')
+      if (html) {
+        html.style.overflow = 'hidden'
+        html.style.height = '100%'
+      }
+
+      let body = document.querySelector('body')
+      if (body) {
+        body.style.height = '100%'
+      }
+
+      let app = document.getElementById('app')
+      if (app) {
+        app.style.height = '100%'
+        app.style.overflow = 'hidden'
+        app.style.minHeight = 'auto'
+      }
+
+      let appBgWrapper = window.document.getElementById('app_bg_wrapper')
+      if (appBgWrapper) {
+        appBgWrapper.style.overflow = 'hidden'
+      }
+
+      let main = document.getElementsByClassName('main')[0]
+      if (main) {
+        main.style.overflow = 'hidden'
+        main.style.height = '100%'
+      }
+
+      let content = document.getElementById('content')
+      if (content) {
+        content.style.paddingTop = '0'
+        content.style.height = '100%'
+        content.style.overflow = 'visible'
+      }
+
+      this.$nextTick(() => {
+        this.updateScrollableContainerHeight()
+      })
+    },
+    unsetMobileChatLayout () {
+      let html = document.querySelector('html')
+      if (html) {
+        html.style.overflow = 'visible'
+        html.style.height = 'unset'
+      }
+
+      let body = document.querySelector('body')
+      if (body) {
+        body.style.height = 'unset'
+      }
+
+      let app = document.getElementById('app')
+      if (app) {
+        app.style.height = '100%'
+        app.style.overflow = 'visible'
+        app.style.minHeight = '100vh'
+      }
+
+      let appBgWrapper = document.getElementById('app_bg_wrapper')
+      if (appBgWrapper) {
+        appBgWrapper.style.overflow = 'visible'
+      }
+
+      let main = document.getElementsByClassName('main')[0]
+      if (main) {
+        main.style.overflow = 'visible'
+        main.style.height = 'unset'
+      }
+
+      let content = document.getElementById('content')
+      if (content) {
+        content.style.paddingTop = '60px'
+        content.style.height = 'unset'
+        content.style.overflow = 'unset'
+      }
+    }
+  }
+}
+
+export default ChatLayout
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
new file mode 100644
index 00000000..f07ba2a1
--- /dev/null
+++ b/src/components/chat/chat_layout_utils.js
@@ -0,0 +1,27 @@
+// Captures a scroll position
+export const getScrollPosition = (el) => {
+  return {
+    scrollTop: el.scrollTop,
+    scrollHeight: el.scrollHeight,
+    offsetHeight: el.offsetHeight
+  }
+}
+
+// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
+// Takes two scroll positions, before and after the update.
+export const getNewTopPosition = (previousPosition, newPosition) => {
+  return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
+}
+
+export const isBottomedOut = (el, offset = 0) => {
+  if (!el) { return }
+  const scrollHeight = el.scrollTop + offset
+  const totalHeight = el.scrollHeight - el.offsetHeight
+  return totalHeight <= scrollHeight
+}
+
+// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
+export const scrollableContainerHeight = (inner, header, footer) => {
+  const height = parseFloat(getComputedStyle(inner, null).height.replace('px', ''))
+  return height - header.clientHeight - footer.clientHeight
+}
diff --git a/src/components/chat_avatar/chat_avatar.js b/src/components/chat_avatar/chat_avatar.js
new file mode 100644
index 00000000..7b26e07c
--- /dev/null
+++ b/src/components/chat_avatar/chat_avatar.js
@@ -0,0 +1,23 @@
+import StillImage from '../still-image/still-image.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import { mapState } from 'vuex'
+
+const ChatAvatar = {
+  props: ['user', 'width', 'height'],
+  components: {
+    StillImage
+  },
+  methods: {
+    getUserProfileLink (user) {
+      if (!user) { return }
+      return generateProfileLink(user.id, user.screen_name)
+    }
+  },
+  computed: {
+    ...mapState({
+      betterShadow: state => state.interface.browserSupport.cssFilter
+    })
+  }
+}
+
+export default ChatAvatar
diff --git a/src/components/chat_avatar/chat_avatar.vue b/src/components/chat_avatar/chat_avatar.vue
new file mode 100644
index 00000000..f54a7151
--- /dev/null
+++ b/src/components/chat_avatar/chat_avatar.vue
@@ -0,0 +1,53 @@
+<template>
+  <router-link
+    :to="getUserProfileLink(user) || ''"
+  >
+    <StillImage
+      v-if="user"
+      :style="{ 'width': width, 'height': height }"
+      class="avatar chat-avatar single-user"
+      :alt="user.screen_name"
+      :title="user.screen_name"
+      :src="user.profile_image_url_original"
+      error-src="/images/avi.png"
+      :class="{ 'better-shadow': betterShadow }"
+    />
+    <div
+      v-else
+      class="avatar chat-avatar single-user"
+      :style="{ 'width': width, 'height': height }"
+    />
+  </router-link>
+</template>
+
+<script src="./chat_avatar.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+
+.chat-avatar {
+  display: inline-block;
+  vertical-align: middle;
+
+  &.single-user {
+    border-radius: $fallback--avatarAltRadius;
+    border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+  }
+
+  .avatar.still-image {
+    width: 48px;
+    height: 48px;
+
+    box-shadow: var(--avatarStatusShadow);
+    border-radius: 0;
+
+    &.better-shadow {
+      box-shadow: var(--avatarStatusShadowInset);
+      filter: var(--avatarStatusShadowFilter)
+    }
+
+    &.animated::before {
+      display: none;
+    }
+  }
+}
+</style>
diff --git a/src/components/chat_list/chat_list.js b/src/components/chat_list/chat_list.js
new file mode 100644
index 00000000..95708d1d
--- /dev/null
+++ b/src/components/chat_list/chat_list.js
@@ -0,0 +1,37 @@
+import { mapState, mapGetters } from 'vuex'
+import ChatListItem from '../chat_list_item/chat_list_item.vue'
+import ChatNew from '../chat_new/chat_new.vue'
+import List from '../list/list.vue'
+
+const ChatList = {
+  components: {
+    ChatListItem,
+    List,
+    ChatNew
+  },
+  computed: {
+    ...mapState({
+      currentUser: state => state.users.currentUser
+    }),
+    ...mapGetters(['sortedChatList'])
+  },
+  data () {
+    return {
+      isNew: false
+    }
+  },
+  created () {
+    this.$store.dispatch('fetchChats', { latest: true })
+  },
+  methods: {
+    cancelNewChat () {
+      this.isNew = false
+      this.$store.dispatch('fetchChats', { latest: true })
+    },
+    newChat () {
+      this.isNew = true
+    }
+  }
+}
+
+export default ChatList
diff --git a/src/components/chat_list/chat_list.vue b/src/components/chat_list/chat_list.vue
new file mode 100644
index 00000000..e62f58e5
--- /dev/null
+++ b/src/components/chat_list/chat_list.vue
@@ -0,0 +1,48 @@
+<template>
+  <div v-if="isNew">
+    <ChatNew @cancel="cancelNewChat" />
+  </div>
+  <div
+    v-else
+    class="chat-list panel panel-default"
+  >
+    <div class="panel-heading">
+      <span class="title">
+        {{ $t("chats.chats") }}
+      </span>
+      <button @click="newChat">
+        {{ $t("chats.new") }}
+      </button>
+    </div>
+    <div class="panel-body">
+      <div class="timeline">
+        <List :items="sortedChatList">
+          <template
+            slot="item"
+            slot-scope="{item}"
+          >
+            <ChatListItem
+              :key="item.id"
+              :compact="false"
+              :chat="item"
+            />
+          </template>
+        </List>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script src="./chat_list.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.chat-list {
+  min-height: calc(100vh - 67px);
+  margin-bottom: 0;
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+}
+
+</style>
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
new file mode 100644
index 00000000..1c27088c
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -0,0 +1,65 @@
+import { mapState } from 'vuex'
+import StatusContent from '../status_content/status_content.vue'
+import fileType from 'src/services/file_type/file_type.service'
+import ChatAvatar from '../chat_avatar/chat_avatar.vue'
+import AvatarList from '../avatar_list/avatar_list.vue'
+import Timeago from '../timeago/timeago.vue'
+import ChatTitle from '../chat_title/chat_title.vue'
+
+const ChatListItem = {
+  name: 'ChatListItem',
+  props: [
+    'chat'
+  ],
+  components: {
+    ChatAvatar,
+    AvatarList,
+    Timeago,
+    ChatTitle,
+    StatusContent
+  },
+  computed: {
+    ...mapState({
+      currentUser: state => state.users.currentUser
+    }),
+    attachmentInfo () {
+      if (this.chat.lastMessage.attachments.length === 0) { return }
+
+      const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
+      if (types.includes('video')) {
+        return this.$t('file_type.video')
+      } else if (types.includes('audio')) {
+        return this.$t('file_type.audio')
+      } else if (types.includes('image')) {
+        return this.$t('file_type.image')
+      } else {
+        return this.$t('file_type.file')
+      }
+    },
+    messageForStatusContent () {
+      const content = this.chat.lastMessage ? (this.attachmentInfo || this.chat.lastMessage.content) : ''
+
+      return {
+        summary: '',
+        statusnet_html: content,
+        text: content,
+        attachments: []
+      }
+    }
+  },
+  methods: {
+    openChat (_e) {
+      if (this.chat.id) {
+        this.$router.push({
+          name: 'chat',
+          params: {
+            username: this.currentUser.screen_name,
+            recipient_id: this.chat.account.id
+          }
+        })
+      }
+    }
+  }
+}
+
+export default ChatListItem
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
new file mode 100644
index 00000000..12269f89
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -0,0 +1,94 @@
+.chat-list-item {
+  &:hover .animated.avatar {
+    canvas {
+      display: none;
+    }
+    img {
+      visibility: visible;
+    }
+  }
+
+  display: flex;
+  flex-direction: row;
+  padding: 0.75em;
+  height: 4.85em;
+  overflow: hidden;
+  box-sizing: border-box;
+  cursor: pointer;
+
+  :focus {
+    outline: none;
+  }
+
+  &:hover {
+    background-color: var(--selectedPost, $fallback--lightBg);
+    box-shadow: 0 0px 3px 1px rgba(0, 0, 0, 0.1);
+  }
+
+  .chat-list-item-left {
+    margin-right: 1em;
+  }
+
+  .chat-list-item-center {
+    width: 100%;
+    box-sizing: border-box;
+    overflow: hidden;
+    word-wrap: break-word;
+  }
+
+  .heading {
+    width: 100%;
+    display: inline-flex;
+    justify-content: space-between;
+    line-height: 1em;
+  }
+
+  .heading-right {
+    white-space: nowrap;
+  }
+
+  .member-count {
+    color: $fallback--text;
+    color: var(--faintText, $fallback--text);
+    margin-right: 2px;
+  }
+
+  .name-and-account-name {
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    overflow: hidden;
+    flex-shrink: 1;
+  }
+
+  .chat-preview {
+    display: inline-flex;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    margin: 0.35rem 0;
+    height: 1.2em;
+    line-height: 1.2em;
+    color: $fallback--text;
+    color: var(--faint, $fallback--text);
+  }
+
+  a {
+    color: var(--faintLink, $fallback--link);
+    text-decoration: none;
+    pointer-events: none;
+  }
+
+  .unread-indicator-wrapper {
+    display: flex;
+    align-items: center;
+    margin-left: 10px;
+  }
+
+  .unread-indicator {
+    border-radius: 100%;
+    height: 8px;
+    width: 8px;
+    background-color: $fallback--link;
+    background-color: var(--link, $fallback--link);
+  }
+}
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
new file mode 100644
index 00000000..26ad581b
--- /dev/null
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -0,0 +1,49 @@
+<template>
+  <div
+    class="chat-list-item"
+    @click.capture.prevent="openChat"
+  >
+    <div class="chat-list-item-left">
+      <ChatAvatar
+        :user="chat.account"
+        height="48px"
+        width="48px"
+      />
+    </div>
+    <div class="chat-list-item-center">
+      <div class="heading">
+        <span
+          v-if="chat.account"
+          class="name-and-account-name"
+        >
+          <ChatTitle
+            :user="chat.account"
+          />
+        </span>
+        <span class="heading-right" />
+      </div>
+      <div class="chat-preview">
+        <StatusContent :status="messageForStatusContent" />
+        <div
+          v-if="chat.unread > 0"
+          class="badge badge-notification unread-chat-count"
+        >
+          {{ chat.unread }}
+        </div>
+      </div>
+    </div>
+    <div>
+      <Timeago
+        :time="chat.updated_at"
+        :auto-update="60"
+      />
+    </div>
+  </div>
+</template>
+
+<script src="./chat_list_item.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat_list_item.scss';
+</style>
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
new file mode 100644
index 00000000..aba95074
--- /dev/null
+++ b/src/components/chat_message/chat_message.js
@@ -0,0 +1,109 @@
+import { mapState, mapGetters } from 'vuex'
+import Popover from '../popover/popover.vue'
+import Attachment from '../attachment/attachment.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import Gallery from '../gallery/gallery.vue'
+import LinkPreview from '../link-preview/link-preview.vue'
+import StatusContent from '../status_content/status_content.vue'
+import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
+const ChatMessage = {
+  name: 'ChatMessage',
+  props: [
+    'author',
+    'edited',
+    'noHeading',
+    'chatViewItem',
+    'hoveredMessageChain'
+  ],
+  components: {
+    Popover,
+    Attachment,
+    StatusContent,
+    UserAvatar,
+    Gallery,
+    LinkPreview,
+    ChatMessageDate
+  },
+  computed: {
+    // Returns HH:MM (hours and minutes) in local time.
+    createdAt () {
+      const time = this.chatViewItem.data.created_at
+      return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
+    },
+    isCurrentUser () {
+      return this.message.account_id === this.currentUser.id
+    },
+    message () {
+      return this.chatViewItem.data
+    },
+    userProfileLink () {
+      return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
+    },
+    isMessage () {
+      return this.chatViewItem.type === 'message'
+    },
+    messageForStatusContent () {
+      return {
+        summary: '',
+        statusnet_html: this.message.content,
+        text: this.message.content,
+        attachments: this.message.attachments
+      }
+    },
+    hasAttachment () {
+      return this.message.attachments.length > 0
+    },
+    ...mapState({
+      betterShadow: state => state.interface.browserSupport.cssFilter,
+      currentUser: state => state.users.currentUser,
+      restrictedNicknames: state => state.instance.restrictedNicknames
+    }),
+    ellipsisButtonWrapperStyle () {
+      let res = {
+        'opacity': this.hovered || this.menuOpened ? '1' : '0'
+      }
+
+      if (this.isCurrentUser) {
+        res.right = '5px'
+      } else {
+        res.left = '5px'
+      }
+
+      return res
+    },
+    popoverMarginStyle () {
+      if (this.isCurrentUser) {
+        return {}
+      } else {
+        return { left: 50 }
+      }
+    },
+    ...mapGetters(['mergedConfig', 'findUser'])
+  },
+  data () {
+    return {
+      hovered: false,
+      menuOpened: false
+    }
+  },
+  methods: {
+    onHover (bool) {
+      this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
+    },
+    async deleteMessage () {
+      const confirmed = window.confirm(this.$t('chats.delete_confirm'))
+      if (confirmed) {
+        await this.$store.dispatch('deleteChatMessage', {
+          messageId: this.chatViewItem.data.id,
+          chatId: this.chatViewItem.data.chat_id
+        })
+      }
+      this.hovered = false
+      this.menuOpened = false
+    }
+  }
+}
+
+export default ChatMessage
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
new file mode 100644
index 00000000..e4028537
--- /dev/null
+++ b/src/components/chat_message/chat_message.scss
@@ -0,0 +1,157 @@
+@import '../../_variables.scss';
+
+.chat-message-wrapper {
+  &.hovered-message-chain {
+    .animated.avatar {
+      canvas {
+        display: none;
+      }
+      img {
+        visibility: visible;
+      }
+    }
+  }
+
+  &:last-child {
+    margin-bottom: 16px;
+  }
+
+  .chat-message-menu {
+    transition: opacity 0.1s;
+    opacity: 0;
+    position: absolute;
+    top: -10px;
+
+    button {
+      padding-top: 3px;
+      padding-bottom: 3px;
+    }
+  }
+
+  .icon-ellipsis {
+    cursor: pointer;
+
+    &:hover, .extra-button-popover.open & {
+      color: $fallback--text;
+      color: var(--text, $fallback--text);
+    }
+
+    border-radius: $fallback--chatMessageRadius;
+    border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
+  }
+
+  .popover {
+    width: 12rem;
+  }
+
+  .chat-message {
+    display: flex;
+    padding-bottom: 7px;
+  }
+
+  .avatar-wrapper {
+    margin-right: 10px;
+    width: 32px;
+  }
+
+  .link-preview, .attachments {
+    margin-bottom: 0.9em;
+  }
+
+  .chat-message-inner {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    max-width: 80%;
+    min-width: 10rem;
+    width: 100%;
+
+    &.with-media {
+      width: 100%;
+
+      .gallery-row {
+        overflow: hidden;
+      }
+
+      .status {
+        width: 100%;
+      }
+    }
+  }
+
+  .status {
+    border-radius: $fallback--chatMessageRadius;
+    border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
+    display: flex;
+    padding: 0.75em;
+  }
+
+  .created-at {
+    float: right;
+    font-size: 0.8em;
+    margin: -10px 0 -5px 4px;
+    font-style: italic;
+    opacity: 0.8;
+  }
+
+  .without-attachment {
+    .status-content {
+      white-space: normal;
+
+      &::after {
+        margin-right: 75px;
+        content: " ";
+        display: inline-block;
+      }
+    }
+  }
+
+  .incoming {
+    a {
+      color: var(--chatMessageIncomingLink, $fallback--link);
+    }
+
+    .status {
+      color: var(--chatMessageIncomingText, $fallback--text);
+      background-color: var(--chatMessageIncomingBg, $fallback--bg);
+      border: 1px solid var(--chatMessageIncomingBorder, --border);
+    }
+
+    .created-at {
+      a {
+        color: var(--chatMessageIncomingText, $fallback--text);
+      }
+    }
+  }
+
+  .outgoing {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    align-content: end;
+    justify-content: flex-end;
+
+    a {
+      color: var(--chatMessageOutgoingLink, $fallback--link);
+    }
+
+    .status {
+      color: var(--chatMessageOutgoingText, $fallback--text);
+      background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
+      border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
+    }
+
+    .chat-message-inner {
+      align-items: flex-end;
+    }
+  }
+}
+
+.chat-message-date-separator {
+  text-align: center;
+  margin: 1.4em 0;
+  font-size: 0.9em;
+  user-select: none;
+  color: $fallback--text;
+  color: var(--faintedText, $fallback--text);
+}
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
new file mode 100644
index 00000000..872ddf70
--- /dev/null
+++ b/src/components/chat_message/chat_message.vue
@@ -0,0 +1,99 @@
+<template>
+  <div
+    v-if="isMessage"
+    class="chat-message-wrapper"
+    :class="{ 'hovered-message-chain': hoveredMessageChain }"
+    @mouseover="onHover(true)"
+    @mouseleave="onHover(false)"
+  >
+    <div
+      class="chat-message"
+      :class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
+    >
+      <div
+        v-if="!isCurrentUser"
+        class="avatar-wrapper"
+      >
+        <router-link
+          v-if="chatViewItem.isHead"
+          :to="userProfileLink"
+        >
+          <UserAvatar
+            :compact="true"
+            :better-shadow="betterShadow"
+            :user="author"
+          />
+        </router-link>
+      </div>
+      <div class="chat-message-inner">
+        <div
+          class="status-body"
+          :style="{ 'min-width': message.attachment ? '80%' : '' }"
+        >
+          <div
+            class="media status"
+            :class="{ 'without-attachment': !hasAttachment }"
+            style="position: relative"
+            @mouseenter="hovered = true"
+            @mouseleave="hovered = false"
+          >
+            <div
+              class="chat-message-menu"
+              :style="ellipsisButtonWrapperStyle"
+            >
+              <Popover
+                trigger="click"
+                placement="top"
+                :bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
+                :bound-to="{ x: 'container' }"
+                :margin="popoverMarginStyle"
+                @show="menuOpened = true"
+                @close="menuOpened = false"
+              >
+                <div slot="content">
+                  <div class="dropdown-menu">
+                    <button
+                      class="dropdown-item dropdown-item-icon"
+                      @click="deleteMessage"
+                    >
+                      <i class="icon-cancel" /> {{ $t("chats.delete") }}
+                    </button>
+                  </div>
+                </div>
+                <button
+                  slot="trigger"
+                  :title="$t('chats.more')"
+                >
+                  <i class="icon-ellipsis" />
+                </button>
+              </Popover>
+            </div>
+            <StatusContent
+              :status="messageForStatusContent"
+              :full-content="true"
+            >
+              <span
+                slot="footer"
+                class="created-at"
+              >
+                {{ createdAt }}
+              </span>
+            </StatusContent>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <div
+    v-else
+    class="chat-message-date-separator"
+  >
+    <ChatMessageDate :date="chatViewItem.date" />
+  </div>
+</template>
+
+<script src="./chat_message.js" ></script>
+<style lang="scss">
+@import './chat_message.scss';
+
+</style>
diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue
new file mode 100644
index 00000000..79c346b6
--- /dev/null
+++ b/src/components/chat_message_date/chat_message_date.vue
@@ -0,0 +1,24 @@
+<template>
+  <time>
+    {{ displayDate }}
+  </time>
+</template>
+
+<script>
+export default {
+  name: 'Timeago',
+  props: ['date'],
+  computed: {
+    displayDate () {
+      const today = new Date()
+      today.setHours(0, 0, 0, 0)
+
+      if (this.date.getTime() === today.getTime()) {
+        return this.$t('display_date.today')
+      } else {
+        return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
+      }
+    }
+  }
+}
+</script>
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
new file mode 100644
index 00000000..0da681f7
--- /dev/null
+++ b/src/components/chat_new/chat_new.js
@@ -0,0 +1,74 @@
+import { throttle } from 'lodash'
+import { mapState, mapGetters } from 'vuex'
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+
+const chatNew = {
+  components: {
+    BasicUserCard,
+    UserAvatar
+  },
+  data () {
+    return {
+      suggestions: [],
+      userIds: [],
+      loading: false,
+      query: ''
+    }
+  },
+  async created () {
+    const { chats } = await this.backendInteractor.chats()
+    chats.forEach(chat => this.suggestions.push(chat.account))
+  },
+  computed: {
+    users () {
+      return this.userIds.map(userId => this.findUser(userId))
+    },
+    availableUsers () {
+      if (this.query.length !== 0) {
+        return this.users
+      } else {
+        return this.suggestions
+      }
+    },
+    ...mapState({
+      currentUser: state => state.users.currentUser,
+      backendInteractor: state => state.api.backendInteractor
+    }),
+    ...mapGetters(['findUser'])
+  },
+  methods: {
+    goBack () {
+      this.$emit('cancel')
+    },
+    goToChat (user) {
+      this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
+    },
+    onInput () {
+      this.search(this.query)
+    },
+    addUser (user) {
+      this.selectedUserIds.push(user.id)
+      this.query = ''
+    },
+    removeUser (userId) {
+      this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
+    },
+    search: throttle(function (query) {
+      if (!query) {
+        this.loading = false
+        return
+      }
+
+      this.loading = true
+      this.userIds = []
+      this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' })
+        .then(data => {
+          this.loading = false
+          this.userIds = data.accounts.map(a => a.id)
+        })
+    })
+  }
+}
+
+export default chatNew
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
new file mode 100644
index 00000000..39216677
--- /dev/null
+++ b/src/components/chat_new/chat_new.scss
@@ -0,0 +1,29 @@
+.chat-new {
+  .input-wrap {
+    display: flex;
+    margin: 0.7em 0.5em 0.7em 0.5em;
+
+    input {
+      width: 100%;
+    }
+  }
+
+  .icon-search {
+    font-size: 1.5em;
+    float: right;
+    margin-right: 0.3em;
+  }
+
+  .member-list {
+    padding-bottom: 0.67rem;
+  }
+
+  .basic-user-card:hover {
+    cursor: pointer;
+    background-color: var(--selectedPost, $fallback--lightBg);
+  }
+
+  .go-back-button {
+    cursor: pointer;
+  }
+}
diff --git a/src/components/chat_new/chat_new.vue b/src/components/chat_new/chat_new.vue
new file mode 100644
index 00000000..3333dbf9
--- /dev/null
+++ b/src/components/chat_new/chat_new.vue
@@ -0,0 +1,46 @@
+<template>
+  <div
+    id="nav"
+    class="panel-default panel chat-new"
+  >
+    <div
+      ref="header"
+      class="panel-heading"
+    >
+      <a
+        class="go-back-button"
+        @click="goBack"
+      >
+        <i class="button-icon icon-left-open" />
+      </a>
+    </div>
+    <div class="input-wrap">
+      <div class="input-search">
+        <i class="button-icon icon-search" />
+      </div>
+      <input
+        ref="search"
+        v-model="query"
+        placeholder="Search people"
+        @input="onInput"
+      >
+    </div>
+    <div class="member-list">
+      <div
+        v-for="user in availableUsers"
+        :key="user.id"
+        class="member"
+      >
+        <div @click.capture.prevent="goToChat(user)">
+          <BasicUserCard :user="user" />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script src="./chat_new.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+@import './chat_new.scss';
+</style>
diff --git a/src/components/chat_panel/chat_panel.vue b/src/components/chat_panel/chat_panel.vue
index 3677722f..12968cfb 100644
--- a/src/components/chat_panel/chat_panel.vue
+++ b/src/components/chat_panel/chat_panel.vue
@@ -84,54 +84,56 @@
   max-width: 25em;
 }
 
-.chat-heading {
-  cursor: pointer;
-  .icon-comment-empty {
-    color: $fallback--text;
-    color: var(--text, $fallback--text);
+.chat-panel {
+  .chat-heading {
+    cursor: pointer;
+    .icon-comment-empty {
+      color: $fallback--text;
+      color: var(--text, $fallback--text);
+    }
   }
-}
-
-.chat-window {
-  overflow-y: auto;
-  overflow-x: hidden;
-  max-height: 20em;
-}
 
-.chat-window-container {
-  height: 100%;
-}
+  .chat-window {
+    overflow-y: auto;
+    overflow-x: hidden;
+    max-height: 20em;
+  }
 
-.chat-message {
-  display: flex;
-  padding: 0.2em 0.5em
-}
+  .chat-window-container {
+    height: 100%;
+  }
 
-.chat-avatar {
-  img {
-    height: 24px;
-    width: 24px;
-    border-radius: $fallback--avatarRadius;
-    border-radius: var(--avatarRadius, $fallback--avatarRadius);
-    margin-right: 0.5em;
-    margin-top: 0.25em;
+  .chat-message {
+    display: flex;
+    padding: 0.2em 0.5em
   }
-}
 
-.chat-input {
-  display: flex;
-  textarea {
-    flex: 1;
-    margin: 0.6em;
-    min-height: 3.5em;
-    resize: none;
+  .chat-avatar {
+    img {
+      height: 24px;
+      width: 24px;
+      border-radius: $fallback--avatarRadius;
+      border-radius: var(--avatarRadius, $fallback--avatarRadius);
+      margin-right: 0.5em;
+      margin-top: 0.25em;
+    }
   }
-}
 
-.chat-panel {
-  .title {
+  .chat-input {
     display: flex;
-    justify-content: space-between;
+    textarea {
+      flex: 1;
+      margin: 0.6em;
+      min-height: 3.5em;
+      resize: none;
+    }
+  }
+
+  .chat-panel {
+    .title {
+      display: flex;
+      justify-content: space-between;
+    }
   }
 }
 </style>
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
new file mode 100644
index 00000000..2723d5f5
--- /dev/null
+++ b/src/components/chat_title/chat_title.js
@@ -0,0 +1,20 @@
+import Vue from 'vue'
+import ChatAvatar from '../chat_avatar/chat_avatar.vue'
+
+export default Vue.component('chat-title', {
+  name: 'ChatTitle',
+  components: {
+    ChatAvatar
+  },
+  props: [
+    'user', 'withAvatar'
+  ],
+  computed: {
+    title () {
+      return this.user ? this.user.screen_name : ''
+    },
+    htmlTitle () {
+      return this.user ? this.user.name_html : ''
+    }
+  }
+})
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
new file mode 100644
index 00000000..fd42d125
--- /dev/null
+++ b/src/components/chat_title/chat_title.vue
@@ -0,0 +1,56 @@
+<template>
+  <!-- eslint-disable vue/no-v-html -->
+  <div
+    class="chat-title"
+    :title="title"
+  >
+    <ChatAvatar
+      v-if="withAvatar"
+      :user="user"
+      width="23px"
+      height="23px"
+    />
+    <span
+      v-if="withAvatar"
+      style="margin-right: 0.5em"
+    />
+    <span
+      class="username"
+      v-html="htmlTitle"
+    />
+  </div>
+  <!-- eslint-enable vue/no-v-html -->
+</template>
+
+<script src="./chat_title.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.chat-title {
+  display: flex;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+
+  a {
+    display: flex;
+    align-items: center;
+  }
+
+  .username {
+    max-width: 100%;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    display: inline;
+    word-wrap: break-word;
+
+    .emoji {
+      width: 14px;
+      height: 14px;
+      vertical-align: middle;
+      object-fit: contain
+    }
+  }
+}
+</style>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 7974a66d..a27da090 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -79,6 +79,15 @@ const EmojiInput = {
       required: false,
       type: Boolean,
       default: false
+    },
+    placement: {
+      /**
+       * Forces the panel to take a specific position relative to the input element.
+       * The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
+       */
+      required: false,
+      type: String, // 'auto', 'top', 'bottom'
+      default: 'auto'
     }
   },
   data () {
@@ -162,6 +171,11 @@ const EmojiInput = {
       input.elm.removeEventListener('input', this.onInput)
     }
   },
+  watch: {
+    showSuggestions: function (newValue) {
+      this.$emit('shown', newValue)
+    }
+  },
   methods: {
     triggerShowPicker () {
       this.showPicker = true
@@ -425,15 +439,29 @@ const EmojiInput = {
       this.caret = selectionStart
     },
     resize () {
-      const { panel, picker } = this.$refs
+      const panel = this.$refs.panel
       if (!panel) return
+      const picker = this.$refs.picker.$el
+      const panelBody = this.$refs['panel-body']
       const { offsetHeight, offsetTop } = this.input.elm
       const offsetBottom = offsetTop + offsetHeight
 
-      panel.style.top = offsetBottom + 'px'
-      if (!picker) return
-      picker.$el.style.top = offsetBottom + 'px'
-      picker.$el.style.bottom = 'auto'
+      this.setPlacement(panelBody, panel, offsetBottom)
+      this.setPlacement(picker, picker, offsetBottom)
+    },
+    setPlacement (container, target, offsetBottom) {
+      if (!container || !target) return
+
+      target.style.top = offsetBottom + 'px'
+      target.style.bottom = 'auto'
+
+      if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
+        target.style.top = 'auto'
+        target.style.bottom = this.input.elm.offsetHeight + 'px'
+      }
+    },
+    overflowsBottom (el) {
+      return el.getBoundingClientRect().bottom > window.innerHeight
     }
   }
 }
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index e9ac09c3..b9a74572 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -29,7 +29,10 @@
       class="autocomplete-panel"
       :class="{ hide: !showSuggestions }"
     >
-      <div class="autocomplete-panel-body">
+      <div
+        ref="panel-body"
+        class="autocomplete-panel-body"
+      >
         <div
           v-for="(suggestion, index) in suggestions"
           :key="index"
diff --git a/src/components/features_panel/features_panel.js b/src/components/features_panel/features_panel.js
index 5f80a079..620a85ea 100644
--- a/src/components/features_panel/features_panel.js
+++ b/src/components/features_panel/features_panel.js
@@ -1,6 +1,7 @@
 const FeaturesPanel = {
   computed: {
     chat: function () { return this.$store.state.instance.chatAvailable },
+    pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
     gopher: function () { return this.$store.state.instance.gopherAvailable },
     whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
     mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index 3e5939a6..608b11c8 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -11,6 +11,9 @@
           <li v-if="chat">
             {{ $t('features_panel.chat') }}
           </li>
+          <li v-if="pleromaChatMessages">
+            {{ $t('features_panel.pleroma_chat_messages') }}
+          </li>
           <li v-if="gopher">
             {{ $t('features_panel.gopher') }}
           </li>
diff --git a/src/components/media_upload/media_upload.js b/src/components/media_upload/media_upload.js
index fbb2d03d..7b8a76cc 100644
--- a/src/components/media_upload/media_upload.js
+++ b/src/components/media_upload/media_upload.js
@@ -61,7 +61,8 @@ const mediaUpload = {
     }
   },
   props: [
-    'dropFiles'
+    'dropFiles',
+    'disabled'
   ],
   watch: {
     'dropFiles': function (fileInfos) {
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index 5e31730b..d719eae1 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -1,5 +1,8 @@
 <template>
-  <div class="media-upload">
+  <div
+    class="media-upload"
+    :class="{ disabled: disabled }"
+  >
     <label
       class="label"
       :title="$t('tool_tip.media_upload')"
@@ -14,6 +17,7 @@
       />
       <input
         v-if="uploadReady"
+        :disabled="disabled"
         type="file"
         style="position: fixed; top: -100em"
         multiple="true"
@@ -26,7 +30,22 @@
 <script src="./media_upload.js" ></script>
 
 <style lang="scss">
+@import '../../_variables.scss';
+
 .media-upload {
+  &.disabled {
+    .new-icon {
+      cursor: not-allowed;
+    }
+
+    &:hover {
+      i, label {
+        color: $fallback--faint;
+        color: var(--faint, $fallback--faint);
+      }
+    }
+  }
+
   .label {
     display: inline-block;
   }
diff --git a/src/components/mobile_nav/mobile_nav.js b/src/components/mobile_nav/mobile_nav.js
index c1166a0c..b27ca6f4 100644
--- a/src/components/mobile_nav/mobile_nav.js
+++ b/src/components/mobile_nav/mobile_nav.js
@@ -30,7 +30,10 @@ const MobileNav = {
       return this.unseenNotifications.length
     },
     hideSitename () { return this.$store.state.instance.hideSitename },
-    sitename () { return this.$store.state.instance.name }
+    sitename () { return this.$store.state.instance.name },
+    navBarStyle () {
+      return { 'visibility': this.$route.name === 'chat' ? 'hidden' : 'visible' }
+    }
   },
   methods: {
     toggleMobileSidebar () {
diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue
index 51f1d636..05568b90 100644
--- a/src/components/mobile_nav/mobile_nav.vue
+++ b/src/components/mobile_nav/mobile_nav.vue
@@ -3,6 +3,7 @@
     <nav
       id="nav"
       class="nav-bar container"
+      :style="navBarStyle"
     >
       <div
         class="mobile-inner-nav"
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 0ad12bb1..6348277b 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,5 +1,10 @@
 import { debounce } from 'lodash'
 
+const HIDDEN_FOR_PAGES = new Set([
+  'chats',
+  'chat'
+])
+
 const MobilePostStatusButton = {
   data () {
     return {
@@ -27,6 +32,8 @@ const MobilePostStatusButton = {
       return !!this.$store.state.users.currentUser
     },
     isHidden () {
+      if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true }
+
       return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
     },
     autohideFloatingPostButton () {
diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js
index 8f7edb7f..b142ffe0 100644
--- a/src/components/nav_panel/nav_panel.js
+++ b/src/components/nav_panel/nav_panel.js
@@ -1,4 +1,4 @@
-import { mapState } from 'vuex'
+import { mapState, mapGetters } from 'vuex'
 
 const NavPanel = {
   created () {
@@ -6,13 +6,17 @@ const NavPanel = {
       this.$store.dispatch('startFetchingFollowRequests')
     }
   },
-  computed: mapState({
-    currentUser: state => state.users.currentUser,
-    chat: state => state.chat.channel,
-    followRequestCount: state => state.api.followRequests.length,
-    privateMode: state => state.instance.private,
-    federating: state => state.instance.federating
-  })
+  computed: {
+    ...mapState({
+      currentUser: state => state.users.currentUser,
+      chat: state => state.chat.channel,
+      followRequestCount: state => state.api.followRequests.length,
+      privateMode: state => state.instance.private,
+      federating: state => state.instance.federating,
+      pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+    }),
+    ...mapGetters(['unreadChatCount'])
+  }
 }
 
 export default NavPanel
diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue
index f164b2b0..8a213d7e 100644
--- a/src/components/nav_panel/nav_panel.vue
+++ b/src/components/nav_panel/nav_panel.vue
@@ -22,6 +22,17 @@
             <i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
           </router-link>
         </li>
+        <li v-if="currentUser && pleromaChatMessagesAvailable">
+          <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
+            <div
+              v-if="unreadChatCount"
+              class="badge badge-notification unread-chat-count"
+            >
+              {{ unreadChatCount }}
+            </div>
+            <i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
+          </router-link>
+        </li>
         <li v-if="currentUser && currentUser.locked">
           <router-link :to="{ name: 'friend-requests' }">
             <i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index 5aa40e98..bb906b50 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,4 +1,5 @@
 import StatusContent from '../status_content/status_content.vue'
+import { mapState } from 'vuex'
 import Status from '../status/status.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
 import UserCard from '../user_card/user_card.vue'
@@ -81,7 +82,10 @@ const Notification = {
     },
     isStatusNotification () {
       return isStatusNotification(this.notification.type)
-    }
+    },
+    ...mapState({
+      currentUser: state => state.users.currentUser
+    })
   }
 }
 
diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js
index d8a327b0..d951e2a8 100644
--- a/src/components/notifications/notifications.js
+++ b/src/components/notifications/notifications.js
@@ -1,3 +1,4 @@
+import { mapGetters } from 'vuex'
 import Notification from '../notification/notification.vue'
 import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
 import {
@@ -51,18 +52,22 @@ const Notifications = {
     unseenCount () {
       return this.unseenNotifications.length
     },
+    unseenCountTitle () {
+      return this.unseenCount + (this.unreadChatCount)
+    },
     loading () {
       return this.$store.state.statuses.notifications.loading
     },
     notificationsToDisplay () {
       return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
-    }
+    },
+    ...mapGetters(['unreadChatCount'])
   },
   components: {
     Notification
   },
   watch: {
-    unseenCount (count) {
+    unseenCountTitle (count) {
       if (count > 0) {
         this.$store.dispatch('setPageTitle', `(${count})`)
       } else {
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 18f02eba..90d0fa81 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -9,7 +9,7 @@ import fileTypeService from '../../services/file_type/file_type.service.js'
 import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
 import { reject, map, uniqBy, debounce } from 'lodash'
 import suggestor from '../emoji_input/suggestor.js'
-import { mapGetters } from 'vuex'
+import { mapGetters, mapState } from 'vuex'
 import Checkbox from '../checkbox/checkbox.vue'
 
 const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
@@ -33,7 +33,22 @@ const PostStatusForm = {
     'repliedUser',
     'attentions',
     'copyMessageScope',
-    'subject'
+    'subject',
+    'disableSubject',
+    'disableScopeSelector',
+    'disableNotice',
+    'disableLockWarning',
+    'disablePolls',
+    'disableSensitivityCheckbox',
+    'disableSubmit',
+    'placeholder',
+    'maxHeight',
+    'request',
+    'preserveFocus',
+    'autoFocus',
+    'fileLimit',
+    'submitOnEnter',
+    'emojiPickerPlacement'
   ],
   components: {
     MediaUpload,
@@ -46,10 +61,13 @@ const PostStatusForm = {
   },
   mounted () {
     this.resize(this.$refs.textarea)
-    const textLength = this.$refs.textarea.value.length
-    this.$refs.textarea.setSelectionRange(textLength, textLength)
 
     if (this.replyTo) {
+      const textLength = this.$refs.textarea.value.length
+      this.$refs.textarea.setSelectionRange(textLength, textLength)
+    }
+
+    if (this.replyTo || this.autoFocus) {
       this.$refs.textarea.focus()
     }
   },
@@ -72,7 +90,7 @@ const PostStatusForm = {
 
     return {
       dropFiles: [],
-      submitDisabled: false,
+      uploadingFiles: false,
       error: null,
       posting: false,
       highlighted: 0,
@@ -91,7 +109,8 @@ const PostStatusForm = {
       showDropIcon: 'hide',
       dropStopTimeout: null,
       preview: null,
-      previewLoading: false
+      previewLoading: false,
+      emojiInputShown: false
     }
   },
   computed: {
@@ -160,10 +179,11 @@ const PostStatusForm = {
     },
     pollsAvailable () {
       return this.$store.state.instance.pollsAvailable &&
-        this.$store.state.instance.pollLimits.max_options >= 2
+        this.$store.state.instance.pollLimits.max_options >= 2 &&
+        this.disablePolls !== true
     },
     hideScopeNotice () {
-      return this.$store.getters.mergedConfig.hideScopeNotice
+      return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
     },
     pollContentError () {
       return this.pollFormVisible &&
@@ -176,7 +196,13 @@ const PostStatusForm = {
     emptyStatus () {
       return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
     },
-    ...mapGetters(['mergedConfig'])
+    uploadFileLimitReached () {
+      return this.newStatus.files.length >= this.fileLimit
+    },
+    ...mapGetters(['mergedConfig']),
+    ...mapState({
+      mobileLayout: state => state.interface.mobileLayout
+    })
   },
   watch: {
     'newStatus.contentType': function () {
@@ -187,9 +213,19 @@ const PostStatusForm = {
     }
   },
   methods: {
-    async postStatus (newStatus) {
+    async postStatus (event, newStatus, opts = {}) {
       if (this.posting) { return }
       if (this.submitDisabled) { return }
+      if (this.emojiInputShown) { return }
+      if (this.submitOnEnter) {
+        event.stopPropagation()
+        event.preventDefault()
+      }
+      if (opts.control && this.submitOnEnter) {
+        newStatus.status = `${newStatus.status}\n`
+        return
+      }
+
       if (this.emptyStatus) {
         this.error = this.$t('post_status.empty_status_error')
         return
@@ -211,7 +247,7 @@ const PostStatusForm = {
         return
       }
 
-      const data = await statusPoster.postStatus({
+      const postingOptions = {
         status: newStatus.status,
         spoilerText: newStatus.spoilerText || null,
         visibility: newStatus.visibility,
@@ -221,32 +257,40 @@ const PostStatusForm = {
         inReplyToStatusId: this.replyTo,
         contentType: newStatus.contentType,
         poll
-      })
-
-      if (!data.error) {
-        this.newStatus = {
-          status: '',
-          spoilerText: '',
-          files: [],
-          visibility: newStatus.visibility,
-          contentType: newStatus.contentType,
-          poll: {},
-          mediaDescriptions: {}
-        }
-        this.pollFormVisible = false
-        this.$refs.mediaUpload.clearFile()
-        this.clearPollForm()
-        this.$emit('posted')
-        let el = this.$el.querySelector('textarea')
-        el.style.height = 'auto'
-        el.style.height = undefined
-        this.error = null
-        if (this.preview) this.previewStatus()
-      } else {
-        this.error = data.error
       }
 
-      this.posting = false
+      const request = this.request ? this.request : statusPoster.postStatus
+
+      request(postingOptions).then((data) => {
+        if (!data.error) {
+          this.newStatus = {
+            status: '',
+            spoilerText: '',
+            files: [],
+            visibility: newStatus.visibility,
+            contentType: newStatus.contentType,
+            poll: {},
+            mediaDescriptions: {}
+          }
+          this.pollFormVisible = false
+          this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
+          this.clearPollForm()
+          this.$emit('posted', data)
+          if (this.preserveFocus) {
+            this.$nextTick(() => {
+              this.$refs.textarea.focus()
+            })
+          }
+          let el = this.$el.querySelector('textarea')
+          el.style.height = 'auto'
+          el.style.height = undefined
+          this.error = null
+          if (this.preview) this.previewStatus()
+        } else {
+          this.error = data.error
+        }
+        this.posting = false
+      })
     },
     previewStatus () {
       if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
@@ -301,20 +345,26 @@ const PostStatusForm = {
     },
     addMediaFile (fileInfo) {
       this.newStatus.files.push(fileInfo)
+
+      // TODO: use fixed dimensions instead so relying on timeout
+      setTimeout(() => {
+        this.$emit('resize')
+      }, 150)
     },
     removeMediaFile (fileInfo) {
       let index = this.newStatus.files.indexOf(fileInfo)
       this.newStatus.files.splice(index, 1)
+      this.$emit('resize')
     },
     uploadFailed (errString, templateArgs) {
       templateArgs = templateArgs || {}
       this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
     },
-    disableSubmit () {
-      this.submitDisabled = true
+    startedUploadingFiles () {
+      this.uploadingFiles = true
     },
-    enableSubmit () {
-      this.submitDisabled = false
+    finishedUploadingFiles () {
+      this.uploadingFiles = false
     },
     type (fileInfo) {
       return fileTypeService.fileType(fileInfo.mimetype)
@@ -348,7 +398,7 @@ const PostStatusForm = {
       this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
     },
     fileDrag (e) {
-      e.dataTransfer.dropEffect = 'copy'
+      e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
       if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
         clearTimeout(this.dropStopTimeout)
         this.showDropIcon = 'show'
@@ -367,6 +417,7 @@ const PostStatusForm = {
       // Reset to default height for empty form, nothing else to do here.
       if (target.value === '') {
         target.style.height = null
+        this.$emit('resize', null)
         this.$refs['emoji-input'].resize()
         return
       }
@@ -419,8 +470,10 @@ const PostStatusForm = {
 
       // BEGIN content size update
       target.style.height = 'auto'
-      const newHeight = target.scrollHeight - vertPadding
+      const heightWithoutPadding = target.scrollHeight - vertPadding
+      const newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
       target.style.height = `${newHeight}px`
+      this.$emit('resize', newHeight)
       // END content size update
 
       // We check where the bottom border of form-bottom element is, this uses findOffset
@@ -480,6 +533,9 @@ const PostStatusForm = {
     setAllMediaDescriptions () {
       const ids = this.newStatus.files.map(file => file.id)
       return Promise.all(ids.map(id => this.setMediaDescription(id)))
+    },
+    handleEmojiInputShow (value) {
+      this.emojiInputShown = value
     }
   }
 }
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 626584ed..d8df68d6 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -5,19 +5,20 @@
   >
     <form
       autocomplete="off"
-      @submit.prevent="postStatus(newStatus)"
+      @submit.prevent
       @dragover.prevent="fileDrag"
     >
       <div
         v-show="showDropIcon !== 'hide'"
         :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
-        class="drop-indicator icon-upload"
+        class="drop-indicator"
+        :class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']"
         @dragleave="fileDragStop"
         @drop.stop="fileDrop"
       />
       <div class="form-group">
         <i18n
-          v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'"
+          v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
           path="post_status.account_not_locked_warning"
           tag="p"
           class="visibility-notice"
@@ -108,7 +109,7 @@
           />
         </div>
         <EmojiInput
-          v-if="newStatus.spoilerText || alwaysShowSubject"
+          v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
           v-model="newStatus.spoilerText"
           enable-emoji-picker
           :suggest="emojiSuggestor"
@@ -126,6 +127,7 @@
           ref="emoji-input"
           v-model="newStatus.status"
           :suggest="emojiUserSuggestor"
+          :placement="emojiPickerPlacement"
           class="form-control main-input"
           enable-emoji-picker
           hide-emoji-button
@@ -133,16 +135,19 @@
           @input="onEmojiInputInput"
           @sticker-uploaded="addMediaFile"
           @sticker-upload-failed="uploadFailed"
+          @shown="handleEmojiInputShow"
         >
           <textarea
             ref="textarea"
             v-model="newStatus.status"
-            :placeholder="$t('post_status.default')"
+            :placeholder="placeholder || $t('post_status.default')"
             rows="1"
             :disabled="posting"
             class="form-post-body"
-            @keydown.meta.enter="postStatus(newStatus)"
-            @keydown.ctrl.enter="postStatus(newStatus)"
+            :class="{ 'scrollable-form': !!maxHeight }"
+            @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
+            @keydown.meta.enter="postStatus($event, newStatus, { control: true })"
+            @keydown.ctrl.enter="postStatus($event, newStatus)"
             @input="resize"
             @compositionupdate="resize"
             @paste="paste"
@@ -155,7 +160,10 @@
             {{ charactersLeft }}
           </p>
         </EmojiInput>
-        <div class="visibility-tray">
+        <div
+          v-if="!disableScopeSelector"
+          class="visibility-tray"
+        >
           <scope-selector
             :show-all="showAllScopes"
             :user-default="userDefaultScope"
@@ -213,10 +221,11 @@
             ref="mediaUpload"
             class="media-upload-icon"
             :drop-files="dropFiles"
-            @uploading="disableSubmit"
+            :disabled="uploadFileLimitReached"
+            @uploading="startedUploadingFiles"
             @uploaded="addMediaFile"
             @upload-failed="uploadFailed"
-            @all-uploaded="enableSubmit"
+            @all-uploaded="finishedUploadingFiles"
           />
           <div
             class="emoji-icon"
@@ -253,11 +262,13 @@
         >
           {{ $t('general.submit') }}
         </button>
+        <!-- touchstart is used to keep the OSK at the same position after a message send -->
         <button
           v-else
-          :disabled="submitDisabled"
-          type="submit"
+          :disabled="uploadingFiles || disableSubmit"
           class="btn btn-default"
+          @touchstart.stop.prevent="postStatus($event, newStatus)"
+          @click.stop.prevent="postStatus($event, newStatus)"
         >
           {{ $t('general.submit') }}
         </button>
@@ -297,7 +308,7 @@
         </div>
       </div>
       <div
-        v-if="newStatus.files.length > 0"
+        v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
         class="upload_settings"
       >
         <Checkbox v-model="newStatus.nsfw">
@@ -331,6 +342,8 @@
 }
 
 .post-status-form {
+  position: relative;
+
   .form-bottom {
     display: flex;
     justify-content: space-between;
@@ -547,6 +560,10 @@
     padding-bottom: 1.75em;
     min-height: 1px;
     box-sizing: content-box;
+
+    &.scrollable-form {
+      overflow-y: auto;
+    }
   }
 
   .main-input {
@@ -609,4 +626,11 @@
     border: 2px dashed var(--text, $fallback--text);
   }
 }
+
+// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
+img.media-upload {
+  line-height: 0;
+  max-height: 200px;
+  max-width: 100%;
+}
 </style>
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 9d61b0c4..e3c5e80a 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -99,7 +99,8 @@ export default {
       avatarRadiusLocal: '',
       avatarAltRadiusLocal: '',
       attachmentRadiusLocal: '',
-      tooltipRadiusLocal: ''
+      tooltipRadiusLocal: '',
+      chatMessageRadiusLocal: ''
     }
   },
   created () {
@@ -214,7 +215,8 @@ export default {
         avatar: this.avatarRadiusLocal,
         avatarAlt: this.avatarAltRadiusLocal,
         tooltip: this.tooltipRadiusLocal,
-        attachment: this.attachmentRadiusLocal
+        attachment: this.attachmentRadiusLocal,
+        chatMessage: this.chatMessageRadiusLocal
       }
     },
     preview () {
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
index d14f854c..d57894de 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.vue
@@ -735,6 +735,65 @@
             />
             <ContrastRatio :contrast="previewContrast.selectedMenuLink" />
           </div>
+          <div class="color-item">
+            <h4>{{ $t('chats.chats') }}</h4>
+            <ColorInput
+              v-model="chatBgColorLocal"
+              name="chatBgColor"
+              :fallback="previewTheme.colors.bg || 1"
+              :label="$t('settings.background')"
+            />
+            <h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
+            <ColorInput
+              v-model="chatMessageIncomingBgColorLocal"
+              name="chatMessageIncomingBgColor"
+              :fallback="previewTheme.colors.bg || 1"
+              :label="$t('settings.background')"
+            />
+            <ColorInput
+              v-model="chatMessageIncomingTextColorLocal"
+              name="chatMessageIncomingTextColor"
+              :fallback="previewTheme.colors.text || 1"
+              :label="$t('settings.text')"
+            />
+            <ColorInput
+              v-model="chatMessageIncomingLinkColorLocal"
+              name="chatMessageIncomingLinkColor"
+              :fallback="previewTheme.colors.link || 1"
+              :label="$t('settings.links')"
+            />
+            <ColorInput
+              v-model="chatMessageIncomingBorderColorLocal"
+              name="chatMessageIncomingBorderLinkColor"
+              :fallback="previewTheme.colors.fg || 1"
+              :label="$t('settings.style.advanced_colors.chat.border')"
+            />
+            <h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
+            <ColorInput
+              v-model="chatMessageOutgoingBgColorLocal"
+              name="chatMessageOutgoingBgColor"
+              :fallback="previewTheme.colors.bg || 1"
+              :label="$t('settings.background')"
+            />
+            <ColorInput
+              v-model="chatMessageOutgoingTextColorLocal"
+              name="chatMessageOutgoingTextColor"
+              :fallback="previewTheme.colors.text || 1"
+              :label="$t('settings.text')"
+            />
+            <ColorInput
+              v-model="chatMessageOutgoingLinkColorLocal"
+              name="chatMessageOutgoingLinkColor"
+              :fallback="previewTheme.colors.link || 1"
+              :label="$t('settings.links')"
+            />
+            <ColorInput
+              v-model="chatMessageOutgoingBorderColorLocal"
+              name="chatMessageOutgoingBorderLinkColor"
+              :fallback="previewTheme.colors.bg || 1"
+              :label="$t('settings.style.advanced_colors.chat.border')"
+            />
+          </div>
         </div>
 
         <div
@@ -814,6 +873,14 @@
             max="50"
             hard-min="0"
           />
+          <RangeInput
+            v-model="chatMessageRadiusLocal"
+            name="chatMessageRadius"
+            :label="$t('settings.chatMessageRadius')"
+            :fallback="previewTheme.radii.chatMessage || 2"
+            max="50"
+            hard-min="0"
+          />
         </div>
 
         <div
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index d1f044f6..3a9e9e8f 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,3 +1,4 @@
+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'
@@ -47,7 +48,11 @@ const SideDrawer = {
     },
     federating () {
       return this.$store.state.instance.federating
-    }
+    },
+    ...mapState({
+      pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+    }),
+    ...mapGetters(['unreadChatCount'])
   },
   methods: {
     toggleDrawer () {
diff --git a/src/components/side_drawer/side_drawer.vue b/src/components/side_drawer/side_drawer.vue
index 0ac53b34..4fdb3d13 100644
--- a/src/components/side_drawer/side_drawer.vue
+++ b/src/components/side_drawer/side_drawer.vue
@@ -40,12 +40,24 @@
           </router-link>
         </li>
         <li
-          v-if="currentUser"
+          v-if="currentUser && pleromaChatMessagesAvailable"
           @click="toggleDrawer"
         >
           <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
             <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
           </router-link>
+          <router-link
+            :to="{ name: 'chats', params: { username: currentUser.screen_name } }"
+            style="position: relative"
+          >
+            <i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
+            <span
+              v-if="unreadChatCount"
+              class="badge badge-notification unread-chat-count"
+            >
+              {{ unreadChatCount }}
+            </span>
+          </router-link>
         </li>
         <li
           v-if="currentUser"
@@ -103,14 +115,6 @@
             <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
           </router-link>
         </li>
-        <li
-          v-if="currentUser && chat"
-          @click="toggleDrawer"
-        >
-          <router-link :to="{ name: 'chat' }">
-            <i class="button-icon icon-chat" /> {{ $t("nav.chat") }}
-          </router-link>
-        </li>
       </ul>
       <ul>
         <li
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index 67d9bd3c..dc83d8cb 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -18,7 +18,7 @@ const StatusContent = {
   ],
   data () {
     return {
-      showingTall: this.inConversation && this.focused,
+      showingTall: this.fullContent || (this.inConversation && this.focused),
       showingLongSubject: false,
       // not as computed because it sets the initial state which will be changed later
       expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 8068d8d2..9ca2cf6d 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -76,7 +76,7 @@
         />
       </a>
       <a
-        v-if="showingMore"
+        v-if="showingMore && !fullContent"
         href="#"
         class="status-unhider"
         @click.prevent="toggleShowMore"
diff --git a/src/hocs/with_load_more/with_load_more.scss b/src/hocs/with_load_more/with_load_more.scss
index 4cefe2be..1a26eb8d 100644
--- a/src/hocs/with_load_more/with_load_more.scss
+++ b/src/hocs/with_load_more/with_load_more.scss
@@ -12,5 +12,9 @@
     .error {
       font-size: 14px;
     }
+
+    a {
+      cursor: pointer;
+    }
   }
 }
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 72c3b1f7..c9a34556 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -44,6 +44,7 @@
   },
   "features_panel": {
     "chat": "Chat",
+    "pleroma_chat_messages": "Pleroma Chat",
     "gopher": "Gopher",
     "media_proxy": "Media proxy",
     "scope_options": "Scope options",
@@ -124,7 +125,8 @@
     "user_search": "User Search",
     "search": "Search",
     "who_to_follow": "Who to follow",
-    "preferences": "Preferences"
+    "preferences": "Preferences",
+    "chats": "Chats"
   },
   "notifications": {
     "broken_favorite": "Unknown status, searching for it…",
@@ -287,6 +289,7 @@
     "change_password": "Change Password",
     "change_password_error": "There was an issue changing your password.",
     "changed_password": "Password changed successfully!",
+    "chatMessageRadius": "Chat message",
     "collapse_subject": "Collapse posts with subjects",
     "composing": "Composing",
     "confirm_new_password": "Confirm new password",
@@ -518,7 +521,12 @@
         "selectedMenu": "Selected menu item",
         "disabled": "Disabled",
         "toggled": "Toggled",
-        "tabs": "Tabs"
+        "tabs": "Tabs",
+        "chat": {
+          "incoming": "Incoming",
+          "outgoing": "Outgoing",
+          "border": "Border"
+        }
       },
       "radii": {
         "_tab_label": "Roundness"
@@ -677,6 +685,7 @@
     "its_you": "It's you!",
     "media": "Media",
     "mention": "Mention",
+    "message": "Message",
     "mute": "Mute",
     "muted": "Muted",
     "per_day": "per day",
@@ -775,5 +784,25 @@
     "password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
     "password_reset_required": "You must reset your password to log in.",
     "password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
+  },
+  "chats": {
+    "message_user": "Message {nickname}",
+    "delete": "Delete",
+    "chats": "Chats",
+    "new": "New Chat",
+    "empty_message_error": "Cannot post empty message",
+    "more": "More",
+    "delete_confirm": "Do you really want to delete this message?",
+    "error_loading_chat": "Something went wrong when loading the chat.",
+    "error_sending_message": "Something went wrong when sending the message."
+  },
+  "file_type": {
+    "audio": "Audio",
+    "video": "Video",
+    "image": "Image",
+    "file": "File"
+  },
+  "display_date": {
+    "today": "Today"
   }
 }
diff --git a/src/main.js b/src/main.js
index 5bddc76e..0a898022 100644
--- a/src/main.js
+++ b/src/main.js
@@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js'
 import reportsModule from './modules/reports.js'
 import pollsModule from './modules/polls.js'
 import postStatusModule from './modules/postStatus.js'
+import chatsModule from './modules/chats.js'
 
 import VueI18n from 'vue-i18n'
 
@@ -91,7 +92,8 @@ const persistedStateOptions = {
       oauthTokens: oauthTokensModule,
       reports: reportsModule,
       polls: pollsModule,
-      postStatus: postStatusModule
+      postStatus: postStatusModule,
+      chats: chatsModule
     },
     plugins,
     strict: false // Socket modifies itself, let's ignore this for now.
diff --git a/src/modules/api.js b/src/modules/api.js
index 04ef6ab4..68402602 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,4 +1,5 @@
 import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
+import { WSConnectionStatus } from '../services/api/api.service.js'
 import { Socket } from 'phoenix'
 
 const api = {
@@ -7,6 +8,7 @@ const api = {
     fetchers: {},
     socket: null,
     mastoUserSocket: null,
+    mastoUserSocketStatus: null,
     followRequests: []
   },
   mutations: {
@@ -28,6 +30,9 @@ const api = {
     },
     setFollowRequests (state, value) {
       state.followRequests = value
+    },
+    setMastoUserSocketStatus (state, value) {
+      state.mastoUserSocketStatus = value
     }
   },
   actions: {
@@ -47,7 +52,7 @@ const api = {
     startMastoUserSocket (store) {
       return new Promise((resolve, reject) => {
         try {
-          const { state, dispatch, rootState } = store
+          const { state, commit, dispatch, rootState } = store
           const timelineData = rootState.statuses.timelines.friends
           state.mastoUserSocket = state.backendInteractor.startUserSocket({ store })
           state.mastoUserSocket.addEventListener(
@@ -66,11 +71,22 @@ const api = {
                   showImmediately: timelineData.visibleStatuses.length === 0,
                   timeline: 'friends'
                 })
+              } else if (message.event === 'pleroma:chat_update') {
+                dispatch('addChatMessages', {
+                  chatId: message.chatUpdate.id,
+                  messages: [message.chatUpdate.lastMessage]
+                })
+                dispatch('updateChat', { chat: message.chatUpdate })
               }
             }
           )
+          state.mastoUserSocket.addEventListener('open', () => {
+            commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
+          })
           state.mastoUserSocket.addEventListener('error', ({ detail: error }) => {
             console.error('Error in MastoAPI websocket:', error)
+            commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
+            dispatch('clearOpenedChats')
           })
           state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => {
             const ignoreCodes = new Set([
@@ -84,8 +100,11 @@ const api = {
               console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`)
               dispatch('startFetchingTimeline', { timeline: 'friends' })
               dispatch('startFetchingNotifications')
+              dispatch('startFetchingChats')
               dispatch('restartMastoUserSocket')
             }
+            commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
+            dispatch('clearOpenedChats')
           })
           resolve()
         } catch (e) {
@@ -99,12 +118,13 @@ const api = {
       return dispatch('startMastoUserSocket').then(() => {
         dispatch('stopFetchingTimeline', { timeline: 'friends' })
         dispatch('stopFetchingNotifications')
+        dispatch('stopFetchingChats')
       })
     },
     stopMastoUserSocket ({ state, dispatch }) {
       dispatch('startFetchingTimeline', { timeline: 'friends' })
       dispatch('startFetchingNotifications')
-      console.log(state.mastoUserSocket)
+      dispatch('startFetchingChats')
       state.mastoUserSocket.close()
     },
 
diff --git a/src/modules/chats.js b/src/modules/chats.js
new file mode 100644
index 00000000..f868ca0c
--- /dev/null
+++ b/src/modules/chats.js
@@ -0,0 +1,228 @@
+import Vue from 'vue'
+import { find, omitBy, orderBy, sumBy } from 'lodash'
+import chatService from '../services/chat_service/chat_service.js'
+import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js'
+
+const emptyChatList = () => ({
+  data: [],
+  idStore: {}
+})
+
+const defaultState = {
+  chatList: emptyChatList(),
+  chatListFetcher: null,
+  openedChats: {},
+  openedChatMessageServices: {},
+  fetcher: undefined,
+  currentChatId: null
+}
+
+const getChatById = (state, id) => {
+  return find(state.chatList.data, { id })
+}
+
+const sortedChatList = (state) => {
+  return orderBy(state.chatList.data, ['updated_at'], ['desc'])
+}
+
+const unreadChatCount = (state) => {
+  return sumBy(state.chatList.data, 'unread')
+}
+
+const chats = {
+  state: { ...defaultState },
+  getters: {
+    currentChat: state => state.openedChats[state.currentChatId],
+    currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId],
+    findOpenedChatByRecipientId: state => recipientId => find(state.openedChats, c => c.account.id === recipientId),
+    sortedChatList,
+    unreadChatCount
+  },
+  actions: {
+    // Chat list
+    startFetchingChats ({ dispatch, commit }) {
+      const fetcher = () => {
+        dispatch('fetchChats', { latest: true })
+      }
+      fetcher()
+      commit('setChatListFetcher', {
+        fetcher: () => setInterval(() => { fetcher() }, 5000)
+      })
+    },
+    stopFetchingChats ({ commit }) {
+      commit('setChatListFetcher', { fetcher: undefined })
+    },
+    fetchChats ({ dispatch, rootState, commit }, params = {}) {
+      return rootState.api.backendInteractor.chats()
+        .then(({ chats }) => {
+          dispatch('addNewChats', { chats })
+          return chats
+        })
+    },
+    addNewChats ({ rootState, commit, dispatch, rootGetters }, { chats }) {
+      commit('addNewChats', { dispatch, chats, rootGetters })
+    },
+    updateChat ({ commit }, { chat }) {
+      commit('updateChat', { chat })
+    },
+
+    // Opened Chats
+    startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) {
+      dispatch('setCurrentChatFetcher', { fetcher })
+    },
+    setCurrentChatFetcher ({ rootState, commit }, { fetcher }) {
+      commit('setCurrentChatFetcher', { fetcher })
+    },
+    addOpenedChat ({ rootState, commit, dispatch }, { chat }) {
+      commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
+      dispatch('addNewUsers', [chat.account])
+    },
+    addChatMessages ({ commit }, value) {
+      commit('addChatMessages', { commit, ...value })
+    },
+    resetChatNewMessageCount ({ commit }, value) {
+      commit('resetChatNewMessageCount', value)
+    },
+    removeFromCurrentChatStatuses ({ commit }, { id }) {
+      commit('removeFromCurrentChatStatuses', id)
+    },
+    clearCurrentChat ({ rootState, commit, dispatch }, value) {
+      commit('setCurrentChatId', { chatId: undefined })
+      commit('setCurrentChatFetcher', { fetcher: undefined })
+    },
+    readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
+      dispatch('resetChatNewMessageCount')
+      commit('readChat', { id })
+      rootState.api.backendInteractor.readChat({ id, lastReadId })
+    },
+    deleteChatMessage ({ rootState, commit }, value) {
+      rootState.api.backendInteractor.deleteChatMessage(value)
+      commit('deleteChatMessage', { commit, ...value })
+    },
+    resetChats ({ commit, dispatch }) {
+      dispatch('clearCurrentChat')
+      commit('resetChats', { commit })
+    },
+    clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
+      commit('clearOpenedChats', { commit })
+    }
+  },
+  mutations: {
+    setChatListFetcher (state, { commit, fetcher }) {
+      const prevFetcher = state.chatListFetcher
+      if (prevFetcher) {
+        clearInterval(prevFetcher)
+      }
+      state.chatListFetcher = fetcher && fetcher()
+    },
+    setCurrentChatFetcher (state, { fetcher }) {
+      const prevFetcher = state.fetcher
+      if (prevFetcher) {
+        clearInterval(prevFetcher)
+      }
+      state.fetcher = fetcher && fetcher()
+    },
+    addOpenedChat (state, { _dispatch, chat }) {
+      state.currentChatId = chat.id
+      Vue.set(state.openedChats, chat.id, chat)
+
+      if (!state.openedChatMessageServices[chat.id]) {
+        Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id))
+      }
+    },
+    setCurrentChatId (state, { chatId }) {
+      state.currentChatId = chatId
+    },
+    addNewChats (state, { _dispatch, chats, _rootGetters }) {
+      chats.forEach((updatedChat) => {
+        const chat = getChatById(state, updatedChat.id)
+
+        if (chat) {
+          chat.lastMessage = updatedChat.lastMessage
+          chat.unread = updatedChat.unread
+        } else {
+          state.chatList.data.push(updatedChat)
+          Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
+        }
+      })
+    },
+    updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) {
+      const chat = getChatById(state, updatedChat.id)
+      if (chat) {
+        chat.lastMessage = updatedChat.lastMessage
+        chat.unread = updatedChat.unread
+        chat.updated_at = updatedChat.updated_at
+      }
+      if (!chat) { state.chatList.data.unshift(updatedChat) }
+      Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
+    },
+    deleteChat (state, { _dispatch, id, _rootGetters }) {
+      state.chats.data = state.chats.data.filter(conversation =>
+        conversation.last_status.id !== id
+      )
+      state.chats.idStore = omitBy(state.chats.idStore, conversation => conversation.last_status.id === id)
+    },
+    resetChats (state, { commit }) {
+      state.chatList = emptyChatList()
+      state.currentChatId = null
+      commit('setChatListFetcher', { fetcher: undefined })
+      for (const chatId in state.openedChats) {
+        chatService.clear(state.openedChatMessageServices[chatId])
+        Vue.delete(state.openedChats, chatId)
+        Vue.delete(state.openedChatMessageServices, chatId)
+      }
+    },
+    setChatsLoading (state, { value }) {
+      state.chats.loading = value
+    },
+    addChatMessages (state, { commit, chatId, messages }) {
+      const chatMessageService = state.openedChatMessageServices[chatId]
+      if (chatMessageService) {
+        chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) })
+        commit('refreshLastMessage', { chatId })
+      }
+    },
+    refreshLastMessage (state, { chatId }) {
+      const chatMessageService = state.openedChatMessageServices[chatId]
+      if (chatMessageService) {
+        const chat = getChatById(state, chatId)
+        if (chat) {
+          chat.lastMessage = chatMessageService.lastMessage
+          if (chatMessageService.lastMessage) {
+            chat.updated_at = chatMessageService.lastMessage.created_at
+          }
+        }
+      }
+    },
+    deleteChatMessage (state, { commit, chatId, messageId }) {
+      const chatMessageService = state.openedChatMessageServices[chatId]
+      if (chatMessageService) {
+        chatService.deleteMessage(chatMessageService, messageId)
+        commit('refreshLastMessage', { chatId })
+      }
+    },
+    resetChatNewMessageCount (state, _value) {
+      const chatMessageService = state.openedChatMessageServices[state.currentChatId]
+      chatService.resetNewMessageCount(chatMessageService)
+    },
+    // Used when a connection loss occurs
+    clearOpenedChats (state) {
+      const currentChatId = state.currentChatId
+      for (const chatId in state.openedChats) {
+        if (currentChatId !== chatId) {
+          chatService.clear(state.openedChatMessageServices[chatId])
+          Vue.delete(state.openedChats, chatId)
+          Vue.delete(state.openedChatMessageServices, chatId)
+        }
+      }
+    },
+    readChat (state, { id }) {
+      const chat = getChatById(state, id)
+      if (chat) {
+        chat.unread = 0
+      }
+    }
+  }
+}
+
+export default chats
diff --git a/src/modules/config.js b/src/modules/config.js
index 47b24d77..e0fe72df 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -46,7 +46,8 @@ export const defaultState = {
     repeats: true,
     moves: true,
     emojiReactions: false,
-    followRequest: true
+    followRequest: true,
+    chatMention: true
   },
   webPushNotifications: false,
   muteWords: [],
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 45a8eeca..3fe3bbf3 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -55,6 +55,7 @@ const defaultState = {
 
   // Feature-set, apparently, not everything here is reported...
   chatAvailable: false,
+  pleromaChatMessagesAvailable: false,
   gopherAvailable: false,
   mediaProxyAvailable: false,
   suggestionsEnabled: false,
diff --git a/src/modules/interface.js b/src/modules/interface.js
index e31630fc..ec08ac0a 100644
--- a/src/modules/interface.js
+++ b/src/modules/interface.js
@@ -15,7 +15,8 @@ const defaultState = {
     )
   },
   mobileLayout: false,
-  globalNotices: []
+  globalNotices: [],
+  layoutHeight: 0
 }
 
 const interfaceMod = {
@@ -65,6 +66,9 @@ const interfaceMod = {
     },
     removeGlobalNotice (state, notice) {
       state.globalNotices = state.globalNotices.filter(n => n !== notice)
+    },
+    setLayoutHeight (state, value) {
+      state.layoutHeight = value
     }
   },
   actions: {
@@ -110,6 +114,9 @@ const interfaceMod = {
     },
     removeGlobalNotice ({ commit }, notice) {
       commit('removeGlobalNotice', notice)
+    },
+    setLayoutHeight ({ commit }, value) {
+      commit('setLayoutHeight', value)
     }
   }
 }
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 7fbf685c..64f5b587 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -478,7 +478,7 @@ export const mutations = {
   },
   setDeleted (state, { status }) {
     const newStatus = state.allStatusesObject[status.id]
-    newStatus.deleted = true
+    if (newStatus) newStatus.deleted = true
   },
   setManyDeleted (state, condition) {
     Object.values(state.allStatusesObject).forEach(status => {
@@ -521,6 +521,9 @@ export const mutations = {
   dismissNotification (state, { id }) {
     state.notifications.data = state.notifications.data.filter(n => n.id !== id)
   },
+  dismissNotifications (state, { finder }) {
+    state.notifications.data = state.notifications.data.filter(n => finder)
+  },
   updateNotification (state, { id, updater }) {
     const notification = find(state.notifications.data, n => n.id === id)
     notification && updater(notification)
diff --git a/src/modules/users.js b/src/modules/users.js
index 7e136c61..16c1e566 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -498,6 +498,7 @@ const users = {
           store.dispatch('stopFetchingFollowRequests')
           store.commit('clearNotifications')
           store.commit('resetStatuses')
+          store.dispatch('resetChats')
         })
     },
     loginUser (store, accessToken) {
@@ -537,6 +538,9 @@ const users = {
 
                 // Start fetching notifications
                 store.dispatch('startFetchingNotifications')
+
+                // Start fetching chats
+                store.dispatch('startFetchingChats')
               }
 
               if (store.getters.mergedConfig.useStreamingApi) {
@@ -544,6 +548,7 @@ const users = {
                   console.error('Failed initializing MastoAPI Streaming socket', error)
                   startPolling()
                 }).then(() => {
+                  store.dispatch('fetchChats', { latest: true })
                   setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
                 })
               } else {
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 14e63e4f..5428cc2a 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,5 +1,5 @@
 import { each, map, concat, last, get } from 'lodash'
-import { parseStatus, parseUser, parseNotification, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
+import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
 import { RegistrationError, StatusCodeError } from '../errors/errors'
 
 /* eslint-env browser */
@@ -81,6 +81,11 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
 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 PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
+const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
+const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
+const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
+const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
 
 const oldfetch = window.fetch
 
@@ -117,13 +122,18 @@ const promisedRequest = ({ method, url, params, payload, credentials, headers =
   }
   return fetch(url, options)
     .then((response) => {
-      return new Promise((resolve, reject) => response.json()
-        .then((json) => {
-          if (!response.ok) {
-            return reject(new StatusCodeError(response.status, json, { url, options }, response))
-          }
-          return resolve(json)
-        }))
+      return new Promise((resolve, reject) => {
+        response.json()
+          .then((json) => {
+            if (!response.ok) {
+              return reject(new StatusCodeError(response.status, json, { url, options }, response))
+            }
+            return resolve(json)
+          })
+          .catch((error) => {
+            return reject(new StatusCodeError(response.status, error.message, { url, options }, response))
+          })
+      })
     })
 }
 
@@ -1067,6 +1077,10 @@ const MASTODON_STREAMING_EVENTS = new Set([
   'filters_changed'
 ])
 
+const PLEROMA_STREAMING_EVENTS = new Set([
+  'pleroma:chat_update'
+])
+
 // A thin wrapper around WebSocket API that allows adding a pre-processor to it
 // Uses EventTarget and a CustomEvent to proxy events
 export const ProcessedWS = ({
@@ -1123,7 +1137,7 @@ export const handleMastoWS = (wsEvent) => {
   if (!data) return
   const parsedEvent = JSON.parse(data)
   const { event, payload } = parsedEvent
-  if (MASTODON_STREAMING_EVENTS.has(event)) {
+  if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) {
     // MastoBE and PleromaBE both send payload for delete as a PLAIN string
     if (event === 'delete') {
       return { event, id: payload }
@@ -1133,6 +1147,8 @@ export const handleMastoWS = (wsEvent) => {
       return { event, status: parseStatus(data) }
     } else if (event === 'notification') {
       return { event, notification: parseNotification(data) }
+    } else if (event === 'pleroma:chat_update') {
+      return { event, chatUpdate: parseChat(data) }
     }
   } else {
     console.warn('Unknown event', wsEvent)
@@ -1140,6 +1156,81 @@ export const handleMastoWS = (wsEvent) => {
   }
 }
 
+export const WSConnectionStatus = Object.freeze({
+  'JOINED': 1,
+  'CLOSED': 2,
+  'ERROR': 3
+})
+
+const chats = ({ credentials }) => {
+  return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
+    .then((data) => data.json())
+    .then((data) => {
+      return { chats: data.map(parseChat).filter(c => c) }
+    })
+}
+
+const getOrCreateChat = ({ accountId, credentials }) => {
+  return promisedRequest({
+    url: PLEROMA_CHAT_URL(accountId),
+    method: 'POST',
+    credentials
+  })
+}
+
+const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
+  let url = PLEROMA_CHAT_MESSAGES_URL(id)
+  const args = [
+    maxId && `max_id=${maxId}`,
+    sinceId && `since_id=${sinceId}`,
+    limit && `limit=${limit}`
+  ].filter(_ => _).join('&')
+
+  url = url + (args ? '?' + args : '')
+
+  return promisedRequest({
+    url,
+    method: 'GET',
+    credentials
+  })
+}
+
+const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
+  const payload = {
+    'content': content
+  }
+
+  if (mediaId) {
+    payload['media_id'] = mediaId
+  }
+
+  return promisedRequest({
+    url: PLEROMA_CHAT_MESSAGES_URL(id),
+    method: 'POST',
+    payload: payload,
+    credentials
+  })
+}
+
+const readChat = ({ id, lastReadId, credentials }) => {
+  return promisedRequest({
+    url: PLEROMA_CHAT_READ_URL(id),
+    method: 'POST',
+    payload: {
+      'last_read_id': lastReadId
+    },
+    credentials
+  })
+}
+
+const deleteChatMessage = ({ chatId, messageId, credentials }) => {
+  return promisedRequest({
+    url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
+    method: 'DELETE',
+    credentials
+  })
+}
+
 const apiService = {
   verifyCredentials,
   fetchTimeline,
@@ -1218,7 +1309,13 @@ const apiService = {
   fetchKnownDomains,
   fetchDomainMutes,
   muteDomain,
-  unmuteDomain
+  unmuteDomain,
+  chats,
+  getOrCreateChat,
+  chatMessages,
+  sendChatMessage,
+  readChat,
+  deleteChatMessage
 }
 
 export default apiService
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
new file mode 100644
index 00000000..763a7607
--- /dev/null
+++ b/src/services/chat_service/chat_service.js
@@ -0,0 +1,150 @@
+import _ from 'lodash'
+
+const empty = (chatId) => {
+  return {
+    idIndex: {},
+    messages: [],
+    newMessageCount: 0,
+    lastSeenTimestamp: 0,
+    chatId: chatId,
+    minId: undefined,
+    lastMessage: undefined
+  }
+}
+
+const clear = (storage) => {
+  storage.idIndex = {}
+  storage.messages.splice(0, storage.messages.length)
+  storage.newMessageCount = 0
+  storage.lastSeenTimestamp = 0
+  storage.minId = undefined
+  storage.lastMessage = undefined
+}
+
+const deleteMessage = (storage, messageId) => {
+  if (!storage) { return }
+  storage.messages = storage.messages.filter(m => m.id !== messageId)
+  delete storage.idIndex[messageId]
+
+  if (storage.lastMessage && (storage.lastMessage.id === messageId)) {
+    storage.lastMessage = _.maxBy(storage.messages, 'id')
+  }
+
+  if (storage.minId === messageId) {
+    storage.minId = _.minBy(storage.messages, 'id')
+  }
+}
+
+const add = (storage, { messages: newMessages }) => {
+  if (!storage) { return }
+  for (let i = 0; i < newMessages.length; i++) {
+    const message = newMessages[i]
+
+    // sanity check
+    if (message.chat_id !== storage.chatId) { return }
+
+    if (!storage.minId || message.id < storage.minId) {
+      storage.minId = message.id
+    }
+
+    if (!storage.lastMessage || message.id > storage.lastMessage.id) {
+      storage.lastMessage = message
+    }
+
+    if (!storage.idIndex[message.id]) {
+      if (storage.lastSeenTimestamp < message.created_at) {
+        storage.newMessageCount++
+      }
+      storage.messages.push(message)
+      storage.idIndex[message.id] = message
+    }
+  }
+}
+
+const resetNewMessageCount = (storage) => {
+  if (!storage) { return }
+  storage.newMessageCount = 0
+  storage.lastSeenTimestamp = new Date()
+}
+
+// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
+const getView = (storage) => {
+  if (!storage) { return [] }
+
+  const result = []
+  const messages = _.sortBy(storage.messages, ['id', 'desc'])
+  const firstMessages = messages[0]
+  let prev = messages[messages.length - 1]
+  let currentMessageChainId
+
+  if (firstMessages) {
+    const date = new Date(firstMessages.created_at)
+    date.setHours(0, 0, 0, 0)
+    result.push({
+      type: 'date',
+      date,
+      id: date.getTime().toString()
+    })
+  }
+
+  let afterDate = false
+
+  for (let i = 0; i < messages.length; i++) {
+    const message = messages[i]
+    const nextMessage = messages[i + 1]
+
+    const date = new Date(message.created_at)
+    date.setHours(0, 0, 0, 0)
+
+    // insert date separator and start a new message chain
+    if (prev && prev.date < date) {
+      result.push({
+        type: 'date',
+        date,
+        id: date.getTime().toString()
+      })
+
+      prev['isTail'] = true
+      currentMessageChainId = undefined
+      afterDate = true
+    }
+
+    const object = {
+      type: 'message',
+      data: message,
+      date,
+      id: message.id,
+      messageChainId: currentMessageChainId
+    }
+
+    // end a message chian
+    if ((nextMessage && nextMessage.account_id) !== message.account_id) {
+      object['isTail'] = true
+      currentMessageChainId = undefined
+    }
+
+    // start a new message chain
+    if ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) {
+      currentMessageChainId = _.uniqueId()
+      object['isHead'] = true
+      object['messageChainId'] = currentMessageChainId
+    }
+
+    result.push(object)
+    prev = object
+    afterDate = false
+  }
+
+  return result
+}
+
+const ChatService = {
+  add,
+  empty,
+  getView,
+  deleteMessage,
+  resetNewMessageCount,
+  clear
+}
+
+export default ChatService
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index ec83c02a..7ea8a16c 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -183,6 +183,7 @@ export const parseUser = (data) => {
     output.deactivated = data.pleroma.deactivated
 
     output.notification_settings = data.pleroma.notification_settings
+    output.unread_chat_count = data.pleroma.unread_chat_count
   }
 
   output.tags = output.tags || []
@@ -372,7 +373,7 @@ export const parseNotification = (data) => {
       ? parseStatus(data.notice.favorited_status)
       : parsedNotice
     output.action = parsedNotice
-    output.from_profile = parseUser(data.from_profile)
+    output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
   }
 
   output.created_at = new Date(data.created_at)
@@ -398,3 +399,34 @@ export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
     minId: flakeId ? minId : parseInt(minId, 10)
   }
 }
+
+export const parseChat = (chat) => {
+  const output = {}
+  output.id = chat.id
+  output.account = parseUser(chat.account)
+  output.unread = chat.unread
+  output.lastMessage = parseChatMessage(chat.last_message)
+  output.updated_at = new Date(chat.updated_at)
+  return output
+}
+
+export const parseChatMessage = (message) => {
+  if (!message) { return }
+  if (message.isNormalized) { return message }
+  const output = message
+  output.id = message.id
+  output.created_at = new Date(message.created_at)
+  output.chat_id = message.chat_id
+  if (message.content) {
+    output.content = addEmojis(message.content, message.emojis)
+  } else {
+    output.content = ''
+  }
+  if (message.attachment) {
+    output.attachments = [parseAttachment(message.attachment)]
+  } else {
+    output.attachments = []
+  }
+  output.isNormalized = true
+  return output
+}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index fbdcf562..07425abd 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -106,7 +106,8 @@ export const generateRadii = (input) => {
     avatar: 5,
     avatarAlt: 50,
     tooltip: 2,
-    attachment: 5
+    attachment: 5,
+    chatMessage: inputRadii.panel
   })
 
   return {
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
index 6b25cd6f..b58ca9be 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -23,7 +23,9 @@ export const LAYERS = {
   inputTopBar: 'topBar',
   alert: 'bg',
   alertPanel: 'panel',
-  poll: 'bg'
+  poll: 'bg',
+  chatBg: 'underlay',
+  chatMessage: 'chatBg'
 }
 
 /* By default opacity slots have 1 as default opacity
@@ -667,5 +669,54 @@ export const SLOT_INHERITANCE = {
     layer: 'badge',
     variant: 'badgeNotification',
     textColor: 'bw'
+  },
+
+  chatBg: {
+    depends: ['bg']
+  },
+
+  chatMessage: {
+    depends: ['chatBg']
+  },
+
+  chatMessageIncomingBg: {
+    depends: ['chatMessage'],
+    layer: 'chatMessage'
+  },
+
+  chatMessageIncomingText: {
+    depends: ['text'],
+    layer: 'text'
+  },
+
+  chatMessageIncomingLink: {
+    depends: ['link'],
+    layer: 'link'
+  },
+
+  chatMessageIncomingBorder: {
+    depends: ['border'],
+    opacity: 'border',
+    color: (mod, border) => brightness(2 * mod, border).rgb
+  },
+
+  chatMessageOutgoingBg: {
+    depends: ['chatMessage'],
+    color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb
+  },
+
+  chatMessageOutgoingText: {
+    depends: ['text'],
+    layer: 'text'
+  },
+
+  chatMessageOutgoingLink: {
+    depends: ['link'],
+    layer: 'link'
+  },
+
+  chatMessageOutgoingBorder: {
+    depends: ['chatMessage'],
+    opacity: 'chatMessage'
   }
 }
diff --git a/src/services/window_utils/window_utils.js b/src/services/window_utils/window_utils.js
index faff6cb9..909088db 100644
--- a/src/services/window_utils/window_utils.js
+++ b/src/services/window_utils/window_utils.js
@@ -3,3 +3,8 @@ export const windowWidth = () =>
   window.innerWidth ||
   document.documentElement.clientWidth ||
   document.body.clientWidth
+
+export const windowHeight = () =>
+  window.innerHeight ||
+  document.documentElement.clientHeight ||
+  document.body.clientHeight
diff --git a/static/fontello.json b/static/fontello.json
index 5ef8544e..706800cd 100644
--- a/static/fontello.json
+++ b/static/fontello.json
@@ -399,6 +399,12 @@
       "css": "doc",
       "code": 59433,
       "src": "fontawesome"
+    },
+    {
+      "uid": "98d9c83c1ee7c2c25af784b518c522c5",
+      "css": "block",
+      "code": 59434,
+      "src": "fontawesome"
     }
   ]
 }
\ No newline at end of file
diff --git a/test/unit/specs/boot/routes.spec.js b/test/unit/specs/boot/routes.spec.js
index a415aeaf..3673256f 100644
--- a/test/unit/specs/boot/routes.spec.js
+++ b/test/unit/specs/boot/routes.spec.js
@@ -1,14 +1,22 @@
+import Vuex from 'vuex'
 import routes from 'src/boot/routes'
 import { createLocalVue } from '@vue/test-utils'
 import VueRouter from 'vue-router'
 
 const localVue = createLocalVue()
+localVue.use(Vuex)
 localVue.use(VueRouter)
 
+const store = new Vuex.Store({
+  state: {
+    instance: {}
+  }
+})
+
 describe('routes', () => {
   const router = new VueRouter({
     mode: 'abstract',
-    routes: routes({})
+    routes: routes(store)
   })
 
   it('root path', () => {
-- 
cgit v1.2.3-70-g09d2


From f05f832bff58034d78de9478ae2dbb06284dea75 Mon Sep 17 00:00:00 2001
From: eugenijm <eugenijm@protonmail.com>
Date: Sun, 21 Jun 2020 17:13:29 +0300
Subject: Address feedback

Use more specific css rules for the emoji dimensions in the chat list status preview.

Use more round em value for chat list item height.
Add global html overflow and height for smoother chat navigation in
the desktop Safari.

Use offsetHeight instad of a computed style when setting the window height on resize.

Remove margin-bottom from the last message to avoid occasional layout shift in the desktop Safari

Use break-word to prevent chat message text overflow

Resize and scroll the textarea when inserting a new line on ctrl+enter

Remove fade transition on route change

Ensure proper border radius at the bottom of the chat, remove unused border-radius

Prevent the chat header "jumping" on the avatar load.
---
 src/App.js                                         |  12 +--
 src/App.scss                                       |  39 +++++++-
 src/App.vue                                        |   4 +-
 src/components/chat/chat.js                        |  63 +++++++++----
 src/components/chat/chat.scss                      |  32 +++----
 src/components/chat/chat.vue                       |   2 +-
 src/components/chat/chat_layout.js                 | 100 ---------------------
 src/components/chat/chat_layout_utils.js           |   3 +-
 src/components/chat_avatar/chat_avatar.js          |  23 -----
 src/components/chat_avatar/chat_avatar.vue         |  53 -----------
 src/components/chat_list_item/chat_list_item.js    |   4 +-
 src/components/chat_list_item/chat_list_item.scss  |  48 +++++-----
 src/components/chat_list_item/chat_list_item.vue   |   2 +-
 src/components/chat_message/chat_message.js        |   4 +-
 src/components/chat_message/chat_message.scss      |  27 +++---
 src/components/chat_new/chat_new.js                |   5 +-
 src/components/chat_new/chat_new.scss              |   2 +-
 src/components/chat_title/chat_title.js            |  10 ++-
 src/components/chat_title/chat_title.vue           |  39 ++++----
 src/components/emoji_input/emoji_input.js          |  23 ++++-
 src/components/media_upload/media_upload.vue       |  13 ---
 .../post_status_form/post_status_form.js           |  19 ++--
 .../post_status_form/post_status_form.vue          |  20 ++++-
 src/services/chat_service/chat_service.js          |  19 ++--
 .../services/chat_service/chat_service.spec.js     |  89 ++++++++++++++++++
 25 files changed, 317 insertions(+), 338 deletions(-)
 delete mode 100644 src/components/chat/chat_layout.js
 delete mode 100644 src/components/chat_avatar/chat_avatar.js
 delete mode 100644 src/components/chat_avatar/chat_avatar.vue
 create mode 100644 test/unit/specs/services/chat_service/chat_service.spec.js

(limited to 'src/App.vue')

diff --git a/src/App.js b/src/App.js
index 84300e00..ded772fa 100644
--- a/src/App.js
+++ b/src/App.js
@@ -45,8 +45,7 @@ export default {
         window.CSS.supports('-moz-mask-size', 'contain') ||
         window.CSS.supports('-ms-mask-size', 'contain') ||
         window.CSS.supports('-o-mask-size', 'contain')
-    ),
-    transitionName: 'fade'
+    )
   }),
   created () {
     // Load the locale from the storage
@@ -135,14 +134,5 @@ export default {
       }
       this.$store.dispatch('setLayoutHeight', layoutHeight)
     }
-  },
-  watch: {
-    '$route' (to, from) {
-      if ((to.name === 'chat' && from.name === 'chats') || (to.name === 'chats' && from.name === 'chat')) {
-        this.transitionName = 'none'
-      } else {
-        this.transitionName = 'fade'
-      }
-    }
   }
 }
diff --git a/src/App.scss b/src/App.scss
index 29ce73a8..e2e2d079 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -47,6 +47,7 @@ html {
 }
 
 body {
+  overscroll-behavior-y: none;
   font-family: sans-serif;
   font-family: var(--interfaceFont, sans-serif);
   margin: 0;
@@ -56,7 +57,6 @@ body {
   overflow-x: hidden;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
-  overscroll-behavior: none;
 
   &.hidden {
     display: none;
@@ -320,7 +320,7 @@ option {
 
 i[class*=icon-] {
   color: $fallback--icon;
-  color: var(--icon, $fallback--icon)
+  color: var(--icon, $fallback--icon);
 }
 
 .btn-block {
@@ -942,3 +942,38 @@ nav {
   max-height: 1.3rem;
   line-height: 1.3rem;
 }
+
+.chat-layout {
+  // Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
+  overflow: hidden;
+  height: 100%;
+
+  // Ensures the fixed position of the mobile browser bars on scroll up / down events.
+  // Prevents the mobile browser bars from overlapping or hiding the message posting form.
+  @media all and (max-width: 800px) {
+    body {
+      height: 100%;
+    }
+
+    #app {
+      height: 100%;
+      overflow: hidden;
+      min-height: auto;
+    }
+
+    #app_bg_wrapper {
+      overflow: hidden;
+    }
+
+    .main {
+      overflow: hidden;
+      height: 100%;
+    }
+
+    #content {
+      padding-top: 0;
+      height: 100%;
+      overflow: visible;
+    }
+  }
+}
diff --git a/src/App.vue b/src/App.vue
index 5d429934..0276c6a6 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -113,9 +113,7 @@
             {{ $t("login.hint") }}
           </router-link>
         </div>
-        <transition :name="transitionName">
-          <router-view />
-        </transition>
+        <router-view />
       </div>
       <media-modal />
     </div>
diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js
index 6e23c20c..9c4e5b05 100644
--- a/src/components/chat/chat.js
+++ b/src/components/chat/chat.js
@@ -2,29 +2,26 @@ import _ from 'lodash'
 import { WSConnectionStatus } from '../../services/api/api.service.js'
 import { mapGetters, mapState } from 'vuex'
 import ChatMessage from '../chat_message/chat_message.vue'
-import ChatAvatar from '../chat_avatar/chat_avatar.vue'
 import PostStatusForm from '../post_status_form/post_status_form.vue'
 import ChatTitle from '../chat_title/chat_title.vue'
 import chatService from '../../services/chat_service/chat_service.js'
-import ChatLayout from './chat_layout.js'
 import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
 
 const BOTTOMED_OUT_OFFSET = 10
 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
+const SAFE_RESIZE_TIME_OFFSET = 100
 
 const Chat = {
   components: {
     ChatMessage,
     ChatTitle,
-    ChatAvatar,
     PostStatusForm
   },
-  mixins: [ChatLayout],
   data () {
     return {
       jumpToBottomButtonVisible: false,
       hoveredMessageChainId: undefined,
-      scrollPositionBeforeResize: {},
+      lastScrollPosition: {},
       scrollableContainerHeight: '100%',
       errorLoadingChat: false
     }
@@ -119,6 +116,7 @@ const Chat = {
     },
     onFilesDropped () {
       this.$nextTick(() => {
+        this.handleResize()
         this.updateScrollableContainerHeight()
       })
     },
@@ -129,13 +127,30 @@ const Chat = {
         }
       })
     },
-    handleLayoutChange () {
-      this.updateScrollableContainerHeight()
-      if (this.mobileLayout) {
-        this.setMobileChatLayout()
-      } else {
-        this.unsetMobileChatLayout()
+    setChatLayout () {
+      //   This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
+      //   This layout prevents empty spaces from being visible at the bottom
+      //   of the chat on iOS Safari (`safe-area-inset`) when
+      //   - the on-screen keyboard appears and the user starts typing
+      //   - the user selects the text inside the input area
+      //   - the user selects and deletes the text that is multiple lines long
+      //   TODO: unify the chat layout with the global layout.
+      let html = document.querySelector('html')
+      if (html) {
+        html.classList.add('chat-layout')
       }
+
+      this.$nextTick(() => {
+        this.updateScrollableContainerHeight()
+      })
+    },
+    unsetChatLayout () {
+      let html = document.querySelector('html')
+      if (html) {
+        html.classList.remove('chat-layout')
+      }
+    },
+    handleLayoutChange () {
       this.$nextTick(() => {
         this.updateScrollableContainerHeight()
         this.scrollDown()
@@ -149,15 +164,24 @@ const Chat = {
       this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
     },
     // Preserves the scroll position when OSK appears or the posting form changes its height.
-    handleResize (opts) {
+    handleResize (opts = {}) {
+      const { expand = false, delayed = false } = opts
+
+      if (delayed) {
+        setTimeout(() => {
+          this.handleResize({ ...opts, delayed: false })
+        }, SAFE_RESIZE_TIME_OFFSET)
+        return
+      }
+
       this.$nextTick(() => {
         this.updateScrollableContainerHeight()
 
-        const { offsetHeight = undefined } = this.scrollPositionBeforeResize
-        this.scrollPositionBeforeResize = getScrollPosition(this.$refs.scrollable)
+        const { offsetHeight = undefined } = this.lastScrollPosition
+        this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
 
-        const diff = this.scrollPositionBeforeResize.offsetHeight - offsetHeight
-        if (diff < 0 || (!this.bottomedOut() && opts && opts.expand)) {
+        const diff = this.lastScrollPosition.offsetHeight - offsetHeight
+        if (diff < 0 || (!this.bottomedOut() && expand)) {
           this.$nextTick(() => {
             this.updateScrollableContainerHeight()
             this.$refs.scrollable.scrollTo({
@@ -281,7 +305,12 @@ const Chat = {
         .then(data => {
           this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
             this.$nextTick(() => {
-              this.updateScrollableContainerHeight()
+              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 })
             })
           })
diff --git a/src/components/chat/chat.scss b/src/components/chat/chat.scss
index 13c52ea3..6ae7ebc9 100644
--- a/src/components/chat/chat.scss
+++ b/src/components/chat/chat.scss
@@ -3,14 +3,17 @@
   height: calc(100vh - 60px);
   width: 100%;
 
+  .chat-title {
+    // prevents chat header jumping on when the user avatar loads
+    height: 28px;
+  }
+
   .chat-view-inner {
     height: auto;
     width: 100%;
     overflow: visible;
     display: flex;
-    margin-top: 0.5em;
-    margin-left: 0.5em;
-    margin-right: 0.5em;
+    margin: 0.5em 0.5em 0 0.5em;
   }
 
   .chat-view-body {
@@ -19,23 +22,18 @@
     flex-direction: column;
     width: 100%;
     overflow: visible;
-    border-radius: none;
     min-height: 100%;
-    margin-left: 0;
-    margin-right: 0;
-    margin-bottom: 0em;
-    margin-top: 0em;
+    margin: 0 0 0 0;
     border-radius: 10px 10px 0 0;
     border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
 
     &::after {
-      border-radius: none;
-      box-shadow: none;
+      border-radius: 0;
     }
   }
 
   .scrollable-message-list {
-    padding: 0 10px;
+    padding: 0 0.8em;
     height: 100%;
     overflow-y: scroll;
     overflow-x: hidden;
@@ -45,7 +43,7 @@
 
   .footer {
     position: sticky;
-    bottom: 0px;
+    bottom: 0;
   }
 
   .chat-view-heading {
@@ -54,15 +52,19 @@
     top: 50px;
     display: flex;
     z-index: 2;
-    border-radius: none;
     position: sticky;
     display: flex;
     overflow: hidden;
   }
 
   .go-back-button {
-    margin-right: 1.2em;
     cursor: pointer;
+    margin-right: 1.4em;
+
+    i {
+      display: flex;
+      align-items: center;
+    }
   }
 
   .jump-to-bottom-button {
@@ -135,7 +137,7 @@
       overflow: hidden;
       height: 100%;
       margin: 0;
-      border-radius: 0 !important;
+      border-radius: 0;
     }
 
     .chat-view-heading {
diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue
index d8c91dbe..62b72e14 100644
--- a/src/components/chat/chat.vue
+++ b/src/components/chat/chat.vue
@@ -75,7 +75,7 @@
               :disable-polls="true"
               :disable-sensitivity-checkbox="true"
               :disable-submit="errorLoadingChat || !currentChat"
-              :request="sendMessage"
+              :post-handler="sendMessage"
               :submit-on-enter="!mobileLayout"
               :preserve-focus="!mobileLayout"
               :auto-focus="!mobileLayout"
diff --git a/src/components/chat/chat_layout.js b/src/components/chat/chat_layout.js
deleted file mode 100644
index 07ae3abf..00000000
--- a/src/components/chat/chat_layout.js
+++ /dev/null
@@ -1,100 +0,0 @@
-const ChatLayout = {
-  methods: {
-    setChatLayout () {
-      if (this.mobileLayout) {
-        this.setMobileChatLayout()
-      }
-    },
-    unsetChatLayout () {
-      this.unsetMobileChatLayout()
-    },
-    setMobileChatLayout () {
-      // This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
-      // This layout prevents empty spaces from being visible at the bottom
-      // of the chat on iOS Safari (`safe-area-inset`) when
-      // - the on-screen keyboard appears and the user starts typing
-      // - the user selects the text inside the input area
-      // - the user selects and deletes the text that is multiple lines long
-      // TODO: unify the chat layout with the global layout.
-
-      let html = document.querySelector('html')
-      if (html) {
-        html.style.overflow = 'hidden'
-        html.style.height = '100%'
-      }
-
-      let body = document.querySelector('body')
-      if (body) {
-        body.style.height = '100%'
-      }
-
-      let app = document.getElementById('app')
-      if (app) {
-        app.style.height = '100%'
-        app.style.overflow = 'hidden'
-        app.style.minHeight = 'auto'
-      }
-
-      let appBgWrapper = window.document.getElementById('app_bg_wrapper')
-      if (appBgWrapper) {
-        appBgWrapper.style.overflow = 'hidden'
-      }
-
-      let main = document.getElementsByClassName('main')[0]
-      if (main) {
-        main.style.overflow = 'hidden'
-        main.style.height = '100%'
-      }
-
-      let content = document.getElementById('content')
-      if (content) {
-        content.style.paddingTop = '0'
-        content.style.height = '100%'
-        content.style.overflow = 'visible'
-      }
-
-      this.$nextTick(() => {
-        this.updateScrollableContainerHeight()
-      })
-    },
-    unsetMobileChatLayout () {
-      let html = document.querySelector('html')
-      if (html) {
-        html.style.overflow = 'visible'
-        html.style.height = 'unset'
-      }
-
-      let body = document.querySelector('body')
-      if (body) {
-        body.style.height = 'unset'
-      }
-
-      let app = document.getElementById('app')
-      if (app) {
-        app.style.height = '100%'
-        app.style.overflow = 'visible'
-        app.style.minHeight = '100vh'
-      }
-
-      let appBgWrapper = document.getElementById('app_bg_wrapper')
-      if (appBgWrapper) {
-        appBgWrapper.style.overflow = 'visible'
-      }
-
-      let main = document.getElementsByClassName('main')[0]
-      if (main) {
-        main.style.overflow = 'visible'
-        main.style.height = 'unset'
-      }
-
-      let content = document.getElementById('content')
-      if (content) {
-        content.style.paddingTop = '60px'
-        content.style.height = 'unset'
-        content.style.overflow = 'unset'
-      }
-    }
-  }
-}
-
-export default ChatLayout
diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js
index f07ba2a1..609dc0c9 100644
--- a/src/components/chat/chat_layout_utils.js
+++ b/src/components/chat/chat_layout_utils.js
@@ -22,6 +22,5 @@ export const isBottomedOut = (el, offset = 0) => {
 
 // Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
 export const scrollableContainerHeight = (inner, header, footer) => {
-  const height = parseFloat(getComputedStyle(inner, null).height.replace('px', ''))
-  return height - header.clientHeight - footer.clientHeight
+  return inner.offsetHeight - header.clientHeight - footer.clientHeight
 }
diff --git a/src/components/chat_avatar/chat_avatar.js b/src/components/chat_avatar/chat_avatar.js
deleted file mode 100644
index 7b26e07c..00000000
--- a/src/components/chat_avatar/chat_avatar.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import StillImage from '../still-image/still-image.vue'
-import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
-import { mapState } from 'vuex'
-
-const ChatAvatar = {
-  props: ['user', 'width', 'height'],
-  components: {
-    StillImage
-  },
-  methods: {
-    getUserProfileLink (user) {
-      if (!user) { return }
-      return generateProfileLink(user.id, user.screen_name)
-    }
-  },
-  computed: {
-    ...mapState({
-      betterShadow: state => state.interface.browserSupport.cssFilter
-    })
-  }
-}
-
-export default ChatAvatar
diff --git a/src/components/chat_avatar/chat_avatar.vue b/src/components/chat_avatar/chat_avatar.vue
deleted file mode 100644
index f54a7151..00000000
--- a/src/components/chat_avatar/chat_avatar.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<template>
-  <router-link
-    :to="getUserProfileLink(user) || ''"
-  >
-    <StillImage
-      v-if="user"
-      :style="{ 'width': width, 'height': height }"
-      class="avatar chat-avatar single-user"
-      :alt="user.screen_name"
-      :title="user.screen_name"
-      :src="user.profile_image_url_original"
-      error-src="/images/avi.png"
-      :class="{ 'better-shadow': betterShadow }"
-    />
-    <div
-      v-else
-      class="avatar chat-avatar single-user"
-      :style="{ 'width': width, 'height': height }"
-    />
-  </router-link>
-</template>
-
-<script src="./chat_avatar.js"></script>
-<style lang="scss">
-@import '../../_variables.scss';
-
-.chat-avatar {
-  display: inline-block;
-  vertical-align: middle;
-
-  &.single-user {
-    border-radius: $fallback--avatarAltRadius;
-    border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
-  }
-
-  .avatar.still-image {
-    width: 48px;
-    height: 48px;
-
-    box-shadow: var(--avatarStatusShadow);
-    border-radius: 0;
-
-    &.better-shadow {
-      box-shadow: var(--avatarStatusShadowInset);
-      filter: var(--avatarStatusShadowFilter)
-    }
-
-    &.animated::before {
-      display: none;
-    }
-  }
-}
-</style>
diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js
index 1c27088c..b6b0519a 100644
--- a/src/components/chat_list_item/chat_list_item.js
+++ b/src/components/chat_list_item/chat_list_item.js
@@ -1,7 +1,7 @@
 import { mapState } from 'vuex'
 import StatusContent from '../status_content/status_content.vue'
 import fileType from 'src/services/file_type/file_type.service'
-import ChatAvatar from '../chat_avatar/chat_avatar.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
 import AvatarList from '../avatar_list/avatar_list.vue'
 import Timeago from '../timeago/timeago.vue'
 import ChatTitle from '../chat_title/chat_title.vue'
@@ -12,7 +12,7 @@ const ChatListItem = {
     'chat'
   ],
   components: {
-    ChatAvatar,
+    UserAvatar,
     AvatarList,
     Timeago,
     ChatTitle,
diff --git a/src/components/chat_list_item/chat_list_item.scss b/src/components/chat_list_item/chat_list_item.scss
index 12269f89..3ec59ea2 100644
--- a/src/components/chat_list_item/chat_list_item.scss
+++ b/src/components/chat_list_item/chat_list_item.scss
@@ -1,17 +1,8 @@
 .chat-list-item {
-  &:hover .animated.avatar {
-    canvas {
-      display: none;
-    }
-    img {
-      visibility: visible;
-    }
-  }
-
   display: flex;
   flex-direction: row;
   padding: 0.75em;
-  height: 4.85em;
+  height: 5em;
   overflow: hidden;
   box-sizing: border-box;
   cursor: pointer;
@@ -22,7 +13,7 @@
 
   &:hover {
     background-color: var(--selectedPost, $fallback--lightBg);
-    box-shadow: 0 0px 3px 1px rgba(0, 0, 0, 0.1);
+    box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
   }
 
   .chat-list-item-left {
@@ -47,12 +38,6 @@
     white-space: nowrap;
   }
 
-  .member-count {
-    color: $fallback--text;
-    color: var(--faintText, $fallback--text);
-    margin-right: 2px;
-  }
-
   .name-and-account-name {
     text-overflow: ellipsis;
     white-space: nowrap;
@@ -65,7 +50,7 @@
     overflow: hidden;
     white-space: nowrap;
     text-overflow: ellipsis;
-    margin: 0.35rem 0;
+    margin: 0.35em 0;
     height: 1.2em;
     line-height: 1.2em;
     color: $fallback--text;
@@ -78,17 +63,24 @@
     pointer-events: none;
   }
 
-  .unread-indicator-wrapper {
-    display: flex;
-    align-items: center;
-    margin-left: 10px;
+  &:hover .animated.avatar {
+    canvas {
+      display: none;
+    }
+    img {
+      visibility: visible;
+    }
   }
 
-  .unread-indicator {
-    border-radius: 100%;
-    height: 8px;
-    width: 8px;
-    background-color: $fallback--link;
-    background-color: var(--link, $fallback--link);
+  .avatar.still-image {
+    border-radius: $fallback--avatarAltRadius;
+    border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+  }
+
+  .status-body {
+    img.emoji {
+      width: 1.4em;
+      height: 1.4em;
+    }
   }
 }
diff --git a/src/components/chat_list_item/chat_list_item.vue b/src/components/chat_list_item/chat_list_item.vue
index 26ad581b..640426b8 100644
--- a/src/components/chat_list_item/chat_list_item.vue
+++ b/src/components/chat_list_item/chat_list_item.vue
@@ -4,7 +4,7 @@
     @click.capture.prevent="openChat"
   >
     <div class="chat-list-item-left">
-      <ChatAvatar
+      <UserAvatar
         :user="chat.account"
         height="48px"
         width="48px"
diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js
index aba95074..4d737e42 100644
--- a/src/components/chat_message/chat_message.js
+++ b/src/components/chat_message/chat_message.js
@@ -66,9 +66,9 @@ const ChatMessage = {
       }
 
       if (this.isCurrentUser) {
-        res.right = '5px'
+        res.right = '0.4rem'
       } else {
-        res.left = '5px'
+        res.left = '0.4rem'
       }
 
       return res
diff --git a/src/components/chat_message/chat_message.scss b/src/components/chat_message/chat_message.scss
index e4028537..9d7b7936 100644
--- a/src/components/chat_message/chat_message.scss
+++ b/src/components/chat_message/chat_message.scss
@@ -12,19 +12,15 @@
     }
   }
 
-  &:last-child {
-    margin-bottom: 16px;
-  }
-
   .chat-message-menu {
     transition: opacity 0.1s;
     opacity: 0;
     position: absolute;
-    top: -10px;
+    top: -0.8em;
 
     button {
-      padding-top: 3px;
-      padding-bottom: 3px;
+      padding-top: 0.2em;
+      padding-bottom: 0.2em;
     }
   }
 
@@ -41,21 +37,21 @@
   }
 
   .popover {
-    width: 12rem;
+    width: 12em;
   }
 
   .chat-message {
     display: flex;
-    padding-bottom: 7px;
+    padding-bottom: 0.5em;
   }
 
   .avatar-wrapper {
-    margin-right: 10px;
+    margin-right: 0.72em;
     width: 32px;
   }
 
   .link-preview, .attachments {
-    margin-bottom: 0.9em;
+    margin-bottom: 1em;
   }
 
   .chat-message-inner {
@@ -63,7 +59,7 @@
     flex-direction: column;
     align-items: flex-start;
     max-width: 80%;
-    min-width: 10rem;
+    min-width: 10em;
     width: 100%;
 
     &.with-media {
@@ -87,19 +83,18 @@
   }
 
   .created-at {
+    position: relative;
     float: right;
     font-size: 0.8em;
-    margin: -10px 0 -5px 4px;
+    margin: -1em 0 -0.5em 0;
     font-style: italic;
     opacity: 0.8;
   }
 
   .without-attachment {
     .status-content {
-      white-space: normal;
-
       &::after {
-        margin-right: 75px;
+        margin-right: 5.4em;
         content: " ";
         display: inline-block;
       }
diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js
index 0da681f7..d023efc0 100644
--- a/src/components/chat_new/chat_new.js
+++ b/src/components/chat_new/chat_new.js
@@ -1,4 +1,3 @@
-import { throttle } from 'lodash'
 import { mapState, mapGetters } from 'vuex'
 import BasicUserCard from '../basic_user_card/basic_user_card.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
@@ -54,7 +53,7 @@ const chatNew = {
     removeUser (userId) {
       this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
     },
-    search: throttle(function (query) {
+    search (query) {
       if (!query) {
         this.loading = false
         return
@@ -67,7 +66,7 @@ const chatNew = {
           this.loading = false
           this.userIds = data.accounts.map(a => a.id)
         })
-    })
+    }
   }
 }
 
diff --git a/src/components/chat_new/chat_new.scss b/src/components/chat_new/chat_new.scss
index 39216677..11305444 100644
--- a/src/components/chat_new/chat_new.scss
+++ b/src/components/chat_new/chat_new.scss
@@ -15,7 +15,7 @@
   }
 
   .member-list {
-    padding-bottom: 0.67rem;
+    padding-bottom: 0.7rem;
   }
 
   .basic-user-card:hover {
diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js
index 2723d5f5..e424bb1f 100644
--- a/src/components/chat_title/chat_title.js
+++ b/src/components/chat_title/chat_title.js
@@ -1,10 +1,11 @@
 import Vue from 'vue'
-import ChatAvatar from '../chat_avatar/chat_avatar.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+import UserAvatar from '../user_avatar/user_avatar.vue'
 
 export default Vue.component('chat-title', {
   name: 'ChatTitle',
   components: {
-    ChatAvatar
+    UserAvatar
   },
   props: [
     'user', 'withAvatar'
@@ -16,5 +17,10 @@ export default Vue.component('chat-title', {
     htmlTitle () {
       return this.user ? this.user.name_html : ''
     }
+  },
+  methods: {
+    getUserProfileLink (user) {
+      return generateProfileLink(user.id, user.screen_name)
+    }
   }
 })
diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue
index fd42d125..cfd1e6d1 100644
--- a/src/components/chat_title/chat_title.vue
+++ b/src/components/chat_title/chat_title.vue
@@ -4,16 +4,16 @@
     class="chat-title"
     :title="title"
   >
-    <ChatAvatar
-      v-if="withAvatar"
-      :user="user"
-      width="23px"
-      height="23px"
-    />
-    <span
-      v-if="withAvatar"
-      style="margin-right: 0.5em"
-    />
+    <router-link
+      v-if="withAvatar && user"
+      :to="getUserProfileLink(user)"
+    >
+      <UserAvatar
+        :user="user"
+        width="23px"
+        height="23px"
+      />
+    </router-link>
     <span
       class="username"
       v-html="htmlTitle"
@@ -32,11 +32,7 @@
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
-
-  a {
-    display: flex;
-    align-items: center;
-  }
+  align-items: center;
 
   .username {
     max-width: 100%;
@@ -52,5 +48,18 @@
       object-fit: contain
     }
   }
+
+  .still-image.avatar {
+    width: 23px;
+    height: 23px;
+    margin-right: 0.5em;
+
+    border-radius: $fallback--avatarAltRadius;
+    border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+
+    &.animated::before {
+      display: none;
+    }
+  }
 }
 </style>
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index a27da090..f0123447 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -88,6 +88,11 @@ const EmojiInput = {
       required: false,
       type: String, // 'auto', 'top', 'bottom'
       default: 'auto'
+    },
+    newlineOnCtrlEnter: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   data () {
@@ -204,7 +209,7 @@ const EmojiInput = {
       this.$emit('input', newValue)
       this.caret = 0
     },
-    insert ({ insertion, keepOpen }) {
+    insert ({ insertion, keepOpen, surroundingSpace = true }) {
       const before = this.value.substring(0, this.caret) || ''
       const after = this.value.substring(this.caret) || ''
 
@@ -223,8 +228,8 @@ const EmojiInput = {
        * them, masto seem to be rendering :emoji::emoji: correctly now so why not
        */
       const isSpaceRegex = /\s/
-      const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : ''
-      const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : ''
+      const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
+      const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
 
       const newValue = [
         before,
@@ -381,6 +386,18 @@ const EmojiInput = {
     },
     onKeyDown (e) {
       const { ctrlKey, shiftKey, key } = e
+      if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
+        this.insert({ insertion: '\n', surroundingSpace: false })
+        // Ensure only one new line is added on macos
+        e.stopPropagation()
+        e.preventDefault()
+
+        // Scroll the input element to the position of the cursor
+        this.$nextTick(() => {
+          this.input.elm.blur()
+          this.input.elm.focus()
+        })
+      }
       // Disable suggestions hotkeys if suggestions are hidden
       if (!this.temporarilyHideSuggestions) {
         if (key === 'Tab') {
diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue
index d719eae1..c8865d77 100644
--- a/src/components/media_upload/media_upload.vue
+++ b/src/components/media_upload/media_upload.vue
@@ -33,19 +33,6 @@
 @import '../../_variables.scss';
 
 .media-upload {
-  &.disabled {
-    .new-icon {
-      cursor: not-allowed;
-    }
-
-    &:hover {
-      i, label {
-        color: $fallback--faint;
-        color: var(--faint, $fallback--faint);
-      }
-    }
-  }
-
   .label {
     display: inline-block;
   }
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 90d0fa81..59e4dc26 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -43,7 +43,7 @@ const PostStatusForm = {
     'disableSubmit',
     'placeholder',
     'maxHeight',
-    'request',
+    'postHandler',
     'preserveFocus',
     'autoFocus',
     'fileLimit',
@@ -221,10 +221,6 @@ const PostStatusForm = {
         event.stopPropagation()
         event.preventDefault()
       }
-      if (opts.control && this.submitOnEnter) {
-        newStatus.status = `${newStatus.status}\n`
-        return
-      }
 
       if (this.emptyStatus) {
         this.error = this.$t('post_status.empty_status_error')
@@ -259,9 +255,9 @@ const PostStatusForm = {
         poll
       }
 
-      const request = this.request ? this.request : statusPoster.postStatus
+      const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
 
-      request(postingOptions).then((data) => {
+      postHandler(postingOptions).then((data) => {
         if (!data.error) {
           this.newStatus = {
             status: '',
@@ -345,11 +341,7 @@ const PostStatusForm = {
     },
     addMediaFile (fileInfo) {
       this.newStatus.files.push(fileInfo)
-
-      // TODO: use fixed dimensions instead so relying on timeout
-      setTimeout(() => {
-        this.$emit('resize')
-      }, 150)
+      this.$emit('resize', { delayed: true })
     },
     removeMediaFile (fileInfo) {
       let index = this.newStatus.files.indexOf(fileInfo)
@@ -364,6 +356,7 @@ const PostStatusForm = {
       this.uploadingFiles = true
     },
     finishedUploadingFiles () {
+      this.$emit('resize')
       this.uploadingFiles = false
     },
     type (fileInfo) {
@@ -417,7 +410,7 @@ const PostStatusForm = {
       // Reset to default height for empty form, nothing else to do here.
       if (target.value === '') {
         target.style.height = null
-        this.$emit('resize', null)
+        this.$emit('resize')
         this.$refs['emoji-input'].resize()
         return
       }
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index d8df68d6..7454958b 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -131,6 +131,7 @@
           class="form-control main-input"
           enable-emoji-picker
           hide-emoji-button
+          :newline-on-ctrl-enter="submitOnEnter"
           enable-sticker-picker
           @input="onEmojiInputInput"
           @sticker-uploaded="addMediaFile"
@@ -146,8 +147,8 @@
             class="form-post-body"
             :class="{ 'scrollable-form': !!maxHeight }"
             @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
-            @keydown.meta.enter="postStatus($event, newStatus, { control: true })"
-            @keydown.ctrl.enter="postStatus($event, newStatus)"
+            @keydown.meta.enter="postStatus($event, newStatus)"
+            @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
             @input="resize"
             @compositionupdate="resize"
             @paste="paste"
@@ -435,6 +436,19 @@
         color: var(--lightText, $fallback--lightText);
       }
     }
+
+    &.disabled {
+      i {
+        cursor: not-allowed;
+        color: $fallback--icon;
+        color: var(--btnDisabledText, $fallback--icon);
+
+        &:hover {
+          color: $fallback--icon;
+          color: var(--btnDisabledText, $fallback--icon);
+        }
+      }
+    }
   }
 
   // Order is not necessary but a good indicator
@@ -628,7 +642,7 @@
 }
 
 // todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
-img.media-upload {
+img.media-upload, .media-upload-container > video {
   line-height: 0;
   max-height: 200px;
   max-width: 100%;
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
index 763a7607..b60a889b 100644
--- a/src/services/chat_service/chat_service.js
+++ b/src/services/chat_service/chat_service.js
@@ -31,7 +31,8 @@ const deleteMessage = (storage, messageId) => {
   }
 
   if (storage.minId === messageId) {
-    storage.minId = _.minBy(storage.messages, 'id')
+    const firstMessage = _.minBy(storage.messages, 'id')
+    storage.minId = firstMessage.id
   }
 }
 
@@ -73,12 +74,12 @@ const getView = (storage) => {
 
   const result = []
   const messages = _.sortBy(storage.messages, ['id', 'desc'])
-  const firstMessages = messages[0]
-  let prev = messages[messages.length - 1]
+  const firstMessage = messages[0]
+  let previousMessage = messages[messages.length - 1]
   let currentMessageChainId
 
-  if (firstMessages) {
-    const date = new Date(firstMessages.created_at)
+  if (firstMessage) {
+    const date = new Date(firstMessage.created_at)
     date.setHours(0, 0, 0, 0)
     result.push({
       type: 'date',
@@ -97,14 +98,14 @@ const getView = (storage) => {
     date.setHours(0, 0, 0, 0)
 
     // insert date separator and start a new message chain
-    if (prev && prev.date < date) {
+    if (previousMessage && previousMessage.date < date) {
       result.push({
         type: 'date',
         date,
         id: date.getTime().toString()
       })
 
-      prev['isTail'] = true
+      previousMessage['isTail'] = true
       currentMessageChainId = undefined
       afterDate = true
     }
@@ -124,14 +125,14 @@ const getView = (storage) => {
     }
 
     // start a new message chain
-    if ((prev && prev.data && prev.data.account_id) !== message.account_id || afterDate) {
+    if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) {
       currentMessageChainId = _.uniqueId()
       object['isHead'] = true
       object['messageChainId'] = currentMessageChainId
     }
 
     result.push(object)
-    prev = object
+    previousMessage = object
     afterDate = false
   }
 
diff --git a/test/unit/specs/services/chat_service/chat_service.spec.js b/test/unit/specs/services/chat_service/chat_service.spec.js
new file mode 100644
index 00000000..4e8e566b
--- /dev/null
+++ b/test/unit/specs/services/chat_service/chat_service.spec.js
@@ -0,0 +1,89 @@
+import chatService from '../../../../../src/services/chat_service/chat_service.js'
+
+const message1 = {
+  id: '9wLkdcmQXD21Oy8lEX',
+  created_at: (new Date('2020-06-22T18:45:53.000Z'))
+}
+
+const message2 = {
+  id: '9wLkdp6ihaOVdNj8Wu',
+  account_id: '9vmRb29zLQReckr5ay',
+  created_at: (new Date('2020-06-22T18:45:56.000Z'))
+}
+
+const message3 = {
+  id: '9wLke9zL4Dy4OZR2RM',
+  account_id: '9vmRb29zLQReckr5ay',
+  created_at: (new Date('2020-07-22T18:45:59.000Z'))
+}
+
+// TODO: only
+describe.only('chatService', () => {
+  describe('.add', () => {
+    it("Doesn't add duplicates", () => {
+      const chat = chatService.empty()
+      chatService.add(chat, { messages: [ message1 ] })
+      chatService.add(chat, { messages: [ message1 ] })
+      expect(chat.messages.length).to.eql(1)
+
+      chatService.add(chat, { messages: [ message2 ] })
+      expect(chat.messages.length).to.eql(2)
+    })
+
+    it('Updates minId and lastMessage and newMessageCount', () => {
+      const chat = chatService.empty()
+
+      chatService.add(chat, { messages: [ message1 ] })
+      expect(chat.lastMessage.id).to.eql(message1.id)
+      expect(chat.minId).to.eql(message1.id)
+      expect(chat.newMessageCount).to.eql(1)
+
+      chatService.add(chat, { messages: [ message2 ] })
+      expect(chat.lastMessage.id).to.eql(message2.id)
+      expect(chat.minId).to.eql(message1.id)
+      expect(chat.newMessageCount).to.eql(2)
+
+      chatService.resetNewMessageCount(chat)
+      expect(chat.newMessageCount).to.eql(0)
+
+      const createdAt = new Date()
+      createdAt.setSeconds(createdAt.getSeconds() + 10)
+      chatService.add(chat, { messages: [ { message3, created_at: createdAt } ] })
+      expect(chat.newMessageCount).to.eql(1)
+    })
+  })
+
+  describe('.delete', () => {
+    it('Updates minId and lastMessage', () => {
+      const chat = chatService.empty()
+
+      chatService.add(chat, { messages: [ message1 ] })
+      chatService.add(chat, { messages: [ message2 ] })
+      chatService.add(chat, { messages: [ message3 ] })
+
+      expect(chat.lastMessage.id).to.eql(message3.id)
+      expect(chat.minId).to.eql(message1.id)
+
+      chatService.deleteMessage(chat, message3.id)
+      expect(chat.lastMessage.id).to.eql(message2.id)
+      expect(chat.minId).to.eql(message1.id)
+
+      chatService.deleteMessage(chat, message1.id)
+      expect(chat.lastMessage.id).to.eql(message2.id)
+      expect(chat.minId).to.eql(message2.id)
+    })
+  })
+
+  describe('.getView', () => {
+    it('Inserts date separators', () => {
+      const chat = chatService.empty()
+
+      chatService.add(chat, { messages: [ message1 ] })
+      chatService.add(chat, { messages: [ message2 ] })
+      chatService.add(chat, { messages: [ message3 ] })
+
+      const view = chatService.getView(chat)
+      expect(view.map(i => i.type)).to.eql(['date', 'message', 'message', 'date', 'message'])
+    })
+  })
+})
-- 
cgit v1.2.3-70-g09d2

