Build a Chat system using Rails 5 API Action Cable and ReactJS with multiple private rooms and group chat option
Tags: ruby, rails, react, showdev
Published: 2021-02-06 19:33:07

**Please note that this post is not a tutorial and it requires knowledge of `Rails 5` `ActionCable` and `ReactJS` / `Javascript` custom library building.**

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/da9k50sv031tz0jby7pp.png)
(please note that this short post will not show you how to build this front-end component though)

One of the awesome features that comes with `Rails 5` is `ActionCable`. With `ActionCable`, you can build all the real-time features you can think of via websocket. While struggling to build a chat system, I had found multiple examples on the ‘net of how to build a chat app with `Rails 5` `ActionCable` but they are extreme simple to even apply the concept for any real life chat application. I believe this is the first example on the internet that shows you how to build such a chat system with:

- Rails 5 API backend and a ReactJS frontend
- Multiple private rooms
- Any positive number of users in a room (not just 1–1) or group chat

The chat system my talented friend [Tim Chang](https://www.producthunt.com/@timothy_chang) and I have built has:

- Multiple private chat rooms
- Multiple chat users per room
- Online / Offline status of each user
- Real-time “typing…” status
- Real-time read receipt

In this short post, I’ll show you only the basic of #1 and #2. Please leave me a comment below if you want me to show you how to build #3, #4 and #5. I’m using Rails 5 as the back-end API and ReactJS library on the front-end.

**Backend**

On creation, Rails will generate the channels folders and files where all the real-time magic happens :)
```ruby
app/channels/application_cable/channel.rb
app/channels/application_cable/connection.rb
```

**Authentication**

First of, let’s authenticate the websocket connection requests to your Rails server inside connection.rb.

```ruby
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private
      def find_verified_user
        # or however you want to verify the user on your system
        access_token = request.params[:'access-token']
        client_id = request.params[:client]
        verified_user = User.find_by(email: client_id)
        if verified_user && verified_user.valid_token?(access_token, client_id)
          verified_user
        else
          reject_unauthorized_connection
        end
      end
  end
end
```

Depending on the authentication gem or service that you use in your project, `find_verified_user` method should be modified to your need. I have a method called `valid_token?` to verified the `access-token` and `client_id` passed in with the websocket request. If the request is not authenticated, then it will be rejected.

**Data Structure**

The idea is very basic: a chat room that has multiple messages, each message has a content and a sender. Note that a message doesn’t have a “receiver”. This allows a room to have any number of users since you don’t need to care about the receiver of the messages, since all the messages from the senders will end up appearing in a room regardless of how many participants in the room. So, this is the data structure that I use:

- Conversation (room): has_many messages, users and has an id
- Message: belongs_to a conversation, has a sender, has the text content
- Sender: is a User

As a result, I created 3 models:

```ruby
# message.rb
class Message < ApplicationRecord
  belongs_to :conversation
  belongs_to :sender, class_name: :User, foreign_key: 'sender_id'

  validates_presence_of :content

  after_create_commit { MessageBroadcastJob.perform_later(self) }
end
```

```ruby
# conversation.rb
class Conversation < ApplicationRecord
  has_many :messages, dependent: :destroy
  has_and_belongs_to_many :users
end
```

```ruby
# user.rb
class User < ApplicationRecord
  has_and_belongs_to_many :conversations, dependent: :destroy
end
```

**Action triggers**

When a client connects (subscribed) or broadcasts a message (speak), the backend will react with actions. Inside folder `app/channels`, I will create a file called `room_channel.rb`.

```ruby
# room_channel.rb
class RoomChannel < ApplicationCable::Channel
  # calls when a client connects to the server
  def subscribed
    if params[:room_id].present?
      # creates a private chat room with a unique name
      stream_from("ChatRoom-#{(params[:room_id])}")
    end
  end
  
  # calls when a client broadcasts data
  def speak(data)
    sender    = get_sender(data)
    room_id   = data['room_id']
    message   = data['message']

    raise 'No room_id!' if room_id.blank?
    convo = get_convo(room_id) # A conversation is a room
    raise 'No conversation found!' if convo.blank?
    raise 'No message!' if message.blank?

    # adds the message sender to the conversation if not already included
    convo.users << sender unless convo.users.include?(sender)
    # saves the message and its data to the DB
    # Note: this does not broadcast to the clients yet!
    Message.create!(
      conversation: convo,
      sender: sender,
      content: message
    )
  end
  
  # Helpers
  
  def get_convo(room_code)
    Conversation.find_by(room_code: room_code)
  end
  
  def get_sender
    User.find_by(guid: id)
  end
end
```

As you can see in the comment, after a client “speaks”, the broadcasting is not happening yet; only a new Message is created with its content and data. The chain of action happens after the Message is saved in the DB. Let’s take a look again in the Message model:

```ruby
after_create_commit { MessageBroadcastJob.perform_later(self) }
```

**Scalability**

This callback is called only after the Message is created and committed to the DB. I’m using background jobs to process this action in order to scale. Imagine that you have thousands of clients sending messages at the same time (this is a chat system, why not?), using background job is a requirement here.

```ruby
# message_broadcast_job.rb
class MessageBroadcastJob < ApplicationJob
  queue_as :default

  def perform(message)
    payload = {
      room_id: message.conversation.id,
      content: message.content,
      sender: message.sender,
      participants: message.conversation.users.collect(&:id)
    }
    ActionCable.server.broadcast(build_room_id(message.conversation.id), payload)
  end
  
  def build_room_id(id)
    "ChatRoom-#{id}"
  end
end
```

Here is when the broadcasting happens. `ActionCable` will broadcast the payload to the specified room with the provided payload.

```
ActionCable.server.broadcast(room_name, payload)
```

**Cable Route**

You will need to add the /cable websocket route to your `routes.rb` so that your client can call this endpoint to broadcast and receive messages.

```
mount ActionCable.server => '/cable'
```

And that’s it for the backend side! Let’s take a look at the ReactJS front-end library.

**Client Library**

Please note that depending on the specifics of your project, you will need to understand the concept of this code in this library and modify it to your needs.

First, install the `ActionCableJS` via `npm`.

Create a `ChatConnection.js` file as one of the services in your `ReactJs` app.

```javascript
// ChatConnection.js

import ActionCable from 'actioncable'

import {
  V2_API_BASE_URL,
  ACCESS_TOKEN_NAME,
  CLIENT_NAME,
  UID_NAME
} from '../../globals.js'

function ChatConnection(senderId, callback) {
  let access_token = localStorage.getItem(ACCESS_TOKEN_NAME)
  let client = localStorage.getItem(CLIENT_NAME)

  var wsUrl = 'ws://' + V2_API_BASE_URL + '/cable'
  wsUrl += '?access-token=' + access_token + '&client=' + client

  this.senderId = senderId
  this.callback = callback

  this.connection = ActionCable.createConsumer(wsUrl)
  this.roomConnections = []
}

ChatConnection.prototype.talk = function(message, roomId) {
  let roomConnObj = this.roomConnections.find(conn => conn.roomId == roomId)
  if (roomConnObj) {
    roomConnObj.conn.speak(message)
  } else {
    console.log('Error: Cannot find room connection')
  }
}

ChatConnection.prototype.openNewRoom = function(roomId) {
  if (roomId !== undefined) {
    this.roomConnections.push({roomId: roomId, conn: this.createRoomConnection(roomId)})
  }
}

ChatConnection.prototype.disconnect = function() {
  this.roomConnections.forEach(c => c.conn.consumer.connection.close())
}

ChatConnection.prototype.createRoomConnection = function(room_code) {
  var scope = this
  return this.connection.subscriptions.create({channel: 'RoomChannel', room_id: room_code, sender: scope.senderId}, {
    connected: function() {
      console.log('connected to RoomChannel. Room code: ' + room_code + '.')
    },
    disconnected: function() {},
    received: function(data) {
      if (data.participants.indexOf(scope.senderId) != -1) {
        return scope.callback(data)
      }
    },
    speak: function(message) {
      return this.perform('speak', {
        room_id: room_code,
        message: message,
        sender:  scope.senderId
      })
    }
  })
}

export default ChatConnection
```

So here is the hook: in `createRoomConnection`, the client will try to connect with (subscribe to) the `RoomChannel` we created in the backend, once it’s connected (subscribed), it will stream from the room name `ChatRoom-id` (look at `room_channel.rb` above again.) Once it’s connected, there are 2 methods that will be called frequently, can you guess which one?

They are: **received** and **speak**!

The received method is called when there is a message broadcast to the client from the server, on the opposite, speak is called when the client broadcasts a message to the server.

Voila! That’s it. Again, this is not made to be a ready-to-run-out-of-the-box kind of tutorial because each project is different, but I hope it gives you an idea how to build a chat system with multiple private chat rooms and multiple users per room. Please let me know in the comment section if you have any question.

And please don't forget to hit the love button if you find this helpful to your project!