Real-Time Data Transfer Using Vue and Socket.IO [Part 2 of 3]
This project uses Node.js v14.15.1.
In the last post we covered setting up a simple server and client which communicate with each other using websockets. This post will focus on the creation of a chat room. The third and final post in this series will build upon the techniques that we’ll cover in this post to create a simple multiplayer game “Fill Frenzy!”.
Add Real-Time Chat
The first of the two main components we will cover will be the chat room. The chat room will have the following features:
- Support for multiple users,
- A list of all connected users,
- A history of all chat messages, and
- Notifications for when other users are typing.
The chat component will be lengthy and touch multiple files, so I will break it down into manageable sections as best I can.
Update the Server Code
Having all the server code in one file will become painful to manage, especially since we’ll be adding new code to handle more components of our app. Let’s try to structure it a little better, and then add our chat component.
- Create a new folder named
server
in the project root. - Move
server.js
into theserver
folder, then rename it toindex.js
. - Create
server/chat.js
and add the following code: - Update
server/index.js
to include our new chat functionality, replacing the code we wrote in part 1 of this series.
let socketio = undefined; const chatHistory = []; const userList = []; const userTypingStatus = {}; const timers = {}; const typingStatusTime = 2000; const id = { message: 0, user: 0, unique: {} };
Firstly, we create a variable socketio
to use as a reference to our main Socket.IO object, and arrays to contain our chat messages and list of users. Next, we add some properties to keep track of the User is typing
notifications. We also create an object to keep track of the IDs that we’ll attach to messages and users.
const initialise = (io) => { socketio = io; }
Next we create an initialise
function which we can use to set our socketio
reference.
const run = (socket) => { // new socket connected, send user list and chat history socket.emit('userList', userList); socket.emit('chatHistory', chatHistory); handleUserConnected(socket); handleChatMessage(socket); handleTypingStatus(socket); }
The run
function will handle the main functionality of the chat room for each individual socket which is passed to the function as an argument. The function will be called only once per socket when a new connection is made, so we will take advantage of this to send the current state of the app to the socket by emitting the events userList
and chatHistory
which both pass the corresponding information.
const handleUserConnected = (socket) => { // listen for 'userConnected' events socket.on('userConnected', (username) => { // create a new user object let newUser = { userID: getID('user'), uniqueID: getID('unique', username), name: username }; // determine user unique name newUser.uniqueName = newUser.uniqueID > 0 ? `${newUser.name}#${newUser.uniqueID}` : newUser.name; // add the user to the userList userList.push(newUser); // send a login event to this user socket.emit('userLogin', newUser); // create a new message about the connection let connectedMessage = { sender: 'Server', text: `${newUser.uniqueName} connected`, id: getID('message'), time: Date.now() }; // add the message to the chatHistory chatHistory.push(connectedMessage); // send the user connection to all connected sockets socketio.emit('userConnected', newUser); socketio.emit('chatMessage', connectedMessage); }); }
The handleUserConnected
function listens for the userConnected
event, and will respond appropriately.
First, we create a new user object and determine what the unique version of the username would be. For example, if two users named Rob
connected, one user would keep the username Rob
while the other would change to Rob#1
.
Once the user is created, we store it in the userList
then send an event notifying the user that they have now logged in. This event also sends the user object, so that the client knows which user it is, and has all the relevant information.
Next, we create a chat message that will notify all users of the new connection and update the clients with this new information.
You may have noticed that in this code snippet events were emitted using the socketio
object, while in the previous snippet emitted events used the socket
object. It is important to note the difference—socket.emit()
is sending an event to that specific socket, whereas socketio.emit()
is sending an event to all connected sockets, much like a broadcast.
const handleChatMessage = (socket) => { // listen for 'chatMessage' events socket.on('chatMessage', (chatMessage) => { // attach a message id and timestamp chatMessage.id = getID('message'); chatMessage.time = Date.now(); // add the message to the chat history chatHistory.push(chatMessage); // clear typing status for this user clearTypingStatus(chatMessage.sender.userID); // send the message to all connected sockets socketio.emit('chatMessage', chatMessage); }); }
The handleChatMessage
function listens for the chatMessage
event, and will respond appropriately. We receive a chat message from the client, add an ID and timestamp, then save it to our chat history. Then we ensure that we clear the typing status so that clients stop seeing this user as typing a message. Lastly, update all clients with the new message.
const handleTypingStatus = (socket) => { // listen for 'setTypingStatus' events socket.on('setTypingStatus', (typingStatus) => { // set typing status to true for this user userTypingStatus[typingStatus.user.userID] = { user: typingStatus.user, typing: true }; // set a timer to reset the typing status setStatusTimer(typingStatus.user.userID); // broadcast the typing status' socketio.emit('typingStatus', userTypingStatus); }); }
The handleTypingStatus
function listens for the setTypingStatus
event, and will respond appropriately. We receive an event from the client, then set the typing status of the user to true
and store that within an object using the user id as a key.
Next we start a timer that will revert the typing status back to false
after a certain amount of time. Lastly, we broadcast the current status of all our users, including the newly updated one.
const clearTypingStatus = (userID) => { // set typing status to false for this user userTypingStatus[userID].typing = false; // if a timer exists for this user, remove it removeStatusTimer(userID); // broadcast the typing status' socketio.emit('typingStatus', userTypingStatus); } const removeStatusTimer = (userID) => { // if this user id is a key of timers, clear the timer if (timers.hasOwnProperty(userID)) { clearTimeout(timers[userID]); } } const setStatusTimer = (userID) => { // if a timer exists for this user, remove it removeStatusTimer(userID); // set a timer to clear the typing status timers[userID] = setTimeout(() => { userTypingStatus[userID].typing = false; // broadcast the typing status' socketio.emit('typingStatus', userTypingStatus); }, typingStatusTime); }
These three functions assist in managing the typing status and associated timers of each user. The function clearTypingStatus
sets the status to false
and removes the timer immediately, whereas the function setStatusTimer
sets the status to false
after typingStatusTime
time has elapsed.
const getID = (type, username = undefined) => { let newID; if (username) { if (!id[type].hasOwnProperty(username)) { // this is a new username newID = id[type][username] = 0; } else { // this is a duplicate username newID = id[type][username]; } id[type][username] += 1; } else { // return the next id newID = id[type]; id[type] += 1; } return newID; } // export these functions for external use module.exports = { initialise, run };
The getID
function manages the distribution of IDs for our messages and users. Finally, we export the two functions that we want to access from our main server/index.js
file.
const express = require('express'); const http = require('http').Server(express); const socketio = require('socket.io')(http, { pingTimeout: 60000 }); const chat = require('./chat'); const port = 3030; chat.initialise(socketio); socketio.on('connection', (socket) => { // new socket connected chat.run(socket); }); http.listen(port, () => { console.log('Server started on port', port); });
After including the chat.js
file we created, we pass the socketio
instance to the initialise
function. Then, when the connection
event triggers we pass the new socket
connection through to the run
function.
Update the Client Functionality
Now that the server has been updated to handle our chat component, we need to update the client to store our chat data and respond to server events.
- Create
src/store/chat.js
and add the following code. - Open
src/store/index.js
and edit the code until you have the following. - Create
src/mixins/addEventListener.js
and add the following code.
This is the Vuex store for our chat room component. The store is a managed collection of data that we use as the middleman between the app front-end and the server. The front-end tells the store to send an event through the socket connection. When the app receives an event from the server through the socket connection, the store is updated with the new data. The app front-end only updates when there is new data in the store.
import Vue from 'vue'; export default { strict: false, namespaced: true, state: () => ({ users: [], history: [] }),
state
is the data that the store is holding. In this case we have users
and history
being kept in the store.
getters: { GET_USERS: (state) => { return state.users; }, GET_HISTORY: (state) => { return state.history; } },
The front-end uses getters
to retrieve data from the store.
mutations: { SET_USERS(state, users) { state.users = users; }, ADD_USER(state, user) { Vue.set(state.users, user.userID, user); }, SET_HISTORY(state, history) { state.history = history; }, ADD_MESSAGE(state, message) { Vue.set(state.history, message.id, message); } },
mutations
are used to change the data in the store somehow. For example, we can set an object collection entirely using SET_HISTORY
, or add a new element to a collection with ADD_USER
.
actions: { RECEIVE_USERS({ commit }, users) { commit('SET_USERS', users); }, RECEIVE_USER({ commit }, user) { commit('ADD_USER', user); }, RECEIVE_HISTORY({ commit }, history) { commit('SET_HISTORY', history); }, RECEIVE_MESSAGE({ commit }, message) { commit('ADD_MESSAGE', message); } } };
actions
are functions that can be called from the app front-end to perform a task, such as sending an event or receiving data. In our case, we would be calling an action from the front-end which in turn mutates our data in some fashion.
import Vue from 'vue'; import Vuex from 'vuex'; import * as socketio from '../plugins/socketio'; import chat from './chat'; Vue.use(Vuex); export default new Vuex.Store({ strict: false, actions: { SEND_EVENT({}, event) { socketio.sendEvent(event); } }, modules: { chat } });
Loading our new file into the store as a module allows us to maintain good object separation and the usage of namespaces to access particular parts of the store. We also added a SEND_EVENT
action in this file, as all of our future components will need to send information to the server.
import * as socketio from '../plugins/socketio'; export const addEventListener = { methods: { addEventListener(eventType, eventCallback) { socketio.addEventListener({ type: eventType, callback: eventCallback }); } } };
Mixins are essentially a reusable code snippet that can be loaded into any of our future components without the need to rewrite the code. This mixin defines a function addEventListener
which allows our components to specify which server events they will listen for, and how to react.
Create Chat Components
That takes care of the app-specific functionality. Now we can jump into building the front-end for our chat room. How should we tackle this? First let’s lay out the structure that we are aiming for. We’ll make it so that the app asks you to connect with a username before you begin, and once you are connected, you’ll see the chat room with components in this order:
- User Login
- Chat log
- Messages
- Typing notifications
- Send a message
- Text field
- Send button
- List of connected users
- User chips
User Login
When a user first loads the app, they’ll be confronted with a simple login field for them to select a username to enter the chatroom with.
- Create
src/components/ChatLogin.vue
and add the following code:
<template> <v-card-text> <v-text-field v-model="username" placeholder="Enter a username" @keydown.enter="setUsername()" ></v-text-field> <v-btn block @click="setUsername()"> Connect </v-btn> </v-card-text> </template> <script> export default { name: 'ChatLogin', data: () => ({ username: '' }), methods: { setUsername() { this.$emit('set-username', this.username); } } } </script>
This component contains a text field which takes the desired username, and handles emitting the data to the parent component.
New Dependencies
We can’t have a really nice chat room without some scrolling functionality. I think it would be ideal for the chat log to automatically scroll down when a new message arrives, but it would be annoying if you were manually scrolling back up to read an old message when that happened.
Well, luckily we can save time and use an existing library to handle this for us! A bit of searching online found this nice little package called VueChatScroll.
- Install VueChatScroll:
- Import into
src/main.js
:
npm install --save vue-chat-scroll
import Vue from 'vue'; import App from './App.vue'; import store from './store'; import vuetify from './plugins/vuetify'; import moment from 'moment'; import VueChatScroll from 'vue-chat-scroll'; Vue.use(VueChatScroll); Vue.config.productionTip = false; Vue.prototype.moment = moment; new Vue({ store, vuetify, render: (h) => h(App) }).$mount('#app');
Chat Log
The chat log will consist of messages and user typing notifications. It will be easiest if we work from the bottom up, so let’s start with individual messages. The easiest way to create a nice looking chat message is probably to use the v-alert component from Vuetify as a base and change up some settings.
- Create
src/components/ChatMessage.vue
and add the following code. - Create
src/components/ChatLog.vue
and add the following code.
<template> <v-alert class="my-1" dense outlined text max-width="80%" :icon="false" :border="getSetting('border')" :class="getSetting('align')" :type="getSetting('type')" > <div v-if="message.showSender" class="overline"> <b>{{ message.sender.uniqueName }}</b> </div> <div class="black--text"> {{ message.text }} </div> <div class="text-right time"> {{ formatTime(message.time) }} </div> </v-alert> </template>
First up, we’ll create a template that uses the v-alert
component. We’ll get some easy wins by setting the props to style the message component. The message will contain text and a timestamp, and show the sender name in some circumstances.
<script> export default { name: 'ChatMessage', props: { message: { type: Object, required: true } }, data: () => ({ messageSettings: { received: { align: 'align-self-start', border: 'left', type: 'error' }, sent: { align: 'align-self-end', border: 'right', type: 'info' }, server: { align: 'align-self-center', border: 'left', type: 'warning' } } }),
The message component takes a message object as a prop, and can have three types – sent
, received
, and server
messages.
methods: { getSetting(setting) { return this.messageSettings[this.message.type][setting]; }, formatTime(time) { if (this.moment(time).isSame(this.moment(), 'day')) { return this.moment(time).format('h:mm a'); } else { return this.moment(time).format('D MMMM, h:mm a'); } } } } </script> <style scoped> .time { font-size: 10px; line-height: 10px; } </style>
The getSetting
method handles retrieving the appropriate setting based on the message type, and the formatTime
method provides a nicely formatted timestamp.
<template> <v-card-text> <v-card> <div v-chat-scroll="scrollSettings" class="d-flex flex-column pa-4 overflow-y-auto log" ref="log" :style="scrollHeight" @scroll="onScroll" > <chat-message v-for="message of messagesToDisplay" :key="message.id" :message="message" /> <v-fab-transition> <v-btn v-if="showScrollButton" absolute class="scroll" color="primary" fab x-small @click="scrollToBottom()" > <v-icon>keyboard_arrow_down</v-icon> </v-btn> </v-fab-transition> </div> <v-expand-transition v-if="hasTypingStatus"> <div class="px-4 py-1 primary white--text caption"> {{ formattedTypingStatus }} </div> </v-expand-transition> </v-card> </v-card-text> </template>
The ChatLog
component handles the display of all chat messages. The v-chat-scroll
property of the div
element enables the scroll functionality provided by the VueChatScroll
package we installed earlier. There are also two conditional elements that may be rendered—a button which will scroll back to the bottom of the chat log, and the notifications for when another user is typing a message.
<script> import ChatMessage from './ChatMessage'; export default { name: 'ChatLog', props: { messages: { type: Array, required: true }, username: { type: String, required: true }, typingStatus: { type: Object, required: true } }, components: { ChatMessage }, data: () => ({ scrollSettings: { always: false, smooth: true }, scrollDistance: 0, logHeight: 400, hasScrolled: false, showScrollButton: false }),
This component takes in three props, an array of messages, the username of this user, and an object containing data on which other users are currently typing.
computed: { hasTypingStatus() { return this.formattedTypingStatus !== ''; }, messagesToDisplay() { let lastSender = ''; let messages = []; for (let message of this.messages) { message.type = this.getType(message); message.showSender = lastSender !== message.sender.uniqueName && message.type === 'received'; messages.push(message); lastSender = message.sender.uniqueName; } return messages; }, scrollHeight() { return `max-height: ${this.logHeight}px;`; }, formattedTypingStatus() { const users = Object.values(this.typingStatus).filter((status) => { return status.user.uniqueName !== this.username && status.typing }).map(status => status.user.uniqueName); if (users.length === 0) { return ''; } const userString = users.join(', ').replace(/,([^,]*)$/, ' and $1'); const userPlural = users.length > 1 ? 'are' : 'is'; return `${userString} ${userPlural} typing...` } },
There are several computed properties to help calculate variables used in the template. messagesToDisplay
is used to build the array of messages that is rendered by the component. At the same time it also determines if the sender of the message is the first in a series, so that we know when to show the username on the messages.
watch: { messages(value) { if (this.hasScrolled) { this.showScrollButton = true; } }, scrollDistance(value) { if (value === 0) { this.hasScrolled = false; this.showScrollButton = false; } } },
There are two properties being watched. When the value of messages
changes we check to see if the user has scrolled away from the bottom, and if they have we show the button that allows them to scroll back to the bottom. The other watched property scrollDistance
does the opposite, and checks to see when the scrollDistance
is 0
(at the bottom) and then hides the scroll button again.
methods: { getType(message) { return message.sender === 'Server' ? 'server' : message.sender.uniqueName === this.username ? 'sent' : 'received'; }, onScroll(event) { this.hasScrolled = true; this.scrollDistance = event.target.scrollHeight - event.target.scrollTop - this.logHeight; }, scrollToBottom() { this.$refs.log.scrollTop = this.$refs.log.scrollHeight; } } } </script>
There are three methods in the ChatLog
component, one to determine the type of the message, and two involving the scrolling behaviour. onScroll
triggers when the user scrolls the view, and calculates how far the log has scrolled from the bottom.
<style scoped> .log { scroll-behavior: smooth; } .scroll { right: 16px; } </style>
Lastly, we’ll add a small amount of styling. The scroll-behavior
css property will ensure the manual scrolling we added is smooth.
Send a Message
The messaging component of the app is fairly straightforward, with a simple v-textarea component.
- Create
src/components/ChatSend.vue
and add the following code:
<template> <v-card-text> <v-textarea v-model="newMessage" clearable placeholder="Send chat message" rows="3" :autofocus="true" :no-resize="true" :solo="true" @keydown.enter="sendMessage()" @keydown.enter.prevent @keydown="textChanged()" ></v-textarea> <v-btn block color="primary" @click="sendMessage()"> Send </v-btn> </v-card-text> </template> <script> export default { name: 'ChatSend', data: () => ({ newMessage: '' }), methods: { sendMessage() { if (this.newMessage !== '') { this.$emit('send', this.newMessage); this.newMessage = ''; } }, textChanged() { this.$emit('is-typing'); } } } </script>
First we create the template with a text area component and button, then handle the send
and is-typing
message events.
List of Connected Users
Once the number of users increases a simple text list would become insufficient, so let’s use a v-chip to display each user and help keep things easier to read.
- Create
src/components/ChatUserChip.vue
and add the following code: - Create
src/components/ChatUsers.vue
and add the following code:
<template> <v-chip class="mr-2 mb-2" :color="chipSettings[user.type].chipColour" > <v-avatar left> <v-icon :color="chipSettings[user.type].iconColour"> {{ chipSettings[user.type].icon }} </v-icon> </v-avatar> {{ user.uniqueName }} </v-chip> </template> <script> export default { name: 'ChatUserChip', props: { user: { type: Object, required: true } }, data: () => ({ chipSettings: { currentUser: { chipColour: 'primary', icon: 'person', iconColour: 'white' }, otherUser: { chipColour: 'default', icon: 'person', iconColour: 'error' } } }) } </script>
First, create the template—a chip with a username and icon with colour determined by chipSettings
. The chip takes one prop, a user
object which contains relevant data.
<template> <div> <v-card-subtitle>Connected Users</v-card-subtitle> <v-card-text> <chat-user-chip v-for="(user, i) of usersToDisplay" :key="i" :user="user" /> </v-card-text> </div> </template> <script> import ChatUserChip from './ChatUserChip'; export default { name: 'ChatUsers', props: { users: { type: Array, required: true }, username: { type: String, required: true } }, components: { ChatUserChip }, computed: { usersToDisplay() { let users = []; for (let user of this.users) { user.type = this.username === user.uniqueName ? 'currentUser' : 'otherUser'; users.push(user); } return users; } } } </script>
The template will display a chip for each user in the usersToDisplay
computed property. The same computed property will also determine if the user is the current user, or one of the other users, which will change the way the chip is rendered.
Bringing It All Together
Now we’ve built all of the components that we need to make the chat log work, let’s put the pieces together to create the final component.
- Create
src/components/Chat.vue
and add the following code. - Update
src/components/RealTimeDemo.vue
with the following code.
<template> <v-card class="elevation-0 grey lighten-4"> <div v-if="!hasUser"> <v-card-title>Connect to chat</v-card-title> <chat-login @set-username="setUsername" /> </div> <div v-else class="elevation-1 px-4"> <v-card-title>Connected as {{ user.uniqueName }}</v-card-title> <chat-log :messages="history" :typing-status="typingStatus" :username="user.uniqueName" /> <chat-send @is-typing="setTypingStatus" @send="sendMessage" /> <chat-users :users="users" :username="user.uniqueName" /> </div> </v-card> </template>
This template first checks to see if the user exists, and if not will display the ChatLogin
component. If the user is logged in, we’ll display their username and then show the ChatLog
, ChatSend
, and ChatUsers
components.
<script> import { mapGetters } from 'vuex'; import { addEventListener } from '../mixins/addEventListener'; import ChatLog from './ChatLog'; import ChatLogin from './ChatLogin'; import ChatSend from './ChatSend'; import ChatUsers from './ChatUsers'; export default { name: 'Chat', mixins: [addEventListener], components: { ChatLog, ChatLogin, ChatSend, ChatUsers }, data: () => ({ user: undefined, typingStatus: {} }),
The script section first imports the getters
that we created in the vuex store, as well as the mixin we created in the last last part of this blog series, and the chat components we just created.
computed: { ...mapGetters({ CHAT_USERS: 'chat/GET_USERS', CHAT_HISTORY: 'chat/GET_HISTORY' }), users() { return this.CHAT_USERS ? this.CHAT_USERS : []; }, history() { return this.CHAT_HISTORY ? this.CHAT_HISTORY : []; }, hasUser() { return !!this.user; } },
The computed ...mapGetters
property uses spread syntax as a shorthand for retrieving all of our store data for local use. users
and history
ensure that missing data is handled appropriately, and provides a way to safely access data.
mounted() { this.addEventListener('userList', (users) => { this.$store.dispatch('chat/RECEIVE_USERS', users); }); this.addEventListener('userConnected', (user) => { this.$store.dispatch('chat/RECEIVE_USER', user); }); this.addEventListener('userLogin', (user) => { this.user = user; }); this.addEventListener('chatHistory', (history) => { this.$store.dispatch('chat/RECEIVE_HISTORY', history); }); this.addEventListener('chatMessage', (message) => { this.$store.dispatch('chat/RECEIVE_MESSAGE', message); }); this.addEventListener('typingStatus', (typingStatus) => { this.typingStatus = typingStatus; }); },
Here we add event listeners for the server events that we set up earlier. As you can see, some of them end up having their data sent to the store to update our data, and others are used locally.
methods: { setUsername(username) { this.$store.dispatch('SEND_EVENT', { type: 'userConnected', data: username }); }, sendMessage(message) { this.$store.dispatch('SEND_EVENT', { type: 'chatMessage', data: { sender: this.user, text: message } }); }, setTypingStatus() { this.$store.dispatch('SEND_EVENT', { type: 'setTypingStatus', data: { user: this.user } }); } } } </script>
Lastly, add some methods to handle sending messages to the server.
<template> <v-container> <v-row> <v-col :cols="4"> <chat /> </v-col> <v-col :cols="8"> </v-col> </v-row> </v-container> </template> <script> import Chat from './Chat'; export default { name: 'RealTimeDemo', components: { Chat } } </script>
Finally, we can add our newly created Chat component to the RealTimeDemo component.
We can now test our new functionality. Start the server with the command node server
, and open the app in your browser. Enter a username and send some chat messages. Open the app multiple times in different browser tabs to see how it handles multiple connections.
Stop the server by pressing the keys ctrl+c
.
You can find this project in its entirety on GitHub.
Header image courtesy of NASA on Unsplash