How to create fast, scalable group chat functionality with Pusher and Vue
Leigh Ola
Posted on October 30, 2022
Implementing speedy group chat functionality with Pusher and Vue.
Pusher is used by industry giants such as The Washington Post, DoorDash and MailChimp What is Pusher?
Pusher is a tool that enables you to build scalable real-time communication. Fast.
Table Of Contents
Features
In this tutorial, We will use Pusher & Vue to build a very simple chat app that enables fast, real-time group messaging across multiple users.
Users will be able to create a username, join a group, and send and receive messages to/from all other online users in the group.
This app will have its backend built with NodeJS and Express, and its frontend built with Pusher and Vue.
Requirements
You will need:
Setting up Pusher
Step 1: Creating a Pusher Channel
- Sign up for a Pusher account.
- Next, head over to channels and click on "Create App".
- Provide the following information:
- App Name: This will be the name of the app on your pusher dashboard.
- Cluster: This is the location of the servers that will handle requests from your app. Choose a location nearest to you.
- Tech stack: This is optional. We'll use Vue.js for our frontend and Node.js for our backend.
Your setup configurations should look something like this:
Click on Create App to continue.
Step 2: Get your App Keys
To use Pusher channels, you need App Keys for your app.
- Head over to the App Keys section on your Pusher App dashboard.
- Copy your
app_id
,key
,secret
andcluster
.
Step 3: Enabling subscription counting.
Subscription count is an in-built pusher event that lets you know how many clients are connected to a single channel.
If this feature is enabled, Pusher will publish a subscription_count event whenever the number of subscribers on a given channel changes.
Here's how to enable this feature:
- From your Pusher App dashboard, head over to App Settings.
- Scroll down and toggle the switches to Enable subscription counting and Enable subscription count events in channels
Setting up the NodeJS backend
Step 1: Install packages
Run the following command to install the dependencies we'll need
npm i cors dotenv express pusher
Step 2: Add app keys to your project
Create a .env
file in your server directory and paste the Pusher app keys you copied earlier
app_id = "1499155"
key = "326ba7284eae570f59ac"
secret = "0919a7fb769182a2ef69"
cluster = "eu"
Remember to change those values to match your own Pusher app keys.
Step 3: Create express server
Next, create an app.js
file in your server directory and add the following code:
const express = require("express");
const Pusher = require("pusher");
const cors = require("cors");
require("dotenv").config();
const app = express();
const port = process.env.PORT || 3000;
const { app_id, key, secret, cluster } = process.env;
app.use(express.json());
app.use(cors());
app.use(express.static("public"));
app.listen(port, () => {
console.log(`Group chat server started at http://localhost:${port}`);
});
This sets up a basic express http server and fetches the environment variables
Step 4: Messaging with Pusher
Add the below snippet to your app.js
to connect to your Pusher app:
// Setup Pusher
const pusher = new Pusher({
appId: app_id,
key,
secret,
cluster,
});
Next, add the following snippet:
// send message data to all clients connected to a channel
function broadcastMessage({ message, sender, timestamp, channel, user_id }) {
// create "message-in" event for each new message received
pusher
.trigger(channel, "message-in", {
message,
sender,
user_id,
timestamp,
})
.catch((error) => {
console.log("Error pushing to client:", error);
});
}
// receive message data from client
app.post("/message", (req, res) => {
broadcastMessage(req.body);
res.send("OK");
});
This creates an endpoint to receive messages from a connected user, and then broadcast the message to all the users on the channel.
Finally, open up your terminal and run:
node app.js
Thats all for NodeJS!
Setting up the Vue frontend
Next, create a /public
in your server directory and in it, create the following files:
index.HTML
index.css
index.js
Creating a Vue instance and connecting to Pusher
Open up the index.js
file and add the following code:
const PusherAppKey = "d1d37aa9b3e2dd707d91";
const PusherAppCluster = "eu";
var pusher;
var channel;
var user_id;
let app = new Vue({
el: "#app",
data() {
return {
connectionState: null,
stage: "subscribe", // subscribe or chat
//
username: "",
usernamePlaceholder: "Username",
currentChannel: "", // remove this
onlineUsersCount: 1,
//
message: "",
selectedMessageTimestamp: null,
messages: [],
channels: {
Programming: "programming",
"Tech News": "tech-news-channel",
"Dad Jokes": "dad-jokes-channel",
},
};
},
watch: {
username(curr, prev) {
// limit usernames to 20 characters
if (String(curr).trim().length > 20) this.username = prev;
},
},
beforeMount() {
// Generate unique user id
user_id = Math.random().toString(36).substring(2);
connectToPusher();
// Watch pusher connection state for changes
pusher.connection.bind("state_change", (states) => {
this.connectionState = states.current;
});
},
mounted() {
this.openChannel();
},
methods: {
verifyInput() {
if (!this.username) {
this.usernamePlaceholder = "Please enter a username";
return false;
}
if (!this.currentChannel) return false;
return true;
},
openChannel() {
// if pusher is not connected, reconnect
if (this.connectionState !== "disconnected") {
connectToPusher();
}
// ensure inputs are valid before proceeding
if (!this.verifyInput()) return;
// subscribe to the chosen channel
let channelId = this.channels[this.currentChannel];
channel = pusher.subscribe(channelId);
// move to the chats screen
this.stage = "chat";
// listen for a message events on the channel
channel.bind("message-in", (data) => {
// format and add the message to the list
data.type = data.user_id == user_id ? "outgoing" : "incoming";
data.dateTime = new Date(data.timestamp).toLocaleString().split(", ");
this.messages.push(data);
// scroll to bottom of the chats box after a new message is added
this.$nextTick(() => {
let chatsBox = document.querySelector(".chats-box");
chatsBox.scrollTop = chatsBox.scrollHeight;
});
});
// listen for a subscription count event on the channel
channel.bind("pusher:subscription_count", (data) => {
this.onlineUsersCount = data.subscription_count;
});
},
},
});
function connectToPusher() {
// instantiate Pusher
pusher = new Pusher(PusherAppKey, {
cluster: "eu",
});
}
This connects the Pusher client to our Pusher app instance. Remember to change This generates a unique user ID for the user, then it calls the Finally, it binds a function to watch for changes to the Pusher instance's connection. The content of the This event allows us to show how many users are online in a channel at any given time.That's a lot of code. Here's a summary:
let app = new Vue({
el: "#app",
// vue instance options...
});
function connectToPusher() {
// instantiate Pusher
pusher = new Pusher(PusherAppKey, {
cluster: PusherAppCluster,
});
}
PusherAppKey
and PusherAppCluster
to match your own Pusher app key values.
beforeMount(){
// Generate unique user id
user_id = Math.random().toString(36).substring(2);
connectToPusher();
// Watch pusher connection state for changes
pusher.connection.bind("state_change", (states) => {
this.connectionState = states.current;
});
}
connectToPusher()
function we created earlier.
Listening for channel events
openChannel()
method is especially important, so let's go through it.
channel = pusher.subscribe(channelId);
message-in
events on that channel. This function gets triggered every time there's a new message on the channel. It then adds the message data to our Vue instance.
channel.bind("message-in", (data) => {
/* some other code */
this.messages.push(data);
/* some other code */
});
subscription_count
event on the channel. This is a Pusher event that gets automatically triggered each time a client connects to a channel.
channel.bind("pusher:subscription_count", (data) => {
this.onlineUsersCount = data.subscription_count;
});
Sending Messages
Add the following function to your Vue instance methods:
sendMessage(event) {
if (event && event.key) {
if (event.key != "Enter") return;
}
if (!this.message) return;
let channelId = this.channels[this.currentChannel];
// send a fetch post request
let data = {
message: this.message,
sender: this.username,
timestamp: new Date().getTime(),
channel: channelId,
user_id,
};
fetch("/message", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
.then(() => {
this.message = "";
})
.catch((e) => {
console.log("Error sending message", e);
});
}
Due to our server-side code, when this Here's what we just did:
let data = {
//
}
/message
endpoint:
fetch("/message", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
/message
endpoint is called, the message-in
event is triggered on the Pusher Channel, with the same data we just sent via our http request.
Disconnecting from a channel
Add one final function to your Vue instance methods:
disconnect() {
this.stage = "subscribe";
this.messages = [];
this.onlineUsersCount = 1;
pusher.disconnect();
}
This will disconnect from the current channel, clear all the data on our Vue instance related to that channel, and return the user to the group selection menu.
Creating and styling our HTML
Go to the index.js
file and paste this snippet:
<!DOCTYPE HTML>
<HTML lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="./index.css">
</head>
<body>
<main id="app" v-cloak>
<div class="subscribe-container" v-show="stage=='subscribe'">
<header>
Select A Group
</header>
<div class="subscribe-items">
<div class="subscribe-item" v-for="(v, name) in channels" :class="{selected: currentChannel==name}" @click="currentChannel=name">
<span>{{name}}</span>
</div>
</div>
<input type="text" v-model="username" :placeholder="usernamePlaceholder">
<button :class="{disabled: !username || !currentChannel}" @click="openChannel">Proceed</button>
</div>
<div class="chats-container" v-show="stage=='chat'">
<header>
<span>{{currentChannel}}</span>
<small>
<ins></ins> {{onlineUsersCount}} users online
</small>
<button @click="disconnect">EXIT</button>
</header>
<div class="chats-box">
<div class="chat-item"
v-for="({sender, message, timestamp, type, dateTime}, i) in messages"
:class="{out: type=='outgoing', selected: selectedMessageTimestamp==timestamp}"
@click="selectedMessageTimestamp=timestamp"
>
<div class="chat-item-content">
<div class="username">{{sender}}</div>
<span class="chat-message">{{message}}</span>
</div>
<span class="timestamp">
<small v-for="item in dateTime">
{{item}}
</small>
</span>
</div>
</div>
<footer>
<input type="text" placeholder="Type a message" v-on:keypress="sendMessage($event)" v-model="message" id="message">
<button id="send" @click="sendMessage">Send</button>
</footer>
</div>
</main>
</body>
<!-- axios -->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<!-- vue -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.13"></script>
<!-- pusher -->
<script src="https://js.pusher.com/7.2/pusher.min.js"></script>
<!-- -->
<script src="./index.js"></script>
</HTML>
Finally, it's time to style our page. Open up your index.css
file and insert this:
/* import roboto font */
@import url("https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap");
/* import ubuntu font */
@import url("https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700&display=swap");
[v-cloak] {
visibility: hidden;
}
body {
padding: 0px;
margin: 0px;
font-size: 16px;
width: 100%;
position: relative;
font-family: "Roboto", sans-serif;
}
#app {
display: flex;
height: 100vh;
max-height: 100vh;
width: 100%;
max-width: 100vw;
position: relative;
background: white;
top: 0px;
left: 0px;
padding: 0px;
}
/* the popup stage */
.subscribe-container {
border-radius: 5px;
display: flex;
flex-flow: column nowrap;
box-shadow: 5px 5px 4px 0px rgba(0, 0, 0, 0.75);
border: 2px solid rgba(0, 0, 0, 0.75);
box-sizing: border-box;
width: 300px;
/* height: ; */
/* height: 300px; */
max-height: 300px;
margin: auto;
overflow: hidden;
}
.subscribe-container header {
width: 100%;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: rgb(63, 81, 181);
color: white;
font-weight: bold;
}
.subscribe-container .subscribe-items {
width: 100%;
height: auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow-y: scroll;
}
/* no scrollbar */
.subscribe-container .subscribe-items::-webkit-scrollbar {
display: none;
}
.subscribe-container .subscribe-items .subscribe-item {
display: flex;
flex-direction: column;
width: 100%;
justify-content: center;
align-items: center;
height: 40px;
margin: 5px auto;
width: 96%;
border-radius: 3px;
flex-shrink: 0;
color: rgb(63, 81, 181);
font-weight: bold;
}
.subscribe-container .subscribe-items .subscribe-item:hover {
background-color: rgba(63, 81, 181, 0.1);
}
.subscribe-container .subscribe-items .subscribe-item.selected {
background-color: rgba(63, 81, 181, 0.25);
}
.subscribe-container .subscribe-items .subscribe-item span {
text-transform: uppercase;
font-size: 1.1em;
}
.subscribe-container .subscribe-items .subscribe-item small {
font-size: 0.7em;
}
/* username & submit */
.subscribe-container input {
background: white;
height: 40px;
width: 100%;
padding: 5px;
border: none;
margin-top: 5px;
text-align: center;
font-size: 0.85em;
color: rgb(63, 81, 181);
}
.subscribe-container button {
width: 100%;
height: 40px;
border: none;
background-color: rgb(21, 138, 1);
color: white;
text-transform: uppercase;
font-size: 1em;
}
.subscribe-container button.disabled {
background-color: rgba(21, 138, 1, 0.5);
color: rgba(255, 255, 255, 0.5);
}
.subscribe-container button:active {
background-color: rgba(21, 138, 1, 0.5);
color: white;
}
/* the chat stage */
.chats-container {
display: flex;
flex-flow: column nowrap;
width: 100%;
height: 100%;
justify-content: space-between;
}
/* chat header */
.chats-container > header {
display: flex;
width: 100%;
height: 50px;
background-color: rgb(63, 81, 181);
/* justify-content: space-between; */
align-items: center;
padding: 0px 5px 0px 20px;
color: white;
box-sizing: border-box;
}
.chats-container > header > span {
font-weight: bold;
font-size: 1.1em;
text-transform: uppercase;
}
.chats-container > header > small {
/* position: relative; */
/* right: 30px; */
margin-left: 10px;
font-size: 0.7em;
display: flex;
}
.chats-container > header > small ins {
width: 5px;
height: 5px;
margin: auto 3px;
content: "";
flex-shrink: 0;
display: inline-block;
border-radius: 100%;
background: greenyellow;
}
.chats-container > header > button {
margin-left: auto;
border: none;
background: transparent;
border-radius: 2px;
font-size: 0.7em;
color: white;
text-transform: uppercase;
padding: 10px;
color: red;
}
.chats-container > header > button:hover {
background-color: rgba(0, 0, 0, 0.25);
}
.chats-container > header > button:active {
color: white;
background: red !important;
}
/* chat footer */
.chats-container > footer {
display: flex;
width: calc(100% - 8px);
height: 50px;
flex-shrink: 0;
justify-content: space-between;
align-items: center;
color: white;
margin: 0px 2px 2px 2px;
padding: 0px;
border: 2px solid rgb(63, 81, 181);
border-radius: 50px;
overflow: hidden;
position: relative;
outline: none;
bottom: 0px;
position: fixed;
}
.chats-container > footer * {
border: none;
}
.chats-container > footer #message {
background: white;
width: 100%;
max-width: calc(100% - 80px);
height: 100%;
padding: 18px 15px;
font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif;
box-sizing: border-box;
}
.chats-container > footer #message::-webkit-scrollbar {
display: none;
}
#message,
#message:focus {
outline: none !important;
}
.chats-container > footer button {
flex-shrink: 0;
height: 46px;
width: 80px;
margin: auto 2px;
background: rgb(63, 81, 181);
color: white;
border-radius: 50px;
border: 0px;
font-weight: bold;
text-transform: uppercase;
}
/* main chats box */
.chats-container .chats-box {
flex-grow: 1;
width: 100%;
max-height: calc(100% - 108px);
display: flex;
overflow: hidden;
overflow-y: scroll;
position: fixed;
top: 50px;
margin: 0px;
flex-flow: column nowrap;
box-sizing: border-box;
padding: 5px 0px;
margin-right: 40px;
}
.chats-container .chats-box::-webkit-scrollbar {
display: none;
}
.chats-box .chat-item {
width: 100%;
display: flex;
box-sizing: border-box;
padding: 0px 5px;
justify-content: space-between;
}
.chats-box .chat-item:not(:last-of-type) {
margin-bottom: 3px;
}
.chats-box .chat-item.selected {
background: rgba(63, 81, 181, 0.15);
padding: 2px 5px;
}
.chat-item .chat-item-content {
position: relative;
z-index: 2;
max-width: 400px;
min-width: 150px;
min-height: 40px;
display: flex;
flex-flow: column nowrap;
border-radius: 20px;
background: rgb(60, 111, 238);
color: white;
padding: 0px 8px 8px 8px;
box-sizing: border-box;
font-family: Ubuntu, Arial, Helvetica, sans-serif;
}
.chat-item.out .chat-item-content {
order: 1;
background: rgb(21, 138, 1);
}
.chat-item .chat-item-content::after {
content: " ";
position: absolute;
z-index: 1;
bottom: 0px;
left: 0px;
background: inherit;
padding: 6px;
border-radius: 50%;
}
.chat-item.out .chat-item-content::after {
left: auto;
right: 0px;
}
.chat-item-content .username {
font-size: 0.7em;
width: 100%;
position: relative;
left: 15px;
right: auto;
top: 3px;
display: block;
/* text-align: right; */
/* margin: auto 5px auto 15px; */
justify-content: space-between;
/* */
color: rgb(179 249 167);
}
.chat-item.out .chat-item-content .username {
box-sizing: border-box;
/* background: white; */
text-align: right;
right: 18px;
left: auto;
}
.chat-item-content .chat-message {
margin-top: 3px;
width: auto;
max-width: 100%;
/* */
word-wrap: break-word;
white-space: pre-line;
}
.chat-item.out .chat-item-content .chat-message {
text-align: right;
}
.chat-item .timestamp {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 0.7em;
color: black;
margin: auto 20px;
text-transform: uppercase;
font-weight: bold;
visibility: hidden;
}
.chats-box .chat-item.selected .timestamp {
visibility: visible;
}
.chat-item .timestamp * {
display: block;
}
That's All!
Results
Conclusion
This article has taken you through the basic steps required to create a fast, scalable, real-time group-chat application with Vue and Pusher.
Check out the project repo on GitHub. Feel free to fork and contribute.
š
Posted on October 30, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.