🎉 New release for Pusher Chatkit - Webhooks! Extend your in-app chat functionality
Hide
Products
chatkit_full-logo

Extensible API for in-app chat

channels_full-logo

Build scalable realtime features

beams_full-logo

Programmatic push notifications

Developers

Docs

Read the docs to learn how to use our products

Tutorials

Explore our tutorials to build apps with Pusher products

Support

Reach out to our support team for help and advice

Sign in
Sign up

Building a video call and chat app - Part 2: Adding a group chat with Chatkit

  • Wern Ancheta
March 18th, 2019
This tutorial uses Node, React and Electron.

This is part two of a three-part series on building a video call and chat app with Electron.

In part one, we have discussed how to add peer-to-peer video calls using the Simple Peer library. In this part, we will add the group chat and file-sharing features using Chatkit.

Prerequisites

This tutorial has the same prerequisites as the first part.

Reading part one of this series is optional. But if you want to learn how to do peer-to-peer video calls then you might want to start with part one first.

If you want to follow along, clone the repo and switch to the part1 branch. Then install all the dependencies by executing yarn on both the project’s root directory and the server directory.

Lastly, you’ll need a Chatkit app instance. So create one if you haven’t already.

Updating the server

The first thing we need to do is update the server so it creates a user record and a room if it doesn’t already exist. Previously, we’re only using an array to maintain a list of rooms and the users who joined it. This time, we actually need to make a request to the Chatkit API so it creates a user and room record for us.

But first, we need to install the Chatkit server module:

    cd server
    yarn add @pusher/chatkit-server

Next, update your server/.env file to include your Chatkit credentials:

    CHATKIT_INSTANCE_ID="YOUR CHATKIT INSTANCE LOCATOR ID (without the v1:us1:)"
    CHATKIT_SECRET_KEY="YOUR CHATKIT SECRET KEY"

Initialize a new Chatkit instance:

    // server/server.js
    const cors = require("cors");
    const Chatkit = require("@pusher/chatkit-server"); // add this

    // ...

    const chatkit = new Chatkit.default({
      instanceLocator: `v1:us1:${process.env.CHATKIT_INSTANCE_ID}`,
      key: process.env.CHATKIT_SECRET_KEY
    });

Create a function for creating a Chatkit user. We need this because every user needs to have a unique identity in order to use Chatkit. The user that will be created will be used in the app later on to send and receive messages:

    const createUser = async (user_id, username) => {
      try {
        await chatkit.createUser({
          id: user_id,
          name: username
        });
      } catch (err) {
        if (err.error === "services/chatkit/user_already_exists") {
          console.log("user already exists: ", err);
        } else {
          console.log("error occurred: ", err);
        }
      }
    };

Next, update the code for the /login route to use the createUser method. In order to handle returning users (users who have already logged in previously, and on the same room), we’ll also add the id to the users array. So we’ll now be storing a user object instead of just their usernames:

    const { channel, user_id, username } = req.body; // add user_id

    var channel_index = channels.findIndex(c => c.name === channel);
    if (channel_index === -1) {
      // add these:
      await createUser(user_id, username);

      try {
        const room = await chatkit.createRoom({
          creatorId: user_id,
          name: channel
        });

        channels.push({
          id: room.id.toString(),
          name: channel,
          users: [
            {
              id: user_id,
              name: username
            }
          ]
        });

        return res.json({
          room_id: room.id.toString()
        });
      } catch (err) {
        console.log("error creating room: ", err);
      }
    } else {
      // add these
      const user_index = channels[channel_index].users.findIndex(
        usr => usr.name === username
      );
      if (user_index === -1) {

        await createUser(user_id, username); 

        channels[channel_index].users.push({
          id: user_id,
          name: username
        });

        return res.json({
          room_id: channels\[channel_index\]["id"].toString()
        });
      }

      return res.json({
        room_id: channels[channel_index].id
      });
    }

    return res.status(500).send("invalid user");

Lastly, you need to update the route that returns the array of usernames in the room. Since we’re already storing an object containing the user’s ID and username, we need to adjust the code accordingly:

    app.post("/users", (req, res) => {
      const { channel, username } = req.body;
      const channel_data = channels.find(ch => {
        return ch.name === channel;
      });

      let channel_users = [];
      if (channel_data) {
        channel_users = channel_data.users.filter(user => {
          return user.name !== username;
        });
      }

      const users = channel_users.map(usr => {
        return usr.name;
      });

      return res.json({
        users: users
      });
    });

Adding group chat to the app

Now we’re ready to update the app. Start by installing the packages we need. Aside from Chatkit, we’ll also need react-dropzone and react-custom-scrollbars. react-dropzone is for adding the functionality to pick files. react-custom-scrollbars is for adding scrollbars for the message list. Lastly, electron-dl is for downloading files using Electron. This is useful for the file-sharing feature because we won’t actually be previewing any of the files in the chat UI:

    yarn add @pusher/chatkit-client react-dropzone react-custom-scrollbars electron-dl

Next, update the .env file with your Chatkit instance locator ID:

    REACT_APP_CHATKIT_INSTANCE_ID="YOUR CHATKIT INSTANCE LOCATOR ID (with the v1:us1:)"

Now we’re ready to update the code. Start by removing the following from the render method since they’re no longer needed. As mentioned in the previous tutorial, we’re only using this to test out if the users are really connected:

    // src/screens/GroupChat.js
    <Form.Control
      type="text"
      placeholder="username"
      value={this.state.username}
      onChange={this.onTypeText}
    />

    <Button variant="primary" type="button" onClick={this._sendMessage}>
      Send Message
    </Button>

After that, delete the arrayBufferToString.js helper and its import statement in src/screens/GroupChat.js:

    import ab2str from "../helpers/arrayBufferToString"; // delete this

Also, delete the following since it’s no longer needed:

    p.on("data", data => {
      console.log(ab2str(data));
    });

Next, import the new packages that we need:

    import { Container, Row, Col, Button, Form, Figure } from "react-bootstrap"; // add Figure here

    // add these: 
    import Chatkit from "@pusher/chatkit-client";
    import { Scrollbars } from "react-custom-scrollbars";
    import Dropzone from "react-dropzone";

Next, add your Chatkit credentials:

    // src/screens/GroupChat.js
    const BASE_URL = "YOUR HTTPS NGROK URL";

    // add these:
    const CHATKIT_TOKEN_PROVIDER_ENDPOINT = "YOUR TEST TOKEN PROVIDER ENDPOINT (don't forget to enable it on your Chatkit instance settings)";
    const CHATKIT_INSTANCE_LOCATOR = process.env.REACT_APP_CHATKIT_INSTANCE_ID;

Update the state with the initial values for the state variables we’ll be using throughout the app:

    state = {
      is_initialized: false,
      streams: [],
      // add these:
      messages: [], // array of messages to be displayed in the UI
      show_load_earlier: false, // whether to show link for loading older messages or not
      is_sending: false, // whether to disable the send button or not
      files: [] // array of files that were picked using react-dropzone
    };

Next, add the code for subscribing the user to the room:

    async componentDidMount() {
      // previous code here..

      this.room_id = navigation.getParam("room_id").toString();

      try {
        const response_data // ...
      } catch (err) {
        // ...
      }

      // add these:

      const chatManager = new ChatManager({
        instanceLocator: CHATKIT_INSTANCE_LOCATOR,
        userId: this.user_id,
        tokenProvider: new TokenProvider({ url: CHATKIT_TOKEN_PROVIDER_ENDPOINT })
      });

      this.currentUser = await chatManager.connect();
      await this.currentUser.subscribeToRoom({
        roomId: this.room_id, 
        hooks: {
          onMessage: this._onReceive
        },
        messageLimit: 10
      });

      // rest of the existing code..
      this.my_channel.bind("client-initiate-signaling", data => {
        // ...
      });
    }

Next, update the render method to include the code for rendering the chat UI:

    <Col md={8} className="VideoContainer">
      ...
    </Col>

    <Col md={4} className="ChatContainer">
      <Row>
        <Col className="Messages">
          <Scrollbars
            style={{ height: 580, width: 440 }}
            ref={c => {
              this.scrollComponent = c;
            }}
            autoHide={true}
          >
            {this.state.show_load_earlier && (
              <Button
                variant="link"
                className="SmallText"
                onClick={this._loadEarlierMessages}
                disabled={this.state.is_loading}
                block
              >
                {this.state.is_loading
                  ? "Loading..."
                  : "Load earlier messages"}
              </Button>
            )}

            <div className="MessageBoxes">{this._renderMessages()}</div>
          </Scrollbars>
        </Col>
      </Row>

      <Row>
        <Col>
          <Form className="ChatForm">
            <Dropzone
              onDrop={this._onFileDrop}
              onFileDialogCancel={this._onFileCancel}
            >
              {({ getRootProps, getInputProps }) => (
                <div {...getRootProps()}>
                  <input {...getInputProps()} />
                  <span className="SmallText">
                    {this.state.files.length
                      ? "File selected"
                      : "Select file"}
                  </span>
                </div>
              )}
            </Dropzone>

            <Form.Group>
              <Form.Control
                as="textarea"
                rows="2"
                className="TextArea"
                onChange={this._updateMessage}
                value={this.state.message}
              />
            </Form.Group>

            <Button
              variant="primary"
              onClick={this._sendMessage}
              disabled={this.state.is_sending}
              block
            >
              {this.state.is_sending ? "Sending…" : "Send"}
            </Button>
          </Form>
        </Col>
      </Row>
    </Col>

In the above code, we’re storing the reference to the scrollbar in the this.scrollComponent instance variable. Later on, we’ll use it to automatically scroll down the message list to the very bottom when a new message is received.

Here’s the _renderMessages and _renderMessageBox function. This renders the user’s avatar, username and a chat bubble containing the message they sent:

    _renderMessages = () => {
      return this.state.messages.map(msg => {
        return this._renderMessageBox(msg);
      });
    };

    _renderMessageBox = msg => {
      if (msg.user._id === this.user_id) {
        return (
          <div className="MessageRow Me">
            <div
              className="ChatBubble"
              dangerouslySetInnerHTML={{ __html: msg.text }}
              onClick={msg._downloadFile}
            />

            <div className="ChatAvatar">
              <Figure>
                <Figure.Image
                  width={30}
                  height={30}
                  src={msg.user.avatar}
                  thumbnail
                  roundedCircle
                />
              </Figure>
              <div className="username">{msg.user.name}</div>
            </div>
          </div>
        );
      }

      return (
        <div className="MessageRow">
          <div className="ChatAvatar">
            <Figure>
              <Figure.Image
                width={30}
                height={30}
                src={msg.user.avatar}
                thumbnail
                roundedCircle
              />
            </Figure>
            <div className="username">{msg.user.name}</div>
          </div>

          <div
            className="ChatBubble"
            dangerouslySetInnerHTML={{ __html: msg.text }}
            onClick={msg._downloadFile}
          />
        </div>
      );

    }

Here’s the function for updating the value for the message input field:

    _updateMessage = evt => {
      this.setState({
        message: evt.target.value
      });
    };

When a file is selected for the dropzone or a file is dropped into it, the _onFileDrop function gets fired. An array of the selected files are automatically passed into it. But since react-dropzone only accepts one file by default, the files array will only have a single item and it will be replaced each time you select a file:

    _onFileDrop = files => {
      this.setState({ files });
    };

On the other hand, the _onFileCancel gets fired when the user clicks on the cancel button when the file selection dialog shows up:

    _onFileCancel = () => {
      this.setState({
        files: []
      });
    };

Next, when the send message button is clicked, this function gets called. This adds an attachment property to the message if a file is picked by the user. We can then send the message using the sendMessage function. Note that sometimes, something can go wrong with this step. That’s why we’ve wrapped it inside a try..catch statement:

    _sendMessage = async () => {
      let msg = {
        text: this.state.message,
        roomId: this.room_id // the ID of the room in which to put the message
      };

      // disables button from being clicked repeatedly
      this.setState({
        is_sending: true
      });

      if (this.state.files.length > 0) {
        const file = this.state.files[0];
        let filename = file.name;

        msg.attachment = {
          file: file,
          name: `${filename}`,
          type: "file"
        };
      }

      try {
        await this.currentUser.sendMessage(msg);
        // reset the UI
        this.setState({
          is_sending: false,
          message: "",
          files: []
        });
      } catch (err) {
        console.log("error sending message: ", err);
      }
    };

Once a message is received (either the current user sent it or one of the users in the room), the _onReceive function gets fired. All it does is append the message to the messages in the state:

    _onReceive = async data => {
      let { message } = await this.getMessageAndFile(data);

      await this.setState(prevState => ({
        messages: [...prevState.messages, message]
      }));

      if (this.state.messages.length > 4) {
        this.setState({
          show_load_earlier: true
        });
      }

      setTimeout(() => {
        this.scrollComponent.scrollToBottom(); // scroll to the bottom of the message list
      }, 1000);
    };

If you’re wondering why we’re wrapping the scrollComponent in setTimeout, that’s because the reference might not exist yet when the _onReceive function gets called. This is especially true if the room in question has already some messages on it. This means that it doesn’t have to wait for a user to send a message in the room for it to be triggered.

The getMessageAndFile function is used to format the individual message data so that it can be easily rendered in the UI. We’re also adding a _downloadFile method to the individual message objects if it has an attachment. This allows us to download the file to the user’s default download directory:

    getMessageAndFile = async ({
      id,
      senderId,
      sender,
      text,
      attachment,
      createdAt
    }) => {
      let file_data = null;
      let msg_data = {
        _id: id,
        text: text,
        createdAt: new Date(createdAt),
        user: {
          _id: senderId,
          name: sender.name,
          avatar:
            "https://cdn.pixabay.com/photo/2016/08/08/09/17/avatar-1577909_960_720.png"
        }
      };

      if (attachment) {
        const { link, name } = attachment;

        msg_data.text += `<br/>attached:<br/><span class="link">${name}</a>`;
        msg_data._downloadFile = async () => {
          window.ipcRenderer.send("download-file", link);
        };
      }

      return {
        message: msg_data
      };
    };

The download code above uses Electron’s event emitter to emit a download-file event (can be any name really). As the electron-dl package can only be used in Electron’s main process, we have to emit an event which we can listen to from the main process. Update the src/starter.js file to to include ipcMain and the electron-dl package, then listen for the download-file event:

    const { app, BrowserWindow, ipcMain } = require("electron"); // add ipcMain here
    const { download } = require("electron-dl");

    function createWindow() {
      mainWindow = new BrowserWindow({
         width: 1440,
        height: 770,
        resizable: false,
        // add these:
        webPreferences: {
          nodeIntegration: false,
          preload: __dirname + "/preload.js"
        }
      });

      mainWindow.loadURL("http://localhost:3000");

      // add these:
      ipcMain.on("download-file", async (event, url) => {
        const win = BrowserWindow.getFocusedWindow();
        await download(win, url)
      });
    }

In the code above, we’re using a preloader file (preload.js) in order for us to have access to the ipcRenderer instance in the Group Chat screen. This allowed us to use the window.ipcRenderer.send method. If you’re new to Electron, ipcMain and ipcRenderer are both instances of Electron’s EventEmitter class. The most common use case for this app is for emitting events from any of the files in your app and listen for it in the main process. We need to do this because there are things that can only be done from the main process.

Here’s the code for src/preload.js:

    window.ipcRenderer = require("electron").ipcRenderer;

Next, go back to the src/screens/GroupChat.js file and add the function for loading older messages. As you’ve seen in the componentDidMount earlier, we’re only fetching the 10 most recent messages by default. This function allows them to make a request to the Chatkit API to fetch the older messages. This uses the fetchMessages function from the Chatkit client library. The most important thing to remember when using this method is to supply the initialId and the direction. initialId is the where you want to start fetching from. And direction is the direction of the fetch. older means “fetch messages older than this ID”. This returns an array of messages which is ordered from the oldest to the last message right before the initialId that was supplied. With that in mind, we simply prepend the returned array of old messages to the current one we have:

    // src/screens/GroupChat.js
    _loadEarlierMessages = async () => {
      // show loading UI
      this.setState({
        is_loading: true
      });

      const earliest_message_id = Math.min(
        ...this.state.messages.map(m => parseInt(m._id))
      );

      try {
        let messages = await this.currentUser.fetchMessages({
          roomId: this.room_id,
          initialId: earliest_message_id,
          direction: "older",
          limit: 10
        });

        if (!messages.length) {
          this.setState({
            show_load_earlier: false
          });
        }

        let earlier_messages = [];

        await this.asyncForEach(messages, async msg => {
          let { message } = await this.getMessageAndFile(msg);
          earlier_messages.push(message);
        });

        await this.setState(prevState => ({
          messages: [...earlier_messages, ...prevState.messages]
        }));
      } catch (err) {
        console.log("error occured while trying to load older messages", err);
      }

      // hide loading UI
      await this.setState({
        is_loading: false
      });
    };

Here’s the asyncForEach function:

    asyncForEach = async (array, callback) => {
      for (let index = 0; index < array.length; index++) {
        await callback(array[index], index, array);
      }
    };

Lastly, you can copy the styles from the repo.

Running the app

At this point, you can now run the app again. Start with the server:

    node server/server.js
    ./ngrok http 5000

Update the ngrok URL on src/screens/Login.js and src/screens/GroupChat.js files. Then run the Electron app itself:

    yarn start
    yarn run electron-dev

Here’s what the app will look like:

Unlike the video streams from part one, you can actually start typing messages and attaching files to them even if you’re the only person in the room. That’s why there’s no video stream in the screenshot above.

Conclusion

That’s it! In this tutorial, you learned how to work with Chatkit in an Electron app written in React. You also learned how to download files from an Electron app using the electron-dl package and Electron’s EventEmitter class.

Stay tuned for the final part of this series where we’ll package the Electron app so it can be installed on all three major operating systems.

You can find the code used in this tutorial on its GitHub repo.

Clone the project repository
  • Chat
  • JavaScript
  • Node.js
  • React
  • Channels
  • Chatkit

Products

  • Channels
  • Chatkit
  • Beams

© 2019 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.