“Talckatoo: Your Real-Time Multilingual Chat Solution Built with SocketIO, Express.js, and React!” — Part 2

miminiverse

miminiverse

Posted on August 15, 2023

“Talckatoo: Your Real-Time Multilingual Chat Solution Built with SocketIO, Express.js, and React!” — Part 2

In the previous part, I provided an overview of our powerful chat application which offers real-time multilingual translations.

In this part, we will delve into the backend how we set up all the routes, and also how we use socketIO to get real-time functionalities.

Our application revolves around three essential models: User, Conversation, and Message.

In essence, our route structure is designed to achieve our ultimate goal of displaying messages. This involves a series of HTTP requests:

Making a POST request to authenticate and log in the user
Making a GET request to get comprehensive information about other users.
Making a GET to fetch both the conversation and associated messages
Let’s start with the User model. Within this model, each user will maintain a list of their conversations with others. Additionally, users will be able to set their preferred translated language during sign-up, thus enhancing the multilingual communication experience.


const UserSchema = new Schema<Iuser>({
  userName: {
    type: String,
    unique: true,
    required: true,
    minLength: 5,
  },
  email: {
    type: String,
    required: true,
    unique: true,
    validate: [validator.isEmail, "please enter a valid email address"],
  },
  password: {
    type: String,
    minlength: 5,
  },
  conversations: [
    {
      type: Schema.Types.ObjectId,
      ref: "Conversation",
    },
  ],
  profileImage: {
    public_id: String,
    url: String,
  },
  language: {
    type: String,
  },
  welcome: {
    type: String,
  },
});
Enter fullscreen mode Exit fullscreen mode

Moving on to the Conversation model, it serves as a container for all the messages exchanged within a particular conversation. Moreover, the model also associates the users who are part of that conversation. This setup ensures that messages are organized and accessible within the context of their relevant discussions, while maintaining a clear link to the users involved.


const conversationSchema = new Schema<Iconversation>(
  {
    messages: [
      {
        type: Schema.Types.ObjectId,
        ref: "Message",
      },
    ],
    users: [
      {
        type: Schema.Types.ObjectId,
        ref: "User",
      },
    ],
  },
  { timestamps: true }
);
Enter fullscreen mode Exit fullscreen mode

Lastly, let’s look at the Message model. An important aspect of this model is that it includes the sender’s information. This addition proves invaluable in distinguishing between the sender and recipient of each message, which in turn facilitates the seamless configuration of APIs and enhances the overall communication process.


const messageSchema = new Schema<Imessage>(
  {
    message: {
      type: String,
      minlength: 1,
      maxlength: 2000,
    },
    sender: {
      type: Schema.Types.ObjectId,
      ref: "User",
      required: true,
    },
    voiceNote: {
      public_id: String,
      url: String,
    },
  },
  { timestamps: true }
);
Enter fullscreen mode Exit fullscreen mode

Moving forward to the controllers, where we’ll navigate through the implementation of various routes.

While I’ll skip detailing the signup, login, and user profile update routes, I’d like to highlight the user-related routes. Specifically, we’ll discuss the process of fetching users from our database.

One of the very first questions, how can we display the friend lists?

In response, we devised a strategic approach: categorization of users into two distinct groups: those who are logged in and have never been contacted, and those with whom you’ve engaged in previous conversations. This segmentation enhances the user experience and helps organize interactions effectively.


exports.getUsers = catchAsync(
  async (req: Request, res: Response, next: NextFunction) => {
    const { userId }: any = req.user;
    const currentUser = await User.findOne({ _id: userId });

    const populateOptions = {
      path: "conversations",
      select: "_id createdAt updatedAt",
    };

    const contactedUsers = await User.find({
      _id: { $ne: currentUser._id },
      conversations: { $in: currentUser.conversations },
    })
      .select("_id userName conversations profileImage language")
      .populate(populateOptions);

    contactedUsers.forEach((user: any) => {
      user.conversations = user.conversations.filter((conversation: any) => {
        return currentUser.conversations.includes(conversation._id);
      });
    });

    const modifiedUsers = contactedUsers.map((user: any) => {
      return {
        _id: user._id,
        userName: user.userName,
        profileImage: user.profileImage,
        conversation: user.conversations[0],
        conversations: undefined,
        language: user.language,
      };
    });

    modifiedUsers.sort((a: any, b: any) => {
      if (
        a.conversation["updatedAt"].getTime() 
        b.conversation["updatedAt"].getTime()
      ) {
        return 1;
      }

      if (
        a.conversation["updatedAt"].getTime() 
        b.conversation["updatedAt"].getTime()
      ) {
        return -1;
      }

      return 0;
    });

    const uncontactedUsers = await User.find({
      _id: { $ne: currentUser._id },
      conversations: { $nin: currentUser.conversations },
    }).select("_id userName profileImage language");

    if (contactedUsers.length < 1 && uncontactedUsers.length < 1) {
      res
        .status(200)
        .json({ status: "Success", message: "There are currently no users" });
    }

    res.status(200).json({
      status: "Success",
      users: { contactedUsers: modifiedUsers, uncontactedUsers },
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

From this point onward, we can retrieve the unique conversation IDs and seamlessly showcase each individual conversation, which facilitates the comprehensive display of all messages that our logged-in user has exchanged with others.


exports.getUserConversation = catchAsync(
  async (req: Request, res: Response, next: NextFunction) => {
    const { conversationId } = req.params;

    const populateOptions = [
      { path: "users", select: "userName profileImage language" },
      { path: "messages", select: "message sender createdAt voiceNote" },
    ];

    const conversation = await Conversation.findOne({
      _id: conversationId,
    }).populate(populateOptions);

    if (!conversation) {
      throw new AppError("This conversation does not exist", 404);
    }

    res.status(200).json({ status: "Success", conversation });
  }
);
Enter fullscreen mode Exit fullscreen mode

Equally crucial is the route dedicated to creating new messages, a feature that defines the essence of our application. Through this functionality, users can receive translated versions of their messages, aligned with their chosen language preference in their profiles.

Messages are classified into two types: text messages and translated voice messages, with the latter being stored using Cloudinary to store URLs for easy retrieval and playback.


exports.createMessage = catchAsync(
  async (req: Request, res: Response, next: NextFunction) => {
    const {
      message: text,
      to,
      from,
      targetLanguage,
      voiceToVoice,
      voiceTargetLanguage,
    } = req.body;
    const target = targetLanguage ? targetLanguage : "en";

    if (!text || !to || !from) {
      throw new AppError("Invalid Input. Please try again", 400);
    }

    if (to === from) {
      throw new AppError("You can't send a message to yourself", 403);
    }

    const options = {
      method: "POST",
      url: process.env.TRANSLATE_URL,
      headers: {
        "content-type": "application/json",
        "X-RapidAPI-Key": process.env.TRANSLATE_API_KEY,
        "X-RapidAPI-Host": process.env.API_HOST,
      },
      data: {
        text,
        target,
      },
    };
    const response = await axios.request(options);

    let translate: string;

    if (response.data[0].result.ori === "en" && target === "en") {
      translate = "";
    } else {
      translate = `\n${response.data[0].result.text}`;
    }

    if (!voiceToVoice) {
      const message = await Message.create({
        message: text + translate,
        sender: from,
      });

      let conversation = await Conversation.findOneAndUpdate(
        { users: { $all: [from, to] } },
        { $push: { messages: message.id } }
      );

      if (!conversation) {
        conversation = await Conversation.create({
          messages: [message.id],
          users: [from, to],
        });

        await User.findOneAndUpdate(
          { _id: from },
          { $push: { conversations: conversation.id } }
        );

        await User.findOneAndUpdate(
          { _id: to },
          { $push: { conversations: conversation.id } }
        );
      }
      res.status(201).json({ status: "Success", message, conversation });
    } else {
      const encodedParams = new URLSearchParams();
      encodedParams.set("src", translate);
      encodedParams.set("hl", voiceTargetLanguage);
      encodedParams.set("r", "0");
      encodedParams.set("c", "mp3");
      encodedParams.set("f", "8khz_8bit_mono");
      encodedParams.set("b64", "true");
      const options = {
        method: "POST",
        url: process.env.URL,
        params: {
          key: process.env.VOICE_PARAMS_KEY,
        },
        headers: {
          "content-type": "application/x-www-form-urlencoded",
          "X-RapidAPI-Key": process.env.VOICE_API_KEY,
          "X-RapidAPI-Host": process.env.VOICE_API_HOST,
        },
        data: encodedParams,
      };

      const response = await axios.request(options);

      const audioData = response.data;

      const decodedData = Buffer.from(audioData, "base64");

      // Create a temporary file to store the audio data
      const tempFilePath = "./temp_audio.mp3";
      fs.writeFileSync(tempFilePath, decodedData);

      const uploadOptions = {
        resource_type: "video",
        format: "mp3",
        folder: "voice-notes",
      };

      cloudinary.uploader.upload_large(
        tempFilePath,
        uploadOptions,
        async (error: any, result: any) => {
          // Delete the temporary file
          fs.unlinkSync(tempFilePath);

          if (error) {
            console.log(error);
          } else {
            const { public_id, url } = result;
            const message = await Message.create({
              voiceNote: { public_id, url },
              sender: from,
            });

            let conversation = await Conversation.findOneAndUpdate(
              { users: { $all: [from, to] } },
              { $push: { messages: message.id } }
            );

            if (!conversation) {
              conversation = await Conversation.create({
                messages: [message.id],
                users: [from, to],
              });

              await User.findOneAndUpdate(
                { _id: from },
                { $push: { conversations: conversation.id } }
              );

              await User.findOneAndUpdate(
                { _id: to },
                { $push: { conversations: conversation.id } }
              );
            }
            res.status(201).json({ status: "Success", message, conversation });
          }
        }
      );
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

If you want to send a voice message, you can use the route below.


exports.createVoiceNote = catchAsync(
  async (req: Request, res: Response, next: NextFunction) => {
    const form = new multiparty.Form();

    form.parse(req, async (err: any, fields: any, files: any) => {
      const { to, from } = fields;

      if (!to || !from) {
        throw new AppError("Invalid Input. Please try again", 400);
      }

      if (to === from) {
        throw new AppError("You can't send a message to yourself", 403);
      }

      if (files.audio) {
        const data = await cloudinary.uploader.upload(files.audio[0].path, {
          resource_type: "video",
          folder: "voice-notes",
        });
        const { public_id, url } = data;
        const message = await Message.create({
          voiceNote: { public_id, url },
          sender: from,
        });

        let conversation = await Conversation.findOneAndUpdate(
          { users: { $all: [from, to] } },
          { $push: { messages: message.id } }
        );

        if (!conversation) {
          conversation = await Conversation.create({
            messages: [message.id],
            users: [from, to],
          });

          await User.findOneAndUpdate(
            { _id: from },
            { $push: { conversations: conversation.id } }
          );

          await User.findOneAndUpdate(
            { _id: to },
            { $push: { conversations: conversation.id } }
          );
        }
        res.status(201).json({ message });
      }
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

And finally here comes the best part: infusing the app with real-time capabilities through SocketIO. Before delving into the mechanics, let’s unravel the rationale behind employing SocketIO and the reasons that set it apart from traditional real-time communication implemented through REST APIs.

1/ REST APIs operate in a stateless manner, treating each GET or POST request in isolation without maintaining any continuous connection.
→ In our context, where we’re striving for REAL-TIME updates, using REST APIs would necessitate incessant requests to the server,
even if there might not be new messages. This approach is resource-intensive and inefficient.

2/ Enter SocketIO, empowering us to establish a persistent connection between two users that remains active until the users opt to disconnect.
The server, in turn, automatically updates and pushes new messages to the users without necessitating a site reload or any additional actions.


io.on("connection", (socket: Socket) => {
  socket.on("addUser", (userId: any) => {
    onlineUsers.set(userId, socket.id);
    io.emit("getUsers", Array.from(onlineUsers));
  });

  socket.on("sendMessage", (data: any) => {
    const sendUserSocket = onlineUsers.get(data.to);
    if (sendUserSocket) {
      io.to(sendUserSocket).emit("getMessage", data);
    }
  });

  socket.on("disconnect", () => {
    // Remove the disconnected socket from onlineUsers map
    for (const [userId, socketId] of onlineUsers) {
      if (socketId === socket.id) {
        onlineUsers.delete(userId);
        break;
      }
    }
    io.emit("getUsers", Array.from(onlineUsers));
  });
});
Enter fullscreen mode Exit fullscreen mode

Certainly, let’s break down the process. Firstly, we’ll initiate the connection using the io.on("connection") method, a built-in feature of SocketIO.

The following code snippet helps us track users who are online. Whenever a user connects to our app (by logging in), their ID is sent from the front end to SocketIO, and we manage them through a Map.

It’s crucial to use a Map to avoid user duplication. When we emit the data back to the front end, we’ll also need to convert it to an array, as Maps and Sets need manual serialization for compatibility.



const onlineUsers = new Map();
socket.on("addUser", (userId: any) => {
    onlineUsers.set(userId, socket.id);
    io.emit("getUsers", Array.from(onlineUsers));
  });
Enter fullscreen mode Exit fullscreen mode

The nextfour lines of code are crucial for enabling real-time messaging in our application.

As a quick recap, we have a Map named onlineUsers that keeps track of connected users. Each user has a unique socket ID associated with them. When a user successfully connects, they receive a socket ID.

Here’s how the messaging process works:

When you want to send a message to a specific user, you send the recipient’s ID from the front end to the backend as data.
With the recipient’s ID, you can retrieve their socket ID from the onlineUsers map.
Once you have the recipient’s socket ID, you emit the message to that socket ID, which effectively sends the message to the intended recipient.
This process ensures that messages are sent directly to the recipient in real-time. Here’s the basic structure of how this works:

socket.on("sendMessage", (data: any) => {
    const sendUserSocket = onlineUsers.get(data.to);
    if (sendUserSocket) {
      io.to(sendUserSocket).emit("getMessage", data);
    }
  });
Enter fullscreen mode Exit fullscreen mode

Lastly, when a user disconnects from the app, we can use socket.on("disconnect")to remove them from the list of online users and emit the updated list to the frontend to reflect the current online status of users.

socket.on("disconnect", () => {
    // Remove the disconnected socket from onlineUsers map
    for (const [userId, socketId] of onlineUsers) {
      if (socketId === socket.id) {
        onlineUsers.delete(userId);
        break;
      }
    }
    io.emit("getUsers", Array.from(onlineUsers));
  });
Enter fullscreen mode Exit fullscreen mode

You can find comprehensive information about error handling, CORS configuration, database integration, and the array of packages employed throughout our project by exploring our repository at: https://github.com/Talckatoo.

Stay tuned for Part 3 on how we’ve built the front-end aspects of our project.

💖 💪 🙅 🚩
miminiverse
miminiverse

Posted on August 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related