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.





Please disable your adblocker or whitelist this site!