Building a chat app for the browser is not a very straightforward task. It requires you to learn some realtime technology (like socket.io) and how to manage connections between participants. Not only that, but also you need to make sure your connections are reliable so your app doesn't miss a message sent between users.

These are just a few challenges that you can face when implementing your own chat system. But luckily for us, CometChat comes to save us the headache. CometChat doesn't only solve these issues, it also provides us with more cool features like chat groups, user roles, and friends list.

In this tutorial, I'll teach you how to build your own one-on-one chat app in Vue from scratch. After you finish this tutorial, you should have something like this:

You can get the tutorial's source code from GitHub. The instructions on how to run it are included there. It's a good idea to run the demo before diving in to make sure you have the full picture of what we're going to build here.

Creating a new Vue project

Let's create a new Vue project using Vue CLI. If you don't have it installed on your machine yet, install it via npm like this:

npm install -g @vue/cli

To create a new project, run the following command:

vue create vue-one-on-one-chat-app

This will ask you to pick a preset. Let's choose typical-spa for this tutorial.

After it's finished, go to the project's directory and run it using: npm run serve.

Now if you open the browser to http://localhost:8080, you should see this:

Great! Now we have a running Vue app. Now let's create and style all necessary pages, and then integrate CometChat into it.

Setting up the routes

Let's start with the router. Open src/router.js and replace everything there with this:

import Vue from 'vue'
import Router from 'vue-router'
import Login from './views/Login.vue'
import Chat from './views/Chat.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'home',
      redirect: 'login'
    },
    {
      path: '/login',
      name: 'login',
      component: Login
    },
    {
      path: '/chat',
      name: 'chat',
      component: Chat
    }
  ]
})

We'll have only two pages in this app, login and chat. The chat page is where the bulk of our work would be. The login page is just responsible for authenticating the user. And since the user should be authenticated before using the app, we're redirecting the homepage (path: '/') to the login page.

You can also see how we used the history mode in the router to get rid of the hash in the url.

We've told the router about our two pages, but we didn't create them yet. So remove any existing components from src/views and create Login.vue and Chat.vue.

Preparing the pages

Before we start implementing our pages, we need to modify our root component, App.vue, then load the fonts and images that we'll be using in this app.

Open src/App.vue and replace everything with this:

<template>
  <div id="app">
    <router-view/>
  </div>
</template>
<style lang="stylus">
html, body
  padding: 0
  margin: 0
  height: 100vh

*
  box-sizing: border-box

#app
  -webkit-font-smoothing: antialiased
  -moz-osx-font-smoothing: grayscale
  height: 100%
</style>

Nothing important to explain here — we just removed the *#nav* element and modified the CSS.

We'll use Roboto and ‌Abril Fatface fonts in this app. To load them, open public/index.html, and add this in the <head> section:

<link href="https://fonts.googleapis.com/css?family=Abril+Fatface|Roboto:400,500&display=swap" rel="stylesheet">

Finally, let's get the needed images from the demo's repo and add them to src/assets.

Implementing Login.vue

Let's first add the HTML code for this component:

<template>
  <div class="login-page">
    <div class="login-container">
      <div class="main">
        <h1 class="title">
          Welcome Back
        </h1>
        <p class="description">
          To access this demo, you can use one of the following four users: <strong>superhero1</strong>, <strong>superhero2</strong>, <strong>superhero3</strong>, or <strong>superhero4</strong>.
        </p>
        <form
          class="login-form"
          @submit.prevent="login"
        >
          <div class="username-field">
            <label class="label">
              Username
            </label>
            <div class="input-group">
              <input
                class="username-input"
                v-model="username"
                :disabled="loggingIn"
                type="text"
                required="required"
              >
              <svg
                style="width:24px;height:24px"
                viewBox="0 0 24 24"
              >
                <path
                  fill="#BDCCD7"
                  d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z"
                />
              </svg>
            </div>
            <button
              class="login-button"
              :disabled="loggingIn"
            >
              <template v-if="!loggingIn">
                LOG IN
              </template>
              <template v-else>
                LOGGING IN...
              </template>
            </button>
          </div>
        </form>
      </div>
      <img
        class="illustration"
        src="../assets/login-illustration.svg"
      >
    </div>
  </div>
</template>

Three things to notice here:

  1. v-model="username" — we're keeping track of the entered username in the username data property, which we'll create later.
  2. @submit.prevent="login" — when the user submits the login form, we should call a method named login(), which we don't have yet.
  3. When the user is currently logging in, we should disable the username input and the login button. Note how we do that in the code above using the loggingIn flag (which we'll create in a bit). So we're using :disabled="loggingIn" on the input and the button. We're also updating the button's text to “LOGGING IN...” when loggingIn is true.

Now let's write the JS code for this component:

<script>
export default {
  data () {
    return {
      username: '',
      loggingIn: false
    }
  },

  methods: {
    login () {}
  }
}
</script>

We've defined our data properties, username and loggingIn. We've also added the login method, which we'll implement later.

Lastly, let's add the CSS code:

<style lang="stylus" scoped>
.login-page
  background: url('../assets/login-background.svg')
  background-repeat: no-repeat
  background-size: cover
  background-position: center center
  height: 100%
  display: flex
  justify-content: center
  align-items: center

.login-container
  background: #FFFFFE
  max-width: 700px
  width: calc(100% - 20px)
  padding: 60px 50px
  border-radius: 6px
  box-shadow: 0px 2px 38px rgba(45, 49, 63, 0.397236)
  display: flex
  justify-content: space-between

.main
  flex: 1
  text-align: left
  padding-right: 30px

.title
  font-family: 'Abril Fatface', cursive
  font-weight: normal
  color: #2D313F
  font-size: 26px
  line-height: 35px

.description
  font-family: 'Roboto', sans-serif
  color: #444
  line-height: 1.4

.description strong
  font-weight: 500

.login-button
  font-family: 'Roboto', sans-serif
  font-weight: bold
  font-size: 13px
  line-height: 15px
  display: flex
  align-items: center
  text-align: center
  justify-content: center
  text-transform: uppercase
  color: #FFFFFF
  background: #1B47DB
  box-shadow: 0px 5px 13px rgba(27, 71, 219, 0.303349)
  border-radius: 4.09091px
  border: none
  width: 100%
  padding: 12px
  margin-top: 50px
  outline: none
  cursor: pointer

.login-button:hover
  background: lighten(#1B47DB, 5%)

.login-button:disabled
  background: lighten(#1B47DB, 20%)
  cursor: default

.label
  font-family: 'Roboto', sans-serif
  font-weight: bold
  font-size: 13.0909px
  line-height: 15px
  color: #BDCCD7

.username-input
  font-family: 'Roboto', sans-serif
  font-weight: normal
  font-size: 13px
  line-height: 15px
  color: #2D313F
  mix-blend-mode: normal
  opacity: 0.8
  border: none
  flex: 1
  outline: none

.username-input:disabled
  color: #888

.input-group
  border-bottom: 1px solid #BDCCD7
  display: flex
  justify-content: space-between
  align-items: center
  padding: 3px 0

@media (max-width: 605px)
  .illustration
    display: none
  .main
    padding: 0
</style>

If you check the login page in the browser, you should see this:

Preparing Chat.vue

Here's how this page would look like in the end:

Instead of writing all the code into this single component, let's break the content of this component into multiple components. We'll have three components in this page: Navbar.vue, ChatSidebar.vue, and ChatMain.vue.

Let's create these components into src/components and put the following code into Chat.vue:

<template>
  <div class="chat-page">
    <navbar/>
    <div class="chat-container">
      <chat-sidebar/>
      <chat-main/>
    </div>
  </div>
</template>

<script>
import Navbar from '@/components/Navbar'
import ChatSidebar from '@/components/ChatSidebar'
import ChatMain from '@/components/ChatMain'

export default {
  components: {
    Navbar,
    ChatSidebar,
    ChatMain
  }
}
</script>

<style lang="stylus" scoped>
.chat-page
  background: url('../assets/chat-background.svg')
  background-repeat: no-repeat
  background-size: cover
  background-position: top center
  min-height: 100%

.chat-container
  background: #FFFFFE
  box-shadow: 0px 2px 36px rgba(45, 49, 63, 0.357436)
  border-radius: 6px
  margin: 50px auto 50px
  max-width: 800px
  width: calc(100% - 20px)
  height: 500px
  min-height: 500px
  display: flex
  justify-content: space-between

.chat-sidebar
  width: 240px
.chat-main
  flex: 1

@media (max-width: 635px)
  .chat-sidebar
    width: 90px
</style>

This is just the start for Chat.vue. We'll keep updating it as we're working on the app.

Implementing Navbar.vue

Open src/components/Navbar.vue, and add this to the HTML section:

<template>
  <div class="navbar">
    <div class="left">
      <img
        class="logo"
        src="../assets/logo.svg"
      >
      <span class="title">
        Chat
      </span>
    </div>
    <div class="right">
      <span class="welcome-message">
        Welcome <strong class="user-name">Superhero</strong>
      </span>
      <spinner
        v-if="loggingOut"
        :size="20"
      />
      <img
        v-else
        class="avatar"
        src="https://ui-avatars.com/api/?size=128&name=Superhero"
        @click="logout"
      >
    </div>
  </div>
</template>

We're here hardcoding the user name and the avatar. Once we have the real data available, we'll get back to this file and update it.

As you can see, we should log out the user if he or she clicked on the avatar — we haven't defined this method yet, but we'll do that later. When the user is currently logging out, we should display a loading indicator, <spinner>, in place of the avatar (that's what loggingOut flag is for).

Now let's add the JS code below the HTML section:

<script>
import Spinner from '@/components/Spinner'

export default {
  components: { Spinner },
  data () {
    return {
      loggingOut: false
    }
  },

  methods: {
    logout () {}
  }
}
</script>

So we've defined the loggingOut flag and the logout method. Note that we'll implement the logout method later because we need to connect to CometChat for that.

Lastly, let's add the CSS code:

<style lang="stylus" scoped>
.navbar
  height: 50px
  width: 100%
  background: #FFFFFE
  padding: 0 20px
  display: flex
  justify-content: space-between
  align-items: center
  font-family: 'Roboto', sans-serif

.left, .right
  display: flex
  justify-content: center
  align-items: center

.logo
  margin-right: 10px

.title
  color: #2D313F
  font-family: 'Abril Fatface', cursive
  font-weight: normal
  font-size: 22px
  line-height: 30px

.welcome-message
  margin-right: 10px

.user-name
  font-weight: 500

.avatar
  width: 30px
  height: 30px
  border-radius: 50%
  overflow: hidden
  cursor: pointer
</style>

Before moving to the next section, let's add the Spinner component. So in src/components create a new file named Spinner.vue, and put the following into it:

<template>
  <div class="spinner">
    <!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
    <svg :width="size" :height="size" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg" stroke="#003">
      <g fill="none" fill-rule="evenodd">
        <g transform="translate(1 1)" stroke-width="2">
          <circle stroke-opacity=".5" cx="18" cy="18" r="18"/>
          <path d="M36 18c0-9.94-8.06-18-18-18">
            <animateTransform
            attributeName="transform"
            type="rotate"
            from="0 18 18"
            to="360 18 18"
            dur="1s"
            repeatCount="indefinite"/>
          </path>
        </g>
      </g>
    </svg>
  </div>
</template>

<script>
export default {
  props: {
    size: {
      type: Number,
      default: 60
    }
  }
}
</script>

<style scoped>
.spinner {
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

After this, your http://localhost:8080/chat page should look like this:

Implementing ChatMain.vue

As always, let's start with the HTML code.

<template>
  <div class="chat-main">
    <div class="header">
      Active Contact Name
    </div>
    <!-- Loading Messages State -->
    <div
      v-if="loadingMessages"
      class="loading-messages"
    >
      <spinner/>
      <span class="loading-title">
        Loading Messages...
      </span>
    </div>
    <!-- End of Loading Messages State -->

    <!-- Empty State -->
    <div
      v-else-if="messages.length === 0"
      class="empty-state"
    >
      <img
        class="empty-state-image"
        src="../assets/empty-state.svg"
      >
      <h2 class="empty-state-title">
        No new message?
      </h2>
      <span class="empty-state-description">
        Send your first message below.
      </span>
    </div>
    <!-- End of Empty State -->

    <!-- Has Messages State -->
    <chat-messages
      v-else
      ref="messagesContainer"
      :messages="messages"
    />
    <!-- End of Has Messages State -->

    <!-- Message Input -->
    <form
      class="chat-input-form"
      @submit.prevent="sendMessage"
    >
      <input
        class="chat-input"
        v-model="messageText"
        type="text"
        placeholder="Type something"
        required="required"
        :disabled="sendingMessage"
      >
      <spinner
        v-if="sendingMessage"
        class="sending-message-spinner"
        :size="20"
      />
    </form>
    <!-- End of Message Input -->
  </div>
</template>

The main area of the chat can have one of the following three states:

  1. Loading messages state: when fetching previous messages between participants, we should show a loading view that tells users about that.
  2. Empty state: when messages are fetched but nothing was returned from the server, then we should display a message that tells the user that there are no messages between you the active contact yet.
  3. Has messages state: in this state, we display any existing messages between the logged-in user and the selected contact. We're displaying the messages in another component called ChatMessages, which we didn't create yet. But since we're at it, let's create src/components/ChatMessage.vue.

Below the messages section, we have the messages input area. We're keeping track of the currently entered text message inside messageText data property. If the user pressed the Enter key, we should send the message by calling the sendMessage method. And as the message is being sent, we should disable the text input and show a loading indicator. We can know that the message is being sent from the sendingMessage data property, which we'll create next.

Now let's add the JS and CSS code for this component:

<script>
import ChatMessages from '@/components/ChatMessages'
import Spinner from '@/components/Spinner'

export default {
  components: {
    ChatMessages,
    Spinner
  },

  data () {
    return {
      loadingMessages: false,
      messages: [],
      messageText: '',
      sendingMessage: false
    }
  },

  methods: {
    sendMessage () {}
  }
}
</script>

<style lang="stylus" scoped>
.chat-main
  border-left: 1px solid #BDCCD7
  background: #F8F9FB
  border-radius: 0 6px 6px 0
  display: flex
  justify-content: space-between
  flex-direction: column
  max-height: 100%

.header
  background: #FFFFFE
  font-family: 'Roboto', sans-serif
  font-weight: normal
  font-size: 15px
  line-height: 18px
  color: #2D313F
  padding: 20px 25px
  border-bottom: 1px solid #BDCCD7
  border-radius: 0 6px 0 0

.empty-state
  display: flex
  justify-content: center
  align-items: center
  flex-direction: column
  margin-top: 50px

.empty-state-title
  font-family: 'Abril Fatface', cursive
  font-weight: normal
  font-size: 26px
  line-height: 35px
  text-align: center
  color: #1B47DB
  margin: 15px 0

.empty-state-description
  font-family: 'Roboto', sans-serif
  font-weight: normal
  font-size: 16px
  line-height: 22px
  text-align: center
  color: #2D313F
  mix-blend-mode: normal
  opacity: 0.8

.chat-input-form
  background: #FFFFFF
  display: flex
  box-shadow: 0px -1px 0px rgba(189, 204, 215, 0.544362)

.sending-message-spinner
  padding: 15px

.chat-input
  width: 100%
  background: #FFFFFF
  border: none
  outline: none
  resize: none
  border-radius: 0 0 6px 0
  font-family: 'Roboto', sans-serif
  font-weight: normal
  font-size: 16px
  color: #333
  padding: 20px

.chat-input::placeholder
  color: #BBBEBE

.loading-messages
  display: flex
  flex-direction: column
  align-items: center
  justify-content: center

.loading-title
  font-family: 'Roboto', sans-serif
  margin-top: 20px
  font-size: 20px
</style>

Here's what you should see in your browser now:

Installing and initializing CometChat

Now we're ready to start integrating CometChat into our app. To do that, we first have to create a new pro account in CometChat. You can create it from app.cometchat.com/#/register.

For each app you build, you have to create a new app in CometChat. You can do that easily from the dashboard. Just enter the app name in the "Add New App" box, and hit the "+" button. In our example let's name it "one-on-one chat app".

For this tutorial, we need to know two pieces of information from CometChat: the App ID and the API Key.

You can get the App Id from the dashboard below the name of the app you've just created.

And for the API Key, click on "Explore" in the app box. Then go to the "API Keys" tab from the left sidebar. Then copy the auth-only api key.

Instead of embedding those keys directly into our code, let’s store them as environment variables so we can easily use different ones for different modes (like development, test, and production). To do so, create a new .env.local in your project's root directory. Then add this to it:

VUE_APP_COMETCHAT_APP_ID=YOUR_APP_ID
VUE_APP_COMETCHAT_API_KEY=YOUR_API_KEY

Then just replace the values with yours.

Before we can use CometChat, we have to install it first. So, run this from your terminal:

npm install --save @cometchat-pro/chat

We won't be able to use CometChat until we initialize it with our App ID. Since this is the first thing we need to do in the app, let's initialize it from src/main.js file.

So, open your main.js file, and import CometChat at the top, like this:

import { CometChat } from '@cometchat-pro/chat'

Then call CometChat.init() below that.

const appId = process.env.VUE_APP_COMETCHAT_APP_ID
CometChat.init(appId)
  .then(() => {
    console.log('CometChat was initialized successfully!')
  })
  .catch(() => {
    console.log('An error occured while initializing CometChat')
  })

Note how we fetched our CometChat appId from our env variables using process.env.VUE_APP_COMETCHAT_APP_ID.

Authenticating the user

Before a user can use CometChat, he or she must be logged in using CometChat.login(username, apiKey) first.

It's important to note that logging in to CometChat is different from logging in to your app in general. CometChat doesn't handle user management. This authentication is only for allowing the user to access CometChat.

So in a real world example, we should authenticate the user as we would normally do (check if the email and the hashed password matches a record in the database, for example), then after that, we can log in that user to CometChat programmatically through CometChat.login.

As this is a demo app, we're going to make things simpler and use CometChat authentication as our app authentication as well.

Let's implement authentication by filling our login() method in Login.vue like this:

login () {
  const apiKey = process.env.VUE_APP_COMETCHAT_API_KEY
  this.loggingIn = true
  CometChat.login(this.username, apiKey)
    .then(() => {
      this.loggingIn = false
      this.$router.push({ name: 'chat' })
    })
    .catch(error => {
      this.loggingIn = false
      console.log('error', error)
    })
}

So we're using CometChat.login with the current value of the username input and our auth-only apiKey, which we stored in .env.local.

Now if the user was logged in successfully, we redirect him or her to the chat page using this.$router.push({ name: 'chat' }).

Note that you can test logging in using one of the following test users that CometChat provides us with: superhero1, superhero2, superhero3, superhero4, or superhero5.

Fetching the logged-in user's data

After the user is logged in, we should fetch his or her data so we can use them throughout the application.

We can do this using CometChat.getLoggedinUser. So, let's load the user's data when the chat page is created, which will be inside the created hook function in this case.

Open Chat.vue, and define the created function like this:

created () {
  this.loadCurrentUser()
}

This means we have to implement the loadCurrentUser method.

methods: {
  loadCurrentUser () {
    CometChat.getLoggedinUser()
      .then(user => {
        this.loggedIn = true
        Vue.prototype.$currentUser = {
          uid: user.uid,
          name: user.name,
          avatar: user.avatar
        }
      })
      .catch(error => {
        this.$router.push({ name: 'login' })
        console.log('error', error)
      })
  }
}

An important thing to note here is how we're storing the user's data. Instead of storing them inside a data property in Chat.vue, we are storing them inside all Vue instances. This will allow us to access the current user's data directly from any component using this.$currentUser without the needing to pass it as a prop.

Note that adding Vue instance properties is fine as long as you’re building a very small app or a demo. But for anything bigger than this, I would recommend using Vuex instead.

You can also see how we're setting loggedIn to true when data are fetched. You'll see why we need this later. But for now let's not forget to define it in the data list.

data () {
  return {
    loggedIn: false
  }
}

To complete this method, let's import CometChat and Vue at the top.

import { CometChat } from '@cometchat-pro/chat'
import Vue from 'vue'

Update the Navbar with real data

Since we now have the logged-in user's data, let's update the name and the avatar in the nav bar to use them.

Go to src/components/Navbar.vue, and update the displayed name to be like this:

Welcome <strong class="user-name">{{ $currentUser.name }}</strong>

And the avatar, like this:

<img
  v-else
  class="avatar"
  :src="$currentUser.avatar"
  @click="logout"
>

If you view the chat page in the browser, you'll see that the navbar is broken. This is expected because we're assuming that the current user's data should be available before the page is loaded. But this isn't true in our case since we don't know when CometChat.getLoggedinUser() will be resolved.

To fix this, we should not display the page until the user's data is available. We can do this by adding this check to the root element of the chat page.

<template>
  <div
    v-if="loggedIn"
    class="chat-page"
  >
    <navbar/>
    <div class="chat-container">
      <chat-sidebar/>
      <chat-main/>
    </div>
  </div>
</template>

Now it should be clear why we needed to create that loggedIn property.

If you check the browser now, it should work as expected.

Loading contacts

Our next step is to load the contacts that we can chat with. Like loading the current user's data, we'll load them from the created() hook function.

created () {
  this.loadCurrentUser()
  this.loadContacts()
}

Here's the implementation of loadContacts:

loadContacts () {
  const usersRequest = new CometChat.UsersRequestBuilder().setLimit(5).build()
  usersRequest.fetchNext()
    .then(usersList => {
      this.contacts = usersList.map(user => ({
        uid: user.uid,
        name: user.name,
        avatar: user.avatar,
        isOnline: user.status === 'online'
      }))
      this.activeContactUid = this.contacts[0].uid
    })
}

The returned list contains the first 5 users (excluding the logged-in user) that you have in your CometChat app — you can see them in the dashboard.

After the list is fetched, we store the contacts inside the contacts data property. We also set the first contact in the list as the active user that we're chatting with. We store that inside activeContactUid, as you can see above.

This means we have to add those properties inside our data list.

data () {
  return {
    loggedIn: false,
    contacts: [],
    activeContactUid: null
  }
}

Showing contacts in the sidebar

Now we have the contacts fetched, let's display them in the sidebar. Before we open the sidebar component, let's pass the contacts list and the active contact id through its props. So, update <chat-sidebar/> in Chat.vue like this:

<chat-sidebar
  :contacts="contacts"
  :active-contact-id="activeContactUid"
  @select-contact="setActiveContactUid"
/>

Now add the following into src/components/ChatSidebar.vue:

<template>
  <div class="chat-sidebar">
    <div class="header">
      Contacts
    </div>
    <div class="contacts-list">
      <div
        v-for="user in contacts"
        :key="user.uid"
        class="contact-item"
        :class="{
          'active': user.uid === activeContactId,
          'online': user.isOnline
        }"
        @click="$emit('select-contact', user.uid)"
      >
        <div class="contact-avatar-wrapper">
          <img
            class="contact-avatar"
            :src="user.avatar"
          >
        </div>
        <span class="contact-name">
          {{ user.name }}
        </span>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    contacts: {
      type: Array,
      default: () => ([])
    },

    activeContactId: {
      type: String,
      default: null
    }
  }
}
</script>

<style lang="stylus" scoped>
.header
  font-family: 'Roboto', sans-serif
  font-weight: normal
  font-size: 15px
  line-height: 18px
  color: #2D313F
  padding: 20px 25px
  border-bottom: 1px solid #BDCCD7

.contacts-list
  padding-bottom: 20px

.contact-item
  height: 67px
  display: flex
  align-items: center
  cursor: pointer
  border-bottom: 1px solid rgba(189, 204, 215, 0.5)
  padding-left: 15px

.contact-item.active
  background: #1B47DB

.contact-item:not(.active):hover
  background: #ECF0FE

.contact-avatar
  width: 37px
  height: 37px
  border-radius: 50%
  margin-right: 10px

.contact-avatar-wrapper
  position: relative
  display: flex

.contact-avatar-wrapper:after
  content: ''
  display: block
  width: 10px
  height: 10px
  border-radius: 50%
  background: #BBBEBE
  position: absolute
  z-index: 9
  right: 10px
  bottom: 0

.contact-item.online .contact-avatar-wrapper:after
  background: #1BDB72

.contact-name
  font-family: 'Roboto', sans-serif
  font-size: 14px
  line-height: 16px
  color: #2D313F

.contact-item:hover .contact-name,
.contact-item.active .contact-name
  font-weight: 500

.contact-item.active .contact-name
  color: #FFF

@media (max-width: 635px)
  .header
    padding: 20px 0
    text-align: center
  .contact-name
    display: none
  .contact-avatar
    width: 40px
    height: 40px
    margin: 0
  .contact-item
    justify-content: center
    padding: 0
</style>

Here's what we're doing here:

  • We're looping through the contacts list using v-for.
  • We mark a contact as active (currently selected) by adding the .active class to it. A contact is active if its id is the same as activeContactId.
  • If the contact item has .online class, we show that the user is currently online (green circle).
  • If we click on a contact, we set it as active by emitting select-contact event with the contact's id.

We've already listened for the select-contact event but we haven't defined the handler for it yet. So in Chat.vue, add this method:

setActiveContactUid (uid) {
  this.activeContactUid = uid
}

Now you should be able switch between active contacts.

If you check your browser now, you should see the contacts displayed like this:

Sending messages

Now let's focus on sending messages to the currently selected contact. All messaging related code should go to ChatMain.vue. But before we implement the sendMessage method, we need to pass the active contact object to the ChatMain component.

Currently, we only have the active contact id, not the contact object itself. So let's create a computed property that returns the currently selected contact object based on what's in activeContactUid.

Let's add that computed property in Chat.vue like this:

computed: {
  activeContact () {
    return this.contacts
      .find(user => user.uid === this.activeContactUid)
  }
}

Now let's pass it to <chat-main/> component.

<chat-main :active-contact="activeContact"/>

And then accept it in ChatMain.vue.

props: {
  activeContact: {
    type: Object,
    default: null
  }
}

Now we have the active contact available, let's implement the sendMessage method in ChatMain.vue.

sendMessage () {
  this.sendingMessage = true
  const textMessage = new CometChat.TextMessage(
    this.activeContact.uid,
    this.messageText,
    CometChat.MESSAGE_TYPE.TEXT,
    CometChat.RECEIVER_TYPE.USER
  )

  CometChat.sendMessage(textMessage)
    .then(message => {
      this.sendingMessage = false
      this.messageText = ''
      this.messages.push(message)
      this.$nextTick(() => {
        this.scrollToBottom()
      })
    })
    .catch(error => {
      console.log(error)
      this.sendingMessage = false
    })
}

To send a message in CometChat to a specific contact, we have to create a new text message using new CometChat.TextMessage(), and in that message we should specify the contact's id we want to send the message to, the message text, the message type, and the receiver type.

After we have that message, we send it through CometChat.sendMessage(message).

Note that after the message is sent, we add it to the local messages array so it gets displayed immediately.

Before you test this, let's define the scrollToBottom method.

scrollToBottom () {
  const messagesContainer = this.$refs.messagesContainer
  if (messagesContainer) {
    messagesContainer.$el.scrollTo(0, messagesContainer.$el.scrollHeight + 30)
  }
}

If you try to send a message, you won't see it displayed on the main area because we haven't implemented ChatMessages.vue yet.

So open src/components/ChatMessages.vue, and add this:

<template>
  <div class="chat-messages">
    <div
      v-for="message in messages"
      :key="message.id"
      class="message-item"
      :class="[message.sender.uid === $currentUser.uid ? 'outgoing' : 'incoming']"
    >
      <img
        class="avatar"
        :src="message.sender.avatar"
      >
      <div class="message-content">
        {{ message.data.text }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    messages: {
      type: Array,
      default: () => ([])
    }
  }
}
</script>

<style lang="stylus" scoped>
.chat-messages
  overflow: auto
  flex: 1
  padding: 20px 10px

.message-item
  display: flex
  align-items: flex-end
  margin-bottom: 15px

.message-item.outgoing
  flex-direction: row-reverse

.avatar
  width: 40px
  height: 40px
  border-radius: 50%
  overflow: hidden
  margin: 0 10px

.message-content
  position: relative
  background: #FFF
  border-radius: 4px
  box-shadow: 0px 1px 2px #BDCCD7
  padding: 10px
  font-family: 'Roboto', sans-serif
  font-weight: normal
  font-size: 13px
  line-height: 21px
  color: #2D313F
  mix-blend-mode: normal
  opacity: 0.8
  max-width: 280px
  min-width: 100px
  margin-left: 10px

.message-item.outgoing .message-content
  margin-left: 0
  margin-right: 10px

.message-item.outgoing .message-content
  background: #1B47DB
  color: #FFF
  opacity: 1

.message-content:after
  content: ''
  position: absolute
  top: calc(100% - 20px)
  width: 0
  height: 0
  border: 12px solid transparent
  margin-top: -12px
  z-index: 1
  transform: skew(0, 30deg)

.message-item.incoming .message-content:after
  left: 0
  border-right-color: #ffffff
  border-left: 0
  margin-left: -10px

.message-item.outgoing .message-content:after
  right: 0
  border-left-color: #1B47DB
  border-right: 0
  margin-right: -11px

.message-content:before
  content: ''
  position: absolute
  z-index: 0
  top: calc(100% - 20px)
  width: 0
  height: 0
  border: 10px solid transparent
  filter: blur(1px)
  margin-top: -10px
  transform: skew(0, 30deg)

.message-item.incoming .message-content:before
  left: 0
  border-right-color: #ffffff
  border-left: 0
  margin-left: -10px
  border-right-color: rgba(0, 0, 0, 0.2)

.message-item.outgoing .message-content:before
  right: 0
  border-left-color: #1B47DB
  border-right: 0
  margin-right: -11px
  border-right-color: rgba(0, 0, 0, 0.2)

@media (max-width: 635px)
  .avatar
    display: none
  .message-content:before,
  .message-content:after
    display: none
  .message-item
    width: 100%
  .message-item.outgoing .message-content,
  .message-item.incoming .message-content
    margin-left: 0
    margin-right: 0
    width: 100%
    max-width: 100%
</style>

To distinguish between the sent and received messages, we add either an .outgoing or .incoming class to the message item. Outgoing messages are the messages with the same sender's id as the logged-in user. The rest should be easy to understand.

Now sending messages should work as expected.

If you reload the browser, however, you should see that your messages are gone. This is expected since we don't load previous messages on page load. This means the messages you send via CometChat are stored for you. So you don’t have to handle message persistence by yourself — how cool is CometChat?

Loading previous messages

Go to ChatMain.vue, and add the following method:

loadMessages () {
  this.loadingMessages = true
  const messagesRequest = new CometChat.MessagesRequestBuilder()
    .setUID(this.activeContact.uid)
    .build()
  messagesRequest.fetchPrevious()
    .then(messages => {
      this.messages = messages
      this.loadingMessages = false
      this.$nextTick(() => {
        this.scrollToBottom()
      })
    })
    .catch(error => {
      console.log('error', error)
      this.loadingMessages = false
    })
}

We should call this method every time we switch to a new contact. So let's call it from a watcher, like this:

watch: {
  'activeContact.uid': {
    immediate: true,
    handler () {
      this.loadMessages()
    }
  }
}

Note how we're using immediate: true so it gets invoked when the component is mounted. We need this to load messages for the first contact that gets selected automatically when contacts are loaded.

Listening for new messages

Now what would happen if you logged in with another user from another browser and tried to send a message?

If you tried that, you wouldn't see the message until you reload the browser. This means we need to listen for new messages in realtime.

We can achieve that very easily with CometChat by adding this code to the mounted hook function in ChatMain.vue:

mounted () {
  const listenerID = 'UNIQUE_LISTENER_ID'
  CometChat.addMessageListener(
    listenerID,
    new CometChat.MessageListener({
      onTextMessageReceived: message => {
        if (message.sender.uid === this.activeContact.uid) {
          this.messages.push(message)
        }
        this.$nextTick(() => {
          this.scrollToBottom()
        })
      }
    })
  )
}

So we're listening for new messages using CometChat message listener, and we're showing messages only from the contact that's currently selected.

Showing current user statuses in realtime

You might already have noticed that the current statuses of the contacts aren’t updated in realtime — you have to reload the browser to see the current statuses.

Like with listening to messages, we can listen to user statuses using CometChat.addUserListener. Let's add this to the created hook function in Chat.vue.

created () {
  this.loadCurrentUser()
  this.loadContacts()

  const listenerID = 'UNIQUE_LISTENER_ID'
  CometChat.addUserListener(
    listenerID,
    new CometChat.UserListener({
      onUserOnline: onlineUser => {
        const index = this.contacts.findIndex(user => user.uid === onlineUser.uid)
        this.$set(this.contacts, index, { ...this.contacts[index], isOnline: true })
      },

      onUserOffline: offlineUser => {
        const index = this.contacts.findIndex(user => user.uid === offlineUser.uid)
        this.$set(this.contacts, index, { ...this.contacts[index], isOnline: false })
      }
    })
  )
}

So each time a user gets online or offline, we're notified in onUserOnline or onUserOffline callbacks with the user's data. We use the user's id to update the status of the matching user from the contacts list.

Showing the currently selected contact name

If you take a look at the header in the chat's main area, you'll see the text "Active Contact Name" regardless of the currently selected contact. Instead, we should display the name of that contact.

This can be easily done by replacing "Active Contact Name" with {{ activeContact.name }} in ChatMain.vue.

Fixing the browser's console error

If you check your browser now, you would see an error telling you that activeContact is null in ChatMain.vue. That's expected since we display the chat page before the contacts are loaded.

We can fix this quickly by including activeContact to the root element's check in the chat page.

<div
  v-if="loggedIn && activeContact"
  class="chat-page"
>

Implementing logging out

Our last step in this tutorial is to implement the logout method in Navbar.vue.

logout () {
  this.loggingOut = true
  CometChat.logout()
    .then(() => {
      this.loggingOut = false
      this.$router.push({ name: 'login' })
    })
    .catch(error => {
      this.loggingOut = false
      console.log('error', error)
    })
}

So logging out is as simple as calling CometChat.logout(), and then redirecting to the login page.

Conclusion

It took time to build this app, but it's mostly because we made it look like a real-world app. CometChat was the easy part as you can see.

So we can conclude the flow of this chat app (or any chat app) like this: keep track of the logged-in user and his or her contacts, specify the contact when sending a message, load any previous messages between participants, and listen for new messages in realtime.


Thanks for reading! By the way, I’m writing a book on how to build a complete single-page application from scratch using Vue. Check out the book’s landing page if you’re interested in learning more about what the book will cover.