Building a Private 1 to 1 Chat System in Rails 7 with WebSockets & Action Cable
Steven Sánchez
Posted on September 22, 2023
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.
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'
Ensure that you also have webpacker
, turbo-rails
, stimulus-rails
and cloudinary
gems.
gem 'webpacker'
gem "turbo-rails"
gem "stimulus-rails"
gem "cloudinary"
Don't miss
bundle install
and re-start your rails serverrails 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"
Now you can run
rails db:migrate
and restart your Rails serverrails 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
class PrivateChat < ApplicationRecord
belongs_to :profile1, class_name: "Profile"
belongs_to :profile2, class_name: "Profile"
has_many :messages, dependent: :destroy
end
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.
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.
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
<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
<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.
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.
<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:
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
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.
<%= 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.
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.
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.
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
:
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.
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! 🚀
Posted on September 22, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 22, 2023