DEV Community

Steven Sánchez
Steven Sánchez

Posted on

Building a Private 1 to 1 Chat System in Rails 7 with WebSockets & Action Cable

WebSocket is a protocol, not tied to any specific framework or language, that enables bidirectional communication between a client and a server over a single long-lived connection.

Unlike the traditional HTTP request-response cycle, WebSocket supports real-time data transfer, which is particularly useful for applications that require instant updates like chat applications, live sports scoreboards, and collaborative tools.

As highlighted in the title, another key player in our discussion is Action Cable, which is a part of the Ruby on Rails framework. It provides an integrated way for Rails applications to use WebSockets and build real-time features.

The goal in this article is implement private, 1 to 1 chats between profiles. We'll explore how to automatically initiate a chat when the 'message' button on a profile is clicked and strategize ways to prevent creating duplicate conversations between the same two profiles.

Image description


Pre-requisites and Setup:

Rails Version:
We'll be working with Rails 7 for this tutorial.

Note: Rails 5 and later versions come with Action Cable integrated by default.

Gems:

While Action Cable supports various backends, Redis is the recommended choice for production environments.

gem 'redis', '~> 5.0'


Enter fullscreen mode Exit fullscreen mode

Ensure that you also have webpacker, turbo-rails, stimulus-rails and cloudinary gems.

gem 'webpacker'
gem "turbo-rails"
gem "stimulus-rails"
gem "cloudinary"
Enter fullscreen mode Exit fullscreen mode
Don't miss bundle install and re-start your rails server rails s.

config/cable.yml
It's the file, automatically generated with default settings, for setting up Action Cable adapters in different environments and their associated configurations, such as the Redis server's URL, the channel's prefix, and other Redis-specific details. Let’s continue with default setting:

  • Async for our development environment.
  • Test specifically for our testing phase.
  • Redis, the preferred choice, for our production environment.

Image storage:
We use Cloudinary as an external service that provides a complete solution for our media files, including images, videos, audio, as well as text and document files.
https://cloudinary.com


Starting Point:

For the purpose of this tutorial, we'll assume you have an existing application with a User model in place to handle user authentication and account-related details. If you need to set up user authentication, you can utilize the Devise gem.


Database Configuration:

Let's generate three models.

Profile model: As mentioned earlier, I already have a User model for authentication purposes. I've created a Profile model to manage details like nickname, bio, profile picture, etc. For now, let's keep it simple and only add fields for the nickname and profile_picture.

rails generate model Profile nickname:string profile_picture:string

Private_chat model, represents individual chat sessions or chat channels between two profiles.
rails generate model PrivateChat profile1:references profile2:references

Message model, represents individual messages within chat sessions.
rails generate model Message content:string private_chat:references profile:references

In their respective migration files, you can add the foreign keys:

add_foreign_key "messages", "private_chats"
add_foreign_key "messages", "profiles"
add_foreign_key "private_chats", "profiles", column: "profile1_id"
add_foreign_key "private_chats", "profiles", column: "profile2_id"

Enter fullscreen mode Exit fullscreen mode
Now you can run rails db:migrate and restart your Rails server rails s.

Model Associations:

Associations between models make it easier to fetch related records and also offers convenience methods for working with the associated records.

In the Profile class, we'll define two separate associations between the Profile model and the PrivateChat model.

The reason for having these two associations is because the table uses two separate columns (profile1_id and profile2_id) to represent each of the profiles involved in the chat, there needs to be a way to fetch the chats for a given profile regardless of whether they are profile1 or profile2.

class Profile < ApplicationRecord
  has_one_attached :profile_picture
  has_many :messages
  has_many :private_chats_as_profile1, class_name: "PrivateChat", foreign_key: "profile1_id"
  has_many :private_chats_as_profile2, class_name: "PrivateChat", foreign_key: "profile2_id"
end
Enter fullscreen mode Exit fullscreen mode
class PrivateChat < ApplicationRecord
  belongs_to :profile1, class_name: "Profile"
  belongs_to :profile2, class_name: "Profile"
  has_many :messages, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode
class Message < ApplicationRecord
  belongs_to :profile
  belongs_to :private_chat
end
Enter fullscreen mode Exit fullscreen mode

Routes configuration:

We need to define three levels of nested routes. At the highest level, we have the Profile routes. Nested within these are the private_chats routes. Finally, nested within private_chats, we define routes for messages. In addition, we'll set up a post route named "create_chat" to handle chat creations when the message button is clicked.

resources :profiles do
  post 'create_chat', on: :member
  resources :private_chats, only: [:index, :show] do
    resources :messages, only: [:create]
  end
end
Enter fullscreen mode Exit fullscreen mode

PrivateChats:

Controller and views:

For now, we'll only define the index and show actions.

class PrivateChatsController < ApplicationController
  before_action :authenticate_user!
  before_action :find_private_chat, only: [:show]

  def index
    if current_profile
      @profile = current_profile
      @private_chats = PrivateChat.where("profile1_id = ? OR profile2_id = ?", @profile.id, @profile.id).order("created_at DESC")
    else
      redirect_to login_path, notice: 'Please log in first.'
    end
  end

  def show
    @profile = Profile.find(params[:profile_id])
    @private_chat = PrivateChat.find(params[:id])
    @message = Message.new
  end

  private

  def find_private_chat
    @private_chat = PrivateChat.find(params[:id])
  end

  def private_chat_params
    params.require(:private_chat).permit(:profile1_id, :profile2_id)
  end

end
Enter fullscreen mode Exit fullscreen mode

views/private_chats/index.html.erb

<div class="private_chat_index_page">
    <% @private_chats.each do |private_chat| %>
      <% other_profile = private_chat.profile1_id == @profile.id ? private_chat.profile2 : private_chat.profile1 %>
      <%= link_to user_profile_private_chat_path(@user, @profile, private_chat) do %>
        <div class="private_chat__index__cards">
          <%= cl_image_tag other_profile.profile_picture.key.to_s, crop: :fill, :class => "card__avatar" %>
          <div class="private_chat__cards__text">
            <h3><%= other_profile.nickname %></h3>
            <p><%= other_profile.updated_at.strftime("%d/%m/%Y %H:%M:%S") %></p>
            <% last_message = other_profile.messages.last %>
            <p><%= last_message.content if last_message %></p>
          </div>
        </div>
      <% end %>
    <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

views/private_chats/show.html.erb

<div class="container private_chat" data-controller="private-chat-subscription" data-private-chat-subscription-private-chat-id-value="<%= @private_chat.id %>">

  <% profile_to_show = @private_chat.profile1_id == @profile.id ? @private_chat.profile2 : @private_chat.profile1 %>
    <%= cl_image_tag profile_to_show.profile_picture.key.to_s, crop: :fill, :class => "private_chat__avatar" %>
    <h1><%= profile_to_show.nickname %></h1>


  <div class="messages" data-private-chat-subscription-target="messages">
    <% @private_chat.messages.each do |message| %>
      <%= render "messages/message", message: message %>
    <% end %>
  </div>

  <%= simple_form_for [@user, @profile, @private_chat, @message],
    html: { data: { action: "turbo:submit-end->private-chat-subscription#resetForm" }, class: "d-flex"} do |f| %>
    <%= f.input :content,
      label: false,
      placeholder: "Message to #{profile_to_show.nickname}",
      wrapper_html: {class: "flex-grow-1"} %>
    <%= f.submit "Send", class: "btn btn-primary mb-3 send__btn" %>
  <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode
Note: Above, you've seen some Stimulus code, which I'll set up later.

Messages:

Controller and views:

We’ll define only the create action, which will also manage the logic for broadcasting to the PrivateChatChannel.

class MessagesController < ApplicationController
  before_action :find_private_chat

  def create
    @message = @private_chat.messages.new(message_params)
    @message.profile_id = current_profile.id

    if @message.save
      PrivateChatChannel.broadcast_to(
        @message.private_chat,
        render_to_string(partial: "message", locals: { message: @message })
      )
    end

    head :ok

  end

  private

  def find_private_chat
    @private_chat = PrivateChat.find(params[:private_chat_id])
  end

  def message_params
    params.require(:message).permit(:content)
  end

end
Enter fullscreen mode Exit fullscreen mode

views/messages/_message.html.erb
This is a partial that is rendered on the private_chats show page.

<div id="message-<%= message.id %>">
  <small>
    <strong><%= message.profile.nickname %></strong>
    <i><%= message.created_at.strftime("%a %b %e at %l:%M %p") %></i>
  </small>
  <p><%= message.content %></p>
</div>
Enter fullscreen mode Exit fullscreen mode

Profiles Controller:

In this controller, we've included the create_chat action. This action either initiates a new conversation between profiles or redirects to an existing chat, if that's the case.

To maintain a tidy controller, we've introduced a class method that fetches a conversation from the private_chats table. This method retrieves the private_chat ID if the chat exists or returns nil if it doesn't. We'll leverage this method in our create_chat action.

Class method on PrivateChat model:

def self.get_private_chat(profile1_id, profile2_id)
    where(
      "(profile1_id = :profile1_id AND profile2_id = :profile2_id) OR
      (profile1_id = :profile2_id AND profile2_id = :profile1_id)",
      profile1_id: profile1_id, profile2_id: profile2_id,
    ).first
  end
Enter fullscreen mode Exit fullscreen mode
def create_chat
    @selected_profile = Profile.find(params[:id])
    profile1_id = current_profile.id
    profile2_id = @selected_profile.id

    if current_profile == @selected_profile
      flash[:alert] = "You cannot send a message to yourself."
      redirect_to user_profile_path(current_user, current_profile) and return
    end

    @private_chat = PrivateChat.get_private_chat(profile1_id, profile2_id)

    unless @private_chat
      @private_chat = PrivateChat.create(profile1: current_profile, profile2: @selected_profile)
    end

    redirect_to user_profile_private_chat_path(current_user, current_profile, @private_chat)

  end

Enter fullscreen mode Exit fullscreen mode

views/profiles/show.html.erb
I've placed Message button in the profile show page, as a trigger for create_chat action.

<%= button_to create_chat_user_profile_path(@user, @profile), method: :post do %>
  <span><i class="fa-solid fa-message"></i></span>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Set Up Action Cable & Channels:

So far, we've created a functional yet static chat where we can send and receive messages by refreshing the entire page. The next steps involve setting up Action Cable and channels to facilitate real-time updates.

If you're using Rails 5 or newer, ActionCable is included by default. For Rails 7, you have Hotwire **(which includes **Turbo and Stimulus) to assist you, but the basic principles remain the same.

Generate a Channel:

rails generate channel PrivateChat

This will generate several files:

  • app/channels/private_chat_channel.rb for the server-side logic.
  • javascript/channels/private_chat_channel.js for the client-side logic.
> Also, ensure that the consumer.js file was generated automatically. If it wasn't, you can recreate it manually.
import { createConsumer } from "@rails/actioncable"

export default createConsumer()
Enter fullscreen mode Exit fullscreen mode

Define Channel's Server-side Logic:

Edit app/channels/private_chat_channel.rb. Here you can define methods that get invoked when a client subscribes or unsubscribes from this channel, as well as any custom methods you might need.

class PrivateChatChannel < ApplicationCable::Channel
  def subscribed
    if params[:id].present?
      @private_chat = PrivateChat.find(params[:id])
    end

    stream_for @private_chat
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

Enter fullscreen mode Exit fullscreen mode

Define private_chat_subscription_controller.js

this Stimulus controller sets up real-time chat functionality by subscribing to an ActionCable channel and updating the chat messages in the DOM as new messages are received.

import { Controller } from "@hotwired/stimulus"
import { createConsumer } from "@rails/actioncable"


//Connects to data-controller="private_chat-subscription"
export default class extends Controller {
  static targets = [ "messages" ]
  static values = {
    privateChatId: Number
  }

  connect() {
    console.log(`connecting to the ActionCable channel with id ${this.privateChatIdValue}`)

    this.channel = createConsumer().subscriptions.create(
      { channel: "PrivateChatChannel", id: this.privateChatIdValue },
      { received: (data) => { this.#insertMessage(data) } }
    )
  }

  // # = private method
  #insertMessage(data) {
    this.messagesTarget.insertAdjacentHTML("beforeend", data)
    this.messagesTarget.scrollTo(0, this.messagesTarget.scrollHeight)
  }

  disconnect() {
    console.log("Unsubscribed from the Private Chat")
    this.channel.unsubscribe()
  }

  resetForm(event) {
    event.target.reset()
  }

}
Enter fullscreen mode Exit fullscreen mode

Define Channel's Client-side Logic:

Edit javascript/channels/private_chat_channel.js:

import consumer from "./consumer"

consumer.subscriptions.create("PrivateChatChannel", {
  connected() {
    // Called when the subscription is ready for use on the server
  },

  disconnected() {
    // Called when the subscription has been terminated by the server
  },

  received(data) {
    // Called when there's incoming data on the websocket for this channel
    this.insertMessage(data.message);
  }
});
Enter fullscreen mode Exit fullscreen mode

Broadcast Messages:

Now that clients can subscribe to a particular chat. We need to broadcast new messages to them, in the place in our code where a new message gets saved, which is MessagesController

The code below is part of the MessageController's create action, which was detailed in a previous step.

if @message.save
      PrivateChatChannel.broadcast_to(
        @message.private_chat,
        render_to_string(partial: "message", locals: { message: @message })
      )
    end
Enter fullscreen mode Exit fullscreen mode

Conclusion:

In this article, we've walked through the process of building a private chat system in Rails 7 using WebSockets & Action Cable. This serves as a foundation for the core functionality of a 1-to-1 chat feature in Rails applications.

From here, you might want to introduce additional features or enhancements based on your specific requirements.

Thank you for reading, and please don't hesitate to ask if you need any clarification or have any doubts.

Aaadios! 🚀

Top comments (0)