Build a Real-time Chat App with Pusher and Vue.js

Michael Wanyoike
Share

Apps that communicate in real time are becoming more and more popular nowadays, as they make for a smoother, more natural user experience.

In this tutorial, we’re going to build a real-time chat application using Vue.js powered by ChatKit, a service provided by Pusher. The ChatKit service will provide us with a complete back end necessary for building a chat application on any device, leaving us to focus on building a front-end user interface that connects to the ChatKit service via the ChatKit client package.

Want to learn Vue.js from the ground up? Get an entire collection of Vue books covering fundamentals, projects, tips and tools & more with SitePoint Premium. Join now for just $9/month.

Prerequisites

This is an intermediate- to advanced-level tutorial. You’ll need to be familiar with the following concepts to follow along:

  • Vue.js basics
  • Vuex fundamentals
  • employing a CSS framework

You’ll also need Node installed on your machine. You can do this by downloading the binaries from the official website, or by using a version manager. This is probably the easiest way, as it allows you to manage multiple versions of Node on the same machine.

Finally, you’ll need to install Vue CLI globally with the following command:

npm install -g @vue/cli

At the time of writing, Node 10.14.1 and Vue CLI 3.2.1 are the latest versions.

About the Project

We’re going to build a rudimentary chat application similar to Slack or Discord. The app will do the following:

  • have multiple channels and rooms
  • list room members and detect presence status
  • detect when other users start typing

As mentioned earlier, we’re just building the front end. The ChatKit service has a back-end interface that will allows us to manage users, permissions and rooms.

You can find the complete code for this project on GitHub.

Setting up a ChatKit Instance

Let’s create our ChatKit instance, which is similar to a server instance if you’re familiar with Discord.

Go to the ChatKit page on Pusher’s website and click the Sign Up button. You’ll be prompted for an email address and password, as well as the option to sign in with GitHub or Google.

Select which option suits you best, then on the next screen fill out some details such as Name, Account type, User role etc.

Click Complete Onboarding and you’ll be taken to the main Pusher dashboard. Here, you should click the ChatKit Product.

The ChatKit dashboard

Click the Create button to create a new ChatKit Instance. I’m going to call mine VueChatTut.

Creating a new ChatKit instance

We’ll be using the free plan for this tutorial. It supports up to 1,000 unique users, which is more than sufficient for our needs. Head over to the Console tab. You’ll need to create a new user to get started. Go ahead and click the Create User button.

Creating a ChatKit user

I’m going to call mine “john” (User Identifier) and “John Wick” (Display Name), but you can name yours however you want. The next part is easy: create the two or more users. For example:

  • salt, Evelyn Salt
  • hunt, Ethan Hunt

Create three or more rooms and assign users. For example:

  • General (john, salt, hunt)
  • Weapons (john, salt)
  • Combat (john, hunt)

Here’s a snapshot of what your Console interface should like.

A snapshot of the console

Next, you can go to the Rooms tab and create a message using a selected user for each room. This is for testing purposes. Then go to the Credentials tab and take note of the Instance Locator. We’ll need to activate the Test Token Provider, which is used for generating our HTTP endpoint, and take a note of that, too.

Test token

Our ChatKit back end is now ready. Let’s start building our Vue.js front end.

Scaffolding the Vue.js Project

Open your terminal and create the project as follows:

vue create vue-chatkit

Select Manually select features and answer the questions as shown below.

Questions to be answered

Make doubly sure you’ve selected Babel, Vuex and Vue Router as additional features. Next, create the following folders and files as follows:

Files and folders to create

Make sure to create all the folders and files as demonstrated. Delete any unnecessary files that don’t appear in the above illustration.

For those of you that are at home in the console, here are the commands to do all that:

mkdir src/assets/css
mkdir src/store

touch src/assets/css/{loading.css,loading-btn.css}
touch src/components/{ChatNavBar.vue,LoginForm.vue,MessageForm.vue,MessageList.vue,RoomList.vue,UserList.vue}
touch src/store/{actions.js,index.js,mutations.js}
touch src/views/{ChatDashboard.vue,Login.vue}
touch src/chatkit.js

rm src/components/HelloWorld.vue
rm src/views/{About.vue,Home.vue}
rm src/store.js

When you’re finished, the contents of the src folder should look like so:

.
├── App.vue
├── assets
│   ├── css
│   │   ├── loading-btn.css
│   │   └── loading.css
│   └── logo.png
├── chatkit.js
├── components
│   ├── ChatNavBar.vue
│   ├── LoginForm.vue
│   ├── MessageForm.vue
│   ├── MessageList.vue
│   ├── RoomList.vue
│   └── UserList.vue
├── main.js
├── router.js
├── store
│   ├── actions.js
│   ├── index.js
│   └── mutations.js
└── views
    ├── ChatDashboard.vue
    └── Login.vue

For the loading-btn.css and the loading.css files, you can find them on the loading.io website. These files are not available in the npm repository, so you need to manually download them and place them in your project. Do make sure to read the documentation to get an idea on what they are and how to use the customizable loaders.

Next, we’re going to install the following dependencies:

npm i @pusher/chatkit-client bootstrap-vue moment vue-chat-scroll vuex-persist

Do check out the links to learn more about what each package does, and how it can be configured.

Now, let’s configure our Vue.js project. Open src/main.js and update the code as follows:

import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue'
import VueChatScroll from 'vue-chat-scroll'

import App from './App.vue'
import router from './router'
import store from './store/index'

import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import './assets/css/loading.css'
import './assets/css/loading-btn.css'

Vue.config.productionTip = false
Vue.use(BootstrapVue)
Vue.use(VueChatScroll)

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

Update src/router.js as follows:

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

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'login',
      component: Login
    },
    {
      path: '/chat',
      name: 'chat',
      component: ChatDashboard,
    }
  ]
})

Update src/store/index.js:

import Vue from 'vue'
import Vuex from 'vuex'
import VuexPersistence from 'vuex-persist'
import mutations from './mutations'
import actions from './actions'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

const vuexLocal = new VuexPersistence({
  storage: window.localStorage
})

export default new Vuex.Store({
  state: {
  },
  mutations,
  actions,
  getters: {
  },
  plugins: [vuexLocal.plugin],
  strict: debug
})

The vuex-persist package ensures that our Vuex state is saved between page reloads or refreshes.

Our project should be able to compile now without errors. However, don’t run it just yet, as we need to build the user interface.

Building the UI Interface

Let’s start by updating src/App.vue as follows:

<template>
  <div id="app">
    <router-view/>
  </div>
</template>

Next, we need to define our Vuex store states as they’re required by our UI components to work. We’ll do this by going to our Vuex store in src/store/index.js. Just update the state and getters sections as follows:

state: {
  loading: false,
  sending: false,
  error: null,
  user: [],
  reconnect: false,
  activeRoom: null,
  rooms: [],
  users: [],
  messages: [],
  userTyping: null
},
getters: {
  hasError: state => state.error ? true : false
},

These are all the state variables that we’ll need for our chat application. The loading state is used by the UI to determine whether it should run the CSS loader. The error state is used to store information of an error that has just occurred. We’ll discuss the rest of the state variables when we cross their bridges.

Next open src/view/Login.vue and update as follows:

<template>
  <div class="login">
    <b-jumbotron  header="Vue.js Chat"
                  lead="Powered by Chatkit SDK and Bootstrap-Vue"
                  bg-variant="info"
                  text-variant="white">
      <p>For more information visit website</p>
      <b-btn target="_blank" href="https://pusher.com/chatkit">More Info</b-btn>
    </b-jumbotron>
    <b-container>
      <b-row>
        <b-col lg="4" md="3"></b-col>
        <b-col lg="4" md="6">
          <LoginForm />
        </b-col>
        <b-col lg="4" md="3"></b-col>
      </b-row>
    </b-container>
  </div>
</template>

<script>
import LoginForm from '@/components/LoginForm.vue'

export default {
  name: 'login',
  components: {
    LoginForm
  }
}
</script>

Next, insert code for src/components/LoginForm.vue as follows:

<template>
  <div class="login-form">
    <h5 class="text-center">Chat Login</h5>
    <hr>
    <b-form @submit.prevent="onSubmit">
       <b-alert variant="danger" :show="hasError">{{ error }} </b-alert>

      <b-form-group id="userInputGroup"
                    label="User Name"
                    label-for="userInput">
        <b-form-input id="userInput"
                      type="text"
                      placeholder="Enter user name"
                      v-model="userId"
                      autocomplete="off"
                      :disabled="loading"
                      required>
        </b-form-input>
      </b-form-group>

      <b-button type="submit"
                variant="primary"
                class="ld-ext-right"
                v-bind:class="{ running: loading }"
                :disabled="isValid">
                Login <div class="ld ld-ring ld-spin"></div>
      </b-button>
    </b-form>
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  name: 'login-form',
  data() {
    return {
      userId: '',
    }
  },
  computed: {
    isValid: function() {
      const result = this.userId.length < 3;
      return result ? result : this.loading
    },
    ...mapState([
      'loading',
      'error'
    ]),
    ...mapGetters([
      'hasError'
    ])
  }
}
</script>

As mentioned earlier, this is an advanced tutorial. If you have trouble understanding any of the code here, please go to the prerequisites or the project dependencies for information.

We can now start the Vue dev server via npm run serve to ensure our application is running without any compilation issues.

Our login UI

You can confirm the validation is working by entering a username. You should see the Login button activate after entering three characters. The Login button doesn’t work for now, as we haven’t coded that part. We’ll look into it later. For now, let’s continue building our chat user interface.

Go to src/view/ChatDashboard.vue and insert the code as follows:

<template>
  <div class="chat-dashboard">
    <ChatNavBar />
    <b-container fluid class="ld-over" v-bind:class="{ running: loading }">
      <div class="ld ld-ring ld-spin"></div>
      <b-row>
        <b-col cols="2">
          <RoomList />
        </b-col>

        <b-col cols="8">
          <b-row>
            <b-col id="chat-content">
              <MessageList />
            </b-col>
          </b-row>
          <b-row>
            <b-col>
              <MessageForm />
            </b-col>
          </b-row>
        </b-col>

        <b-col cols="2">
          <UserList />
        </b-col>
      </b-row>
    </b-container>
  </div>
</template>

<script>
import ChatNavBar from '@/components/ChatNavBar.vue'
import RoomList from '@/components/RoomList.vue'
import MessageList from '@/components/MessageList.vue'
import MessageForm from '@/components/MessageForm.vue'
import UserList from '@/components/UserList.vue'
import { mapState } from 'vuex';

export default {
  name: 'Chat',
  components: {
    ChatNavBar,
    RoomList,
    UserList,
    MessageList,
    MessageForm
  },
  computed: {
    ...mapState([
      'loading'
    ])
  }
}
</script>

The ChatDashboard will act as a layout parent for the following child components:

  • ChatNavBar, a basic navigation bar
  • RoomList, which lists rooms that the logged in user has access to, and which is also a room selector
  • UserList, which lists members of a selected room
  • MessageList, which displays messages posted in a selected room
  • MessageForm, a form for sending messages to the selected room

Let’s put some boilerplate code in each component to ensure everything gets displayed.

Insert boilerplate code for src/components/ChatNavBar.vue as follows:

<template>
  <b-navbar id="chat-navbar" toggleable="md" type="dark" variant="info">
    <b-navbar-brand href="#">
      Vue Chat
    </b-navbar-brand>
    <b-navbar-nav class="ml-auto">
      <b-nav-text>{{ user.name }} | </b-nav-text>
      <b-nav-item href="#" active>Logout</b-nav-item>
    </b-navbar-nav>
  </b-navbar>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'ChatNavBar',
  computed: {
    ...mapState([
      'user',
    ])
  },
}
</script>

<style>
  #chat-navbar {
    margin-bottom: 15px;
  }
</style>

Insert boilerplate code for src/components/RoomList.vue as follows:

<template>
  <div class="room-list">
    <h4>Channels</h4>
    <hr>
    <b-list-group v-if="activeRoom">
      <b-list-group-item v-for="room in rooms"
                        :key="room.name"
                        :active="activeRoom.id === room.id"
                        href="#"
                        @click="onChange(room)">
        # {{ room.name }}
      </b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'RoomList',
  computed: {
    ...mapState([
      'rooms',
      'activeRoom'
    ]),
  }
}
</script>

Insert boilerplate code for src/components/UserList.vue as follows:

<template>
  <div class="user-list">
    <h4>Members</h4>
    <hr>
    <b-list-group>
      <b-list-group-item v-for="user in users" :key="user.username">
        {{ user.name }}
        <b-badge v-if="user.presence"
        :variant="statusColor(user.presence)"
        pill>
        {{ user.presence }}</b-badge>
      </b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'user-list',
  computed: {
    ...mapState([
      'loading',
      'users'
    ])
  },
  methods: {
    statusColor(status) {
      return status === 'online' ? 'success' : 'warning'
    }
  }
}
</script>

Insert boilerplate code for src/components/MessageList.vue as follows:

<template>
  <div class="message-list">
    <h4>Messages</h4>
    <hr>
    <div id="chat-messages" class="message-group" v-chat-scroll="{smooth: true}">
      <div class="message" v-for="(message, index) in messages" :key="index">
        <div class="clearfix">
          <h4 class="message-title">{{ message.name }}</h4>
          <small class="text-muted float-right">@{{ message.username }}</small>
        </div>
        <p class="message-text">
          {{ message.text }}
        </p>
        <div class="clearfix">
          <small class="text-muted float-right">{{ message.date }}</small>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'message-list',
  computed: {
    ...mapState([
      'messages',
    ])
  }
}
</script>

<style>
.message-list {
  margin-bottom: 15px;
  padding-right: 15px;
}
.message-group {
  height: 65vh !important;
  overflow-y: scroll;
}
.message {
  border: 1px solid lightblue;
  border-radius: 4px;
  padding: 10px;
  margin-bottom: 15px;
}
.message-title {
  font-size: 1rem;
  display:inline;
}
.message-text {
  color: gray;
  margin-bottom: 0;
}
.user-typing {
  height: 1rem;
}
</style>

Insert boilerplate code for src/components/MessageForm.vue as follows:

<template>
  <div class="message-form ld-over">
    <small class="text-muted">@{{ user.username }}</small>
    <b-form @submit.prevent="onSubmit" class="ld-over" v-bind:class="{ running: sending }">
      <div class="ld ld-ring ld-spin"></div>
      <b-alert variant="danger" :show="hasError">{{ error }} </b-alert>
      <b-form-group>
        <b-form-input id="message-input"
                      type="text"
                      v-model="message"
                      placeholder="Enter Message"
                      autocomplete="off"
                      required>
        </b-form-input>
      </b-form-group>
      <div class="clearfix">
        <b-button type="submit" variant="primary" class="float-right">
          Send
        </b-button>
      </div>
    </b-form>
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  name: 'message-form',
  data() {
    return {
      message: ''
    }
  },
  computed: {
    ...mapState([
      'user',
      'sending',
      'error',
      'activeRoom'
    ]),
    ...mapGetters([
      'hasError'
    ])
  }
}
</script>

Go over the code to ensure nothing is a mystery to you. Navigate to http://localhost:8080/chat to check if everything is running. Check the terminal and browser consoles to ensure there are no errors at this point. You should now have the following view.

The blank chat dashboard

Pretty empty, right? Let’s go to src/store/index.js and insert some mock data in state:

state: {
  loading: false,
  sending: false,
  error: 'Relax! This is just a drill error message',
  user: {
    username: 'Jack',
    name: 'Jack Sparrow'
  },
  reconnect: false,
  activeRoom: {
    id: '124'
  },
  rooms: [
    {
      id: '123',
      name: 'Ships'
    },
    {
      id: '124',
      name: 'Treasure'
    }
  ],
  users: [
    {
      username: 'Jack',
      name: 'Jack Sparrow',
      presence: 'online'
    },
    {
      username: 'Barbossa',
      name: 'Hector Barbossa',
      presence: 'offline'
    }
  ],
  messages: [
    {
      username: 'Jack',
      date: '11/12/1644',
      text: 'Not all treasure is silver and gold mate'
    },
    {
      username: 'Jack',
      date: '12/12/1644',
      text: 'If you were waiting for the opportune moment, that was it'
    },
    {
      username: 'Hector',
      date: '12/12/1644',
      text: 'You know Jack, I thought I had you figured out'
    }
  ],
  userTyping: null
},

After saving the file, your view should match the image below.

Displaying mock data

This simple test ensures that all components and states are all tied up together nicely. You can now revert the state code back to its original form:

state: {
  loading: false,
  sending: false,
  error: null,
  user: null,
  reconnect: false,
  activeRoom: null,
  rooms: [],
  users: [],
  messages: [],
  userTyping: null
}

Let’s start implementing concrete features, starting with the login form.

Password-less Authentication

For this tutorial, we’ll employ a password-less non-secure authentication system. A proper, secure authentication system is outside the scope of this tutorial. To start with, we need to start building our own interface that will interact with ChatKit service via the @pusher/chatkit-client package.

Go back to the ChatKit dashboard and copy the instance and test token parameters. Save them in the file .env.local at the root of your project like this:

VUE_APP_INSTANCE_LOCATOR=
VUE_APP_TOKEN_URL=
VUE_APP_MESSAGE_LIMIT=10

I’ve also added a MESSAGE_LIMIT parameter. This value simply restricts the number of messages our chat application can fetch. Make sure to fill in the other parameters from the credentials tab.

Next, go to src/chatkit.js to start building our chat application foundation:

import { ChatManager, TokenProvider } from '@pusher/chatkit-client'

const INSTANCE_LOCATOR = process.env.VUE_APP_INSTANCE_LOCATOR;
const TOKEN_URL = process.env.VUE_APP_TOKEN_URL;
const MESSAGE_LIMIT = Number(process.env.VUE_APP_MESSAGE_LIMIT) || 10;

let currentUser = null;
let activeRoom = null;

async function connectUser(userId) {
  const chatManager = new ChatManager({
    instanceLocator: INSTANCE_LOCATOR,
    tokenProvider: new TokenProvider({ url: TOKEN_URL }),
    userId
  });
  currentUser = await chatManager.connect();
  return currentUser;
}

export default {
  connectUser
}

Notice that we’re casting the MESSAGE_LIMIT constant to a number, as by default the process.env object forces all of its properties to be of type string.

Insert the following code for src/store/mutations:

export default {
  setError(state, error) {
    state.error = error;
  },
  setLoading(state, loading) {
    state.loading = loading;
  },
  setUser(state, user) {
    state.user = user;
  },
  setReconnect(state, reconnect) {
    state.reconnect = reconnect;
  },
  setActiveRoom(state, roomId) {
    state.activeRoom = roomId;
  },
  setRooms(state, rooms) {
    state.rooms = rooms
  },
  setUsers(state, users) {
    state.users = users
  },
 clearChatRoom(state) {
    state.users = [];
    state.messages = [];
  },
  setMessages(state, messages) {
    state.messages = messages
  },
  addMessage(state, message) {
    state.messages.push(message)
  },
  setSending(state, status) {
    state.sending = status
  },
  setUserTyping(state, userId) {
    state.userTyping = userId
  },
  reset(state) {
    state.error = null;
    state.users = [];
    state.messages = [];
    state.rooms = [];
    state.user = null
  }
}

The code for mutations is really simple — just a bunch of setters. You’ll soon understand what each mutation function is for in the later sections. Next, update src/store/actions.js with this code:

import chatkit from '../chatkit';

// Helper function for displaying error messages
function handleError(commit, error) {
  const message = error.message || error.info.error_description;
  commit('setError', message);
}

export default {
  async login({ commit, state }, userId) {
    try {
      commit('setError', '');
      commit('setLoading', true);
      // Connect user to ChatKit service
      const currentUser = await chatkit.connectUser(userId);
      commit('setUser', {
        username: currentUser.id,
        name: currentUser.name
      });
      commit('setReconnect', false);

      // Test state.user
      console.log(state.user);
    } catch (error) {
      handleError(commit, error)
    } finally {
      commit('setLoading', false);
    }
  }
}

Next, update src/components/LoginForm.vue as follows:

import { mapState, mapGetters, mapActions } from 'vuex'

//...
export default {
  //...
  methods: {
    ...mapActions([
      'login'
    ]),
    async onSubmit() {
      const result = await this.login(this.userId);
      if(result) {
        this.$router.push('chat');
      }
    }
  }
}

You’ll have to restart the Vue.js server in order to load env.local data. If you see any errors regarding unused variables, ignore them for now. Once you’ve done that, navigate to http://localhost:8080/ and test the login feature:

A login error

In the above example, I’ve used an incorrect username just to make sure the error handling feature is working correctly.

A successful login

In this screenshot, I’ve used the correct username. I’ve also opened up the browser console tab to ensure that the user object has been populated. Better yet, if you’ve installed Vue.js Dev Tools in Chrome or Firefox, you should be able to see more detailed information.

More detailed login information

If everything’s working correctly for you at this point, move on to the next step.

Subscribing to a Room

Now that we’ve successfully verified that the login feature works, we need to redirect users to the ChatDashboard view. The code this.$router.push('chat'); does this for us. However, our action login needs to return a Boolean to determine when it’s okay to navigate to the ChatDashboard view. We also need to populate the RoomList and UserList components with actual data from the ChatKit service.

Update src/chatkit.js as follows:

//...
import moment from 'moment'
import store from './store/index'

//...
function setMembers() {
  const members = activeRoom.users.map(user => ({
    username: user.id,
    name: user.name,
    presence: user.presence.state
  }));
  store.commit('setUsers', members);
}

async function subscribeToRoom(roomId) {
  store.commit('clearChatRoom');
  activeRoom = await currentUser.subscribeToRoom({
    roomId,
    messageLimit: MESSAGE_LIMIT,
    hooks: {
      onMessage: message => {
        store.commit('addMessage', {
          name: message.sender.name,
          username: message.senderId,
          text: message.text,
          date: moment(message.createdAt).format('h:mm:ss a D-MM-YYYY')
        });
      },
      onPresenceChanged: () => {
        setMembers();
      },
      onUserStartedTyping: user => {
        store.commit('setUserTyping', user.id)
      },
      onUserStoppedTyping: () => {
        store.commit('setUserTyping', null)
      }
    }
  });
  setMembers();
  return activeRoom;
}

export default {
  connectUser,
  subscribeToRoom
}

If you look at the hooks section, we have event handlers used by the ChatKit service to communicate with our client application. You can find the full documentation here. I’ll quickly summarize the purpose of each hook method:

  • onMessage receives messages
  • onPresenceChanged receives an event when a user logs in or out
  • onUserStartedTyping receives an event that a user is typing
  • onUserStoppedTyping receives an event that a user has stopped typing

For the onUserStartedTyping to work, we need to emit a typing event from our MessageForm while a user is typing. We’ll look into this in the next section.

Update the login function in src/store/actions.js with the following code:

//...
try {
  //... (place right after the `setUser` commit statement)
  // Save list of user's rooms in store
  const rooms = currentUser.rooms.map(room => ({
    id: room.id,
    name: room.name
  }))
  commit('setRooms', rooms);

  // Subscribe user to a room
  const activeRoom = state.activeRoom || rooms[0]; // pick last used room, or the first one
  commit('setActiveRoom', {
    id: activeRoom.id,
    name: activeRoom.name
  });
  await chatkit.subscribeToRoom(activeRoom.id);

  return true;
} catch (error) {
  //...
}

After you’ve saved the code, go back to the login screen and enter the correct username. You should be taken to the following screen.

The current chat dashboard

Nice! Almost all the components are working without additional effort since we wired them up properly to the Vuex store. Try sending a message via ChatKit’s dashboard console interface. Create a message and post it to the General room. You should see the new messages pop up automatically in the MessageList component. Soon, we’ll implement the logic for sending messages from our Vue.js app.

If You Experience Issues

In case you’re experiencing issues, try the following:

  • restart the Vue.js server
  • clear your browser cache
  • do a hard reset/refresh (available in Chrome if the Console tab is open and you hold the Reload button for five seconds)
  • clear localStorage using your browser console

If everything is running okay up to this point, continue with the next section, where we implement logic for changing rooms.

Changing Rooms

This part is quite simple, since we’ve already laid out the foundation. First, we’ll create an action that will allow users to change rooms. Go to src/store/actions.js and add this function right after the login action handler:

async changeRoom({ commit }, roomId) {
  try {
    const { id, name } = await chatkit.subscribeToRoom(roomId);
    commit('setActiveRoom', { id, name });
  } catch (error) {
    handleError(commit, error)
  }
},

Next, go to src/componenents/RoomList.vue and update the script section as follows:

import { mapState, mapActions } from 'vuex'
//...
export default {
  //...
  methods: {
    ...mapActions([
      'changeRoom'
    ]),
    onChange(room) {
      this.changeRoom(room.id)
    }
  }
}

If you recall, we’ve already defined @click="onChange(room)" in the b-list-group-item element. Let’s test out this new feature by clicking the items in the RoomList component.

Changing chat rooms

Your UI should update with each click of the room. The MessageList and UserList component should display the correct information for the selected room. For the next section, we’ll implement multiple features at once.

Reconnecting the User After a Page Refresh

You may have noticed that, when you do some changes to store/index.js, or you do a page refresh, you get the following error: Cannot read property 'subscribeToRoom' of null. This happens because the state of your application gets reset. Luckily, the vuex-persist package maintains our Vuex state between page reloads by saving it in the browser’s local storage.

Unfortunately, the references that connect our app to the ChatKit server are reset back to null. To fix this, we need to perform a reconnect operation. We also need a way to tell our app that a page reload has just happened and that our app needs to reconnect in order to continue functioning properly. We’ll implement this code in src/components/ChatNavbar.vue. Update the script section as follows:

<script>
import { mapState, mapActions, mapMutations } from 'vuex'

export default {
  name: 'ChatNavBar',
  computed: {
    ...mapState([
      'user',
       'reconnect'
    ])
  },
  methods: {
    ...mapActions([
      'logout',
      'login'
    ]),
    ...mapMutations([
      'setReconnect'
    ]),
    onLogout() {
      this.$router.push({ path: '/' });
      this.logout();
    },
    unload() {
      if(this.user.username) { // User hasn't logged out
        this.setReconnect(true);
      }
    }
  },
  mounted() {
    window.addEventListener('beforeunload', this.unload);
    if(this.reconnect) {
      this.login(this.user.username);
    }
  }
}
</script>

Let me break down the sequence of events so that you can understand the logic behind reconnecting to the ChatKit service:

  1. unload. When a page refresh occurs, this method gets called. It checks first the state user.username has been set. If it has, it means the user has not logged out. The state reconnect is set to true.
  2. mounted. This method gets called every time ChatNavbar.vue has just finished rendering. It first assigns a handler to an event listener that gets called just before the page unloads. It also does a check if state.reconnect has been set to true. If so, then the login procedure is executed, thus reconnecting our chat application back to our ChatKit service.

I’ve also added a Logout feature, which we’ll look into later.

After making these changes, try refreshing the page. You’ll see the page update itself automatically as it does the reconnection process behind the scenes. When you switch rooms, it should work flawlessly.

Sending Messages, Detecting User Typing and Logging Out

Let’s start with implementing these features in src/chatkit.js by adding the following code:

//...
async function sendMessage(text) {
  const messageId = await currentUser.sendMessage({
    text,
    roomId: activeRoom.id
  });
  return messageId;
}

export function isTyping(roomId) {
  currentUser.isTypingIn({ roomId });
}

function disconnectUser() {
  currentUser.disconnect();
}

export default {
  connectUser,
  subscribeToRoom,
  sendMessage,
  disconnectUser
}

While the functions sendMessage and disconnectUser will be bundled in ChatKit’s module export, isTyping function will be exported separately. This is to allow MessageForm to directly send typing events without involving the Vuex store.

For sendMessage and disconnectUser, we’ll need to update the store in order to cater for things like error handling and loading status notifications. Go to src/store/actions.js and insert the following code right after the changeRoom function:

async sendMessage({ commit }, message) {
  try {
    commit('setError', '');
    commit('setSending', true);
    const messageId = await chatkit.sendMessage(message);
    return messageId;
  } catch (error) {
    handleError(commit, error)
  } finally {
    commit('setSending', false);
  }
},
async logout({ commit }) {
  commit('reset');
  chatkit.disconnectUser();
  window.localStorage.clear();
}

For the logout function, we call commit('reset') to reset our store back to its original state. It’s a basic security feature to remove user information and messages from the browser cache.

Let’s start by updating the form input in src/components/MessageForm.vue to emit typing events by adding the @input directive:

<b-form-input id="message-input"
              type="text"
              v-model="message"
              @input="isTyping"
              placeholder="Enter Message"
              autocomplete="off"
              required>
</b-form-input>

Let’s now update the script section for src/components/MessageForm.vue to handle message sending and emitting of typing events. Update as follows:

<script>
import { mapActions, mapState, mapGetters } from 'vuex'
import { isTyping } from '../chatkit.js'

export default {
  name: 'message-form',
  data() {
    return {
      message: ''
    }
  },
  computed: {
    ...mapState([
      'user',
      'sending',
      'error',
      'activeRoom'
    ]),
    ...mapGetters([
      'hasError'
    ])
  },
  methods: {
    ...mapActions([
      'sendMessage',
    ]),
    async onSubmit() {
      const result = await this.sendMessage(this.message);
      if(result) {
        this.message = '';
      }
    },
     async isTyping() {
      await isTyping(this.activeRoom.id);
    }
  }
}
</script>

And in src/MessageList.vue:

import { mapState } from 'vuex'

export default {
  name: 'message-list',
  computed: {
    ...mapState([
      'messages',
      'userTyping'
    ])
  }
}

The send message feature should now work. In order to display a notification that another user is typing, we need to add an element for displaying this information. Add the following snippet in the template section of src/components/MessageList.vue, right after the message-group div:

<div class="user-typing">
  <small class="text-muted" v-if="userTyping">@{{ userTyping }} is typing....</small>
</div>

To test out this feature, simply log in as another user using a different browser and start typing. You should see a notification appear on the other user’s chat window.

A notification in another user’s chat window

Let’s finish this tutorial by implementing the last feature, logout. Our Vuex store already has the necessary code to handle the logout process. We just need to update src/components/ChatNavBar.vue. Simply link the Logout button with function handler onLogout that we had specified earlier:

 <b-nav-item href="#" @click="onLogout" active>Logout</b-nav-item>

That’s it. You can now log out and log in again as another user.

Switching between member accounts

Summary

We’ve now come to the end of the tutorial. The ChatKit API has enabled us to rapidly build a chat application in a short time. If we were to build a similar application from scratch, it would take us several weeks, since we’d have to flesh out the back end as well. The great thing about this solution is that we don’t have to deal with hosting, managing databases and other infrastructure issues. We can simply build and deploy the front-end code to client devices on web, Android and IOS platforms.

Please do take a look at the documentation, as there’s a ton of back-end features I wasn’t able to show you in this tutorial. Given time, you can easily build a full-featured chat application that can rival popular chat products like Slack and Discord.