28. Group chat with attachments
Now we need to create a group chat feature where all members of the group can chat. We already have a separate collection of “messages” where all our messages are being stored. But right now, we only have private chat messages. From now on, we will also be storing group chat messages in it. So we need to distinguish between private chat messages and group chat messages.
We will be using a key “type” and its value must be either “private” or “group”. This will distinguish between private and group messages.
Pre group chat
So before moving to the group chat feature, we need to set the private chat message to store that key. You can check our private chat tutorial to understand this better. So open your api/modules/chat.js file and in the “/send” POST route, you will see an object named “object”. You need to add another value to it named “type” and here its value will be “private”:
const object = {
message: hw,
sender: {
_id: user._id,
name: user.name,
email: user.email
},
receiver: {
_id: receiver._id,
name: receiver.name,
email: receiver.email
},
type: "private",
isRead: false,
createdAt: createdAt
};
Then we need to add that field in messageObject object too.
const messageObject = {
_id: document.insertedId,
message: message,
sender: object.sender,
receiver: object.receiver,
attachment: object.attachment,
type: "private",
isRead: false,
createdAt: createdAt
};
Then we need to change our “/fetch” API. While fetching all the chat messages, we need to check if this key does not exist and if its value is “private”. Since old messages do not have this key, so we will be using Mongo DB $exists operator.
const messages = await db.collection("messages").find({
$and: [{
$or: [{
"sender._id": user._id,
"receiver._id": receiver._id
}, {
"sender._id": receiver._id,
"receiver._id": user._id
}]
}, {
// previous messages will not have "type" key
// so we will know that they are private messages
$or: [{
type: {
$exists: false
}
}, {
// and future messages will have type=private
type: "private"
}]
}]
})
We just need to change the above query in the “/fetch” API and it will work fine.
Send a message to the group
Since we also are encrypting the group chat messages, so you need to move the following lines from “chat.js” to “globals.js” so we can access it in other files too. And also add the “global” keyword before each function.
const crypto = require('crypto');
const algorithm = 'aes-256-cbc'; // Using AES encryption
const key = "adnan-tech-programming-computers"; // must be of 32 characters
global.encrypt = function (text) {
const iv = crypto.randomBytes(16);
// protected data
const message = text;
// the cipher function
const cipher = crypto.createCipheriv(algorithm, key, iv);
// encrypt the message
// input encoding
// output encoding
let encryptedData = cipher.update(message, "utf-8", "hex");
encryptedData += cipher.final("hex");
const base64data = Buffer.from(iv, 'binary').toString('base64');
return {
iv: base64data,
encryptedData: encryptedData
};
};
global.decrypt = function (text) {
const origionalData = Buffer.from(text.iv, 'base64')
const decipher = crypto.createDecipheriv(algorithm, key, origionalData);
let decryptedData = decipher.update(text.encryptedData, "hex", "utf-8");
decryptedData += decipher.final("utf8");
return decryptedData;
};
// function to encode file data to base64 encoded string
global.base64Encode = function(file) {
// read binary data
var bitmap = fileSystem.readFileSync(file);
// convert binary data to base64 encoded string
return new Buffer.from(bitmap).toString('base64');
};
Now we can move and start work on group chat.
Create chat layout
Create another row in GroupsComponent.vue to display the chat layout:
<div class="row clearfix" style="margin-top: 30px;">
<div class="col-lg-12">
<div class="card chat-app">
<div class="chat">
<div class="chat-header clearfix">
<div class="row">
<div class="col-lg-12 text-white">
<span v-if="attachment != null" style="margin-right: 10px; position: relative; top: 7px;" v-text="attachment.name"></span>
<a href="javascript:void(0);" class="btn btn-outline-secondary pull-right text-white" v-on:click="selectFile"><i class="fa fa-paperclip"></i></a>
<input type="file" id="attachment" style="display: none;" v-on:change="fileSelected" />
</div>
</div>
</div>
<div class="chat-history">
<ul class="m-b-0">
<li style="text-align: center;">
<i v-bind:class="btnLoadMoreClass + ' btnLoadMore'" v-on:click="loadMore" v-if="hasMoreMessages"></i>
</li>
<li v-for="msg in messages" class="clearfix" v-bind:key="msg._id">
<div v-bind:class="'message-data ' + (user != null && user.email == msg.sender.email ? 'text-right' : '')">
<span class="message-data-time text-white" v-text="getMessageTime(msg.createdAt)"></span>
<img src="https://bootdey.com/img/Content/avatar/avatar7.png" alt="avatar" style="width: 50px;" />
</div>
<div v-bind:class="'message ' + (user != null && user.email == msg.sender.email ? 'my-message float-right' : 'other-message')">
<p v-text="msg.message" v-bind:class="(user != null && user.email == msg.sender.email ? 'text-right' : '')" style="margin-bottom: 0px;"></p>
<template v-if="msg.attachment != null">
<a href="javascript:void(0)" v-bind:data-id="msg._id" v-on:click.prevent="downloadAttachment" v-text="msg.attachment.displayName" class="text-info" target="_blank"></a>
</template>
</div>
</li>
</ul>
</div>
<div class="chat-message clearfix">
<div class="input-group mb-0">
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Enter text here..." v-model="message" />
<button class="btn btn-primary" v-on:click="sendMessage" type="button">Send</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
Send message
Then create the following 2 properties in the data() object:
attachment: null,
message: "",
Then create the following functions in the methods object:
sendMessage: async function () {
const formData = new FormData()
formData.append("_id", this._id)
formData.append("message", this.message)
if (this.attachment != null) {
formData.append("attachment", this.attachment)
}
const response = await axios.post(
this.$apiURL + "/groups/sendMessage",
formData,
{
headers: this.$headers
}
)
if (response.data.status == "success") {
this.message = ""
this.attachment = null
document.getElementById("attachment").value = null
// store.commit("appendMessage", response.data.messageObject)
} else {
swal.fire("Error", response.data.message, "error")
}
},
fileSelected: function () {
const files = event.target.files
if (files.length > 0) {
this.attachment = files[0]
}
},
selectFile: function () {
document.getElementById("attachment").click()
},
And in your api/modules/groups.js file, first, include the globals module:
require("./globals");
Then create an API that will handle the request:
router.post("/sendMessage", auth, async function (request, result) {
const user = request.user;
const _id = request.fields._id;
const message = request.fields.message;
const createdAt = new Date().getTime();
const attachment = request.files.attachment; // size (bytes), path, name, type, mtime
if (!_id || !message) {
result.json({
status: "error",
message: "Please enter all fields."
});
return;
}
// Text send to encrypt function
const hw = encrypt(message);
// check if group exists
const group = await db.collection("groups").findOne({
_id: ObjectId(_id)
});
if (group == null) {
result.json({
status: "error",
message: "Group does not exists."
});
return;
}
// check if member or admin
let isMember = false;
let status = "";
for (let a = 0; a < group.members.length; a++) {
if (group.members[a].user._id.toString() == user._id.toString()) {
isMember = true;
status = group.members[a].status;
break;
}
}
const isAdmin = (group.createdBy._id.toString() == user._id.toString());
if (!isAdmin && !isMember) {
result.json({
status: "error",
message: "You are not a member of this group."
});
return;
}
// check if has accepted the invitation
if (!isAdmin && status != "accepted") {
result.json({
status: "error",
message: "Sorry, you have not accepted the invitation to join this group yet."
});
return;
}
const object = {
message: hw,
sender: {
_id: user._id,
name: user.name,
email: user.email
},
receiver: {
_id: group._id,
name: group.name
},
type: "group",
createdAt: createdAt
};
if (attachment != null && attachment.size > 0) {
const dateObj = new Date();
const datetimeStr = dateObj.getFullYear() + "-" + (dateObj.getMonth() + 1) + "-" + dateObj.getDate() + " " + dateObj.getHours() + ":" + dateObj.getMinutes() + ":" + dateObj.getSeconds();
const fileName = "ChatStation-" + datetimeStr + "-" + attachment.name;
const filePath = "uploads/groups/" + fileName;
object.attachment = {
size: attachment.size,
path: filePath,
name: fileName,
displayName: attachment.name,
type: attachment.type
};
fileSystem.readFile(attachment.path, function (error, data) {
if (error) {
console.error(error);
}
fileSystem.writeFile(filePath, data, function (error) {
if (error) {
console.error(error);
}
});
fileSystem.unlink(attachment.path, function (error) {
if (error) {
console.error(error);
}
});
});
}
const document = await db.collection("messages").insertOne(object);
const messageObject = {
_id: document.insertedId,
message: message,
sender: object.sender,
receiver: object.receiver,
attachment: object.attachment,
type: "group",
isRead: false,
createdAt: createdAt
};
result.json({
status: "success",
message: "Message has been sent.",
messageObject: messageObject
});
});
You can test the app now. Type any message in the input field and hit “Send”. You will see the input field becomes empty. But you will not see the message now, we will do it later in this series.
View group chat
The first thing we need to do is to show the messages when this component is loaded.
First, open your vuex/store.js file and create a new array for group messages.
export default createStore({
state() {
return {
...
groupMessages: [],
}
},
})
Then we need to create its mutators.
mutations: {
...
prependGroupMessage (state, newMessage) {
state.groupMessages.unshift(newMessage)
},
setGroupMessages (state, newMessages) {
state.groupMessages = newMessages
},
}
And of course, we need to create its getter as well.
getters: {
getGroupMessages (state) {
return state.groupMessages
},
...
}
Then in your GroupDetailComponent.vue file, first import the store file.
import store from "../vuex/store"
Then create the following values in your data() object:
data() {
return {
...
page: 0,
btnLoadMoreClass: "fa fa-repeat",
hasMoreMessages: true,
}
},
And we need to create a computed property that will return the group messages array.
computed: {
messages() {
return store.getters.getGroupMessages
}
},
Also, we need to empty the group messages array when the user leaves this page. Otherwise, it will keep appending the messages because we are using the Vuex store. So, create a watch property:
watch: {
$route: function (to, from) {
if (from.href.includes("/groups/detail/" + this._id)) {
store.commit("setGroupMessages", [])
}
}
},
Then we need to create a new method in our methods object that will return the message time in a proper format.
getMessageTime: function (time) {
const dateObj = new Date(time)
let timeStr = dateObj.getFullYear() + "-" + (dateObj.getMonth() + 1) + "-" + dateObj.getDate() + " " + dateObj.getHours() + ":" + dateObj.getMinutes() + ":" + dateObj.getSeconds()
return timeStr
},
We need to change our getData() function to the following:
// get group document from AJAX
getData: async function () {
const formData = new FormData()
formData.append("_id", this._id)
formData.append("page", this.page)
const response = await axios.post(
this.$apiURL + "/groups/detail",
formData,
{
headers: this.$headers
}
)
if (response.data.status == "success") {
this.group = response.data.group
this.user = response.data.user
for (let a = 0; a < response.data.messages.length; a++) {
store.commit("prependGroupMessage", response.data.messages[a])
}
this.hasMoreMessages = (response.data.messages.length == 0) ? false : true
} else {
swal.fire("Error", response.data.message, "error")
}
}
First, we are sending page value in the form of the data object. And when the response is received, we are prepending the messages in the group messages array. Now we need to change our group detail API to return the group messages as well.
Create API to fetch messages
Before that, open your api/modules/globals.js file and create the following function:
global.getGroupChat = async function (request, group) {
const page = request.fields.page ?? 0;
const limit = 10;
const messages = await db.collection("messages").find({
$and: [{
"receiver._id": group._id
}, {
type: "group"
}]
})
.sort("createdAt", -1)
.skip(page * limit)
.limit(limit)
.toArray();
const data = [];
for (let a = 0; a < messages.length; a++) {
data.push({
_id: messages[a]._id.toString(),
message: decrypt(messages[a].message),
sender: {
email: messages[a].sender.email,
name: messages[a].sender.name
},
createdAt: messages[a].createdAt,
attachment: messages[a].attachment
});
}
return data;
};
We are creating this function in globals because we will be accessing it when the load more button is clicked. And the user wants to fetch old messages.
Then open the api/server.js file and go to the “/detail” API. We just need to change the response and attach the messages as well with the response.
// get messages
const messages = getGroupChat(request, group);
messages.then(function (chatMessages) {
result.json({
status: "success",
message: "Data has been fetched.",
group: group,
user: user,
messages: chatMessages
});
});
You are free to test the app now. Refresh the page and you will be able to see the messages you have sent in this group chat. Encryption and decryption is applied on group chat too.
Now we need to download the attachments too.
Download group chat attachments
To download attachments from the group chat, first, you need to include the NPM file system module in our api/modules/globals.js file:
const fileSystem = require("fs");
Then in your GroupDetailComponent.vue, add the following tag before the closing <template> tag.
<a v-bind:href="base64Str" ref="btnDownloadAttachment" v-bind:download="downloadFileName"></a>
Then in your data() object, we need to create the following 2 variables:
base64Str: "",
downloadFileName: "",
After that, we need to create a method in our methods object that will get the base64 string from the server and download the file in the user’s browser.
downloadAttachment: async function () {
const anchor = event.target
const id = anchor.getAttribute("data-id")
const originalHtml = anchor.innerHTML
anchor.innerHTML = "Loading..."
const formData = new FormData()
formData.append("messageId", id)
const response = await axios.post(
this.$apiURL + "/groups/attachment",
formData,
{
headers: this.$headers
}
)
if (response.data.status == "success") {
this.base64Str = response.data.base64Str
this.downloadFileName = response.data.fileName
const btnDownloadAttachment = this.$refs["btnDownloadAttachment"]
setTimeout(function () {
btnDownloadAttachment.click()
anchor.innerHTML = originalHtml
}, 500)
} else {
swal.fire("Error", response.data.message, "error")
}
},
This calls an AJAX request to get the base64 string. So we need to create an API that will handle this request. So create the following POST route in api/modules/groups.js file:
router.post("/attachment", auth, async function (request, result) {
const messageId = request.fields.messageId;
const user = request.user;
const message = await db.collection("messages").findOne({
_id: ObjectId(messageId)
});
if (message == null) {
result.status(404).json({
status: "error",
message: "Message not found."
});
return;
}
// get group
const group = await db.collection("groups").findOne({
_id: message.receiver._id
});
if (group == null) {
result.status(404).json({
status: "error",
message: "Group does not exists."
});
return;
}
// check if user is admin or member of the group
let isMember = false;
for (let a = 0; a < group.members.length; a++) {
if (group.members[a].user._id.toString() == user._id.toString()) {
isMember = true;
break;
}
}
const isAdmin = group.createdBy._id.toString() == user._id.toString();
if (!isAdmin && !isMember) {
result.json({
status: "error",
message: "Sorry, you are not a member of this group."
});
return;
}
let attachment = message.attachment;
const base64Str = "data:" + attachment.type + ";base64," + base64Encode(attachment.path);
result.json({
status: "success",
message: "Attachment has been fetched",
base64Str: base64Str,
fileName: attachment.displayName
});
});
Now click on any of the attachments in the message and you will see the file will be downloaded to your system.
Append message in chat when sent
Right now, if you send a message, you either have to refresh the page. Or re-open the group detail component to see your message. However, it should automatically be appended when it is sent. To do that, first, open your vuex/store.js file and create the following function in your mutations object:
appendGroupMessage (state, newMessage) {
state.groupMessages.push(newMessage)
},
Then open your GroupDetailComponent.vue file, in the sendMessage function when the response status is “success”. Write the following line:
store.commit("appendGroupMessage", response.data.messageObject)
We are already receiving the messageObject from the API. Now try to send another message to the group. Now it will be displayed as soon as you send it.