DEV Community

mreigen
mreigen

Posted on • Originally published at Medium

Build a Chat system using Rails 5 API Action Cable and ReactJS with multiple private rooms and group chat option

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
(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 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 :)

app/channels/application_cable/channel.rb
app/channels/application_cable/connection.rb
Enter fullscreen mode Exit fullscreen mode

Authentication

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

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
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode
# conversation.rb
class Conversation < ApplicationRecord
  has_many :messages, dependent: :destroy
  has_and_belongs_to_many :users
end
Enter fullscreen mode Exit fullscreen mode
# user.rb
class User < ApplicationRecord
  has_and_belongs_to_many :conversations, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode

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.

# 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
Enter fullscreen mode Exit fullscreen mode

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:

after_create_commit { MessageBroadcastJob.perform_later(self) }
Enter fullscreen mode Exit fullscreen mode

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.

# 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.

// 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
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (2)

Collapse
 
siba2893 profile image
Daniel Sibaja

This is amazing! Please continue with this tutorials.

Collapse
 
duybuffet profile image
Tăng Hoài Duy

Awesome, please continue with this