Building a Private 1 to 1 Chat System in Rails 7 with WebSockets & Action Cable
Tags: rails, websocket, actioncable, javascript
Published: 2023-09-22 09:51:15

**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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/enh26nztcp8edcgfbdwh.jpg)

---

## **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.
```ruby
gem 'redis', '~> 5.0'  
```
Ensure that you also have `webpacker`, `turbo-rails`, `stimulus-rails` and `cloudinary` gems.

```ruby
gem 'webpacker'
gem "turbo-rails"
gem "stimulus-rails"
gem "cloudinary"
```

>###### _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](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:

```ruby
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"

```

> ###### _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.

```ruby
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
```

```ruby
class PrivateChat < ApplicationRecord
  belongs_to :profile1, class_name: "Profile"
  belongs_to :profile2, class_name: "Profile"
  has_many :messages, dependent: :destroy
end
```

```ruby
class Message < ApplicationRecord
  belongs_to :profile
  belongs_to :private_chat
end
```

---


## 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.


```ruby
resources :profiles do
  post 'create_chat', on: :member
  resources :private_chats, only: [:index, :show] do
    resources :messages, only: [:create]
  end
end
```

---

# PrivateChats: 
### Controller and views:
For now, we'll only define the **index** and **show** actions.

```ruby
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
```

> **_views/private_chats/index.html.erb_**

```ruby
<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>
```

> **_views/private_chats/show.html.erb_**

```ruby
<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>
```

> ###### 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**.

```ruby
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
```

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

```ruby
<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>
```

---

## 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:_

```ruby
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
```

```ruby
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

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

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

---

# 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.

```js
import { createConsumer } from "@rails/actioncable"

export default createConsumer()
```

---

### 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.

```ruby
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

```

---

### 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.

```js
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()
  }

}
```

---

### Define Channel's Client-side Logic:
Edit `javascript/channels/private_chat_channel.js`:

```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);
  }
});
```

---

## 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_.

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

---


## 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! 🚀