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-window {
-  overflow-y: auto;
-  overflow-x: hidden;
-  max-height: 20em;
-}
-
-.chat-window-container {
-  height: 100%;
-}
-
-.chat-message {
-  display: flex;
-  padding: 0.2em 0.5em
-}
-
-.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-input {
-  display: flex;
-  textarea {
-    flex: 1;
-    margin: 0.6em;
-    min-height: 3.5em;
-    resize: none;
-  }
-}
-
 .chat-panel {
-  .title {
+  .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-message {
     display: flex;
-    justify-content: space-between;
+    padding: 0.2em 0.5em
+  }
+
+  .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-input {
+    display: flex;
+    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', () => {