About a year ago, I wrote an article about Mailboxer – a gem to send messages in Rails applications. This gem provides a nice set of functionality, however it has a bunch of issues:
- It lacks proper documentation.
- It is pretty complex, especially for junior programmers. Since it does not have extensive documentation, it is sometimes required to dive into the source code to understand how some method works.
- It is not actively maintained.
- It has some bugs and, as long as it is not actively evolving, who knows when these bugs will be fixed.
In the past few months, I’ve received many questions about Mailboxer and therefore decided to explain how to create a custom messaging system for Rails. Of course, this system will not provide all of the features Mailboxer has, but it will be more than enough for many application. When you fully understand how this system works, it’ll be much easier to further enhance it.
This article is going to be divided into two parts:
- The first part will cover preparations, associations setup, and the creation of controllers and views. We will also add support for Emoji. By the end of this part, you will have a working messaging system.
- The second part will explain how to utilize ActionCable to implement a real-time messaging and notification system. ActionCable is probably the most anticipated feature of Rails 5 and I already covered it some time ago. On top of that, we will also implement “user is online” functionality.
The source code is available at GitHub.
The working demo is available at sitepoint-custom-messaging.herokuapp.com.
Authentication
Take a deep breath and create a new Rails app:
$ rails new Messager -T
ActionCable was introduced in Rails 5, therefore some parts of this article do not apply to earlier versions. Still, the not-ActionCable concepts are valid for Rails 3 and 4.
We will need a way to authenticate users, so let’s go with Devise:
Gemfile
# ...
gem 'devise'
# ...
Run the following commands to install Devise and create the necessary files:
$ bundle install
$ rails generate devise:install
$ rails generate devise User
$ rails db:migrate
Now the User
model is in place and equipped with Devise’s magic. Let’s add a simple partial displaying flash messages and a couple of links:
shared/_menu.html.erb
<% flash.each do |key, value| %>
<div>
<%= value %>
</div>
<% end %>
<% if user_signed_in? %>
<p>Signed in as <%= current_user.name %> | <%= link_to 'log out', destroy_user_session_path, method: :delete %></p>
<% end %>
<ul>
<li><%= link_to 'Home', root_path %></li>
</ul>
Render this partial in the layout:
layouts/application.html.erb
<%= render 'shared/menu' %>
Our users don’t actually have a name, but Devise does introduce an email
column so let’s utilize it instead:
models/user.rb
def name
email.split('@')[0]
end
Creating Models and Establishing Associations
Next, we will require two more models and here are their main fields:
Conversation
author_id
– indexed field specifying who started the conversationrecevier_id
– indexed field specifying who is the receiver. We will use Facebook’s conversation system as an example, sothere can be only one conversation between two specific users.
PersonalMessage
body
– the actual text of the messageconversation_id
– indexed field specifying the parent conversationuser_id
– indexed field specifying who the author of the message
That’s it. Generate these two models and the corresponding migrations:
$ rails g model Conversation author_id:integer:index receiver_id:integer:index
$ rails g model PersonalMessage body:text conversation:belongs_to user:belongs_to
Tweak the first migration by adding yet another index ensuring the uniqueness of the author_id
and receiver_id
combination:
db/migrate/xyz_create_conversations.rb
# ...
add_index :conversations, [:author_id, :receiver_id], unique: true
# ...
Apply migrations:
$ rails db:migrate
Now the tricky part. Conversations should belong to an author and receiver, but in reality those two are the same User
model. This requires us to provide a special option for the belongs_to
method:
models/conversation.rb
# ...
belongs_to :author, class_name: 'User'
belongs_to :receiver, class_name: 'User'
# ...
To learn more about Rails association read this article. On the user side, we also need to establish two relations:
models/user.rb
# ...
has_many :authored_conversations, class_name: 'Conversation', foreign_key: 'author_id'
has_many :received_conversations, class_name: 'Conversation', foreign_key: 'received_id'
# ...
In this case, it is required to specify which foreign key to use because, by default, Rails uses the association name to infer the key’s name.
Let’s also create a validation to ensure that there cannot be two conversations between the same two users:
models/conversation.rb
# ...
validates :author, uniqueness: {scope: :receiver}
# ...
PersonalMessage
also needs to be tackled. Set up a has_many
relation on the conversation side, specifying the default sorting rule (the oldest one comes first):
models/conversation.rb
# ...
has_many :personal_messages, -> { order(created_at: :asc) }, dependent: :destroy
# ...
Personal message belongs to conversation and user:
models/personal_message.rb
# ...
belongs_to :conversation
belongs_to :user
# ...
Also, while we are here, let’s add a simple validation rule:
models/personal_message.rb
# ...
validates :body, presence: true
# ...
Lastly take care of the User
model:
models/user.rb
# ...
has_many :personal_messages, dependent: :destroy
# ...
Great, all associations are now set. Let’s proceed to the controllers, views, and routes.
Displaying Conversations
First of all, add a global before_action
enforcing users to authenticate (this method is provided by Devise):
application_controller.rb
# ...
before_action :authenticate_user!
# ...
On the main page of our app I want to list all conversations in which the current user participates. The problem, however, is that “participates” means that they are either an author or a receiver:
conversations_controller.rb
class ConversationsController < ApplicationController
def index
@conversations = Conversation.participating(current_user).order('updated_at DESC')
end
end
To make this work, introduce a new scope called participating
:
models/conversation.rb
# ...
scope :participating, -> (user) do
where("(conversations.author_id = ? OR conversations.receiver_id = ?)", user.id, user.id)
end
# ...
Nice. Add the root route:
config/routes.rb
# ...
root 'conversations#index'
# ...
Create the view
views/conversations/index.html.erb
<h1>Your conversations</h1>
<div id="conversations">
<%= render @conversations %>
</div>
Add the partial to render a conversation:
views/conversations/_conversation.html.erb
<div>
Chatting with <%= conversation.with(current_user).name %>
<br>
<em><%= conversation.personal_messages.last.body.truncate(50) %></em>
<br>
<%= link_to 'View conversation', conversation_path(conversation) %>
<hr>
</div>
conversation_path
will be tackled later. with
is a method that we’ll create now that returns the other participant of a conversation:
models/conversation.rb
# ...
def with(current_user)
author == current_user ? receiver : author
end
# ...
Add the show
action for ConversationsController
. Before calling this action, however, we must make sure that the user is actually authorized to view the requested conversation:
conversations_controller.rb
# ...
before_action :set_conversation, except: [:index]
before_action :check_participating!, except: [:index]
def show
@personal_message = PersonalMessage.new
end
private
def set_conversation
@conversation = Conversation.find_by(id: params[:id])
end
def check_participating!
redirect_to root_path unless @conversation && @conversation.participates?(current_user)
end
# ...
Inside the show
action we instantiate the @personal_message
variable, as it will be used inside the view to render a form. participates?
is yet another instance method:
models/conversation.rb
# ...
def participates?(user)
author == user || receiver == user
end
# ...
Now here is the view:
views/conversations/show.html.erb
<h1>Chatting with <%= @conversation.with(current_user).name %></h1>
<div id="conversation-main">
<div id="conversation-body">
<%= render @conversation.personal_messages %>
</div>
<%= form_for @personal_message do |f| %>
<%= hidden_field_tag 'conversation_id', @conversation.id %>
<%= f.label :body %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
</div>
This view is actually pretty simple. First, render the list of the already existing messages and then provide a form to send a new message.
Create a partial to display the actual message:
views/personal_messages/_personal_message.html.erb
<p><%= personal_message.body %></p>
<p>at <strong><%= personal_message.created_at %></strong><br>
by <strong><%= personal_message.user.name %></strong></p>
<hr>
Responding to Conversations
Now, of course, we need a new controller to handle the personal messages, so create one:
personal_messages_controller.rb
class PersonalMessagesController < ApplicationController
before_action :find_conversation!
def create
@personal_message = current_user.personal_messages.build(personal_message_params)
@personal_message.conversation_id = @conversation.id
@personal_message.save!
flash[:success] = "Your message was sent!"
redirect_to conversation_path(@conversation)
end
private
def personal_message_params
params.require(:personal_message).permit(:body)
end
def find_conversation!
@conversation = Conversation.find_by(id: params[:conversation_id])
redirect_to(root_path) and return unless @conversation && @conversation.participates?(current_user)
end
end
Inside before_action
, try to find a conversation by its id. Then, if found and the user participates in it, build a new personal message and save it. Finally, redirect back to the conversation’s page.
Next add the routes:
config/routes.rb
# ...
resources :personal_messages, only: [:create]
resources :conversations, only: [:index, :show]
# ...
Starting a New Conversation
Currently there is no way to start a new conversation with a user, so let’s fix it now. Create a controller to manage users:
users_controller.rb
class UsersController < ApplicationController
def index
@users = User.all
end
end
An index
view:
views/users/index.html.erb
<h1>Users</h1>
<ul><%= render @users %></ul>
and the partial:
views/users/_user.html.erb
<li>
<%= user.name %> | <%= link_to 'send a message', new_personal_message_path(receiver_id: user) %>
</li>
Tweak the routes:
config/routes.rb
# ...
resources :users, only: [:index]
resources :personal_messages, only: [:new, :create]
# ...
Also present a new link in the menu:
shared/_menu.html.erb
# ...
<ul>
<li><%= link_to 'Home', root_path %></li>
<li><%= link_to 'Users', users_path %></li>
</ul>
Now add the new
action for the PersonalMessagesController
:
personal_messages_controller.rb
# ...
def new
@personal_message = current_user.personal_messages.build
end
# ...
There is a problem, however. When the find_conversation!
method is called as a part of before_action
, we say @conversation = Conversation.find_by(id: params[:conversation_id])
, but there is no :conversation_id
when a user clicks the “send a message” link. Therefore, we need to introduce a bit more complex logic:
- If the
:receiver_id
is set (that is, the “send a message” link was clicked), we try to find the other user to address the message. - If the user was not found, redirect to the root path (of course, you may display an error of some kind).
- If the user was found, check if the conversation between him and the current user already exist.
- If the conversation does exist, redirect to the “show conversation” action.
- If it does not exist, render the form to start a conversation.
- Lastly, if the
:receiver_id
id is not set, we try to find an existing conversation by:conversation_id
.
Here is the updated find_conversation!
method and the new
action:
conversations_controller.rb
# ...
def new
redirect_to conversation_path(@conversation) and return if @conversation
@personal_message = current_user.personal_messages.build
end
private
def find_conversation!
if params[:receiver_id]
@receiver = User.find_by(id: params[:receiver_id])
redirect_to(root_path) and return unless @receiver
@conversation = Conversation.between(current_user.id, @receiver.id)[0]
else
@conversation = Conversation.find_by(id: params[:conversation_id])
redirect_to(root_path) and return unless @conversation && @conversation.participates?(current_user)
end
end
# ...
between
is a scope that returns a conversation for two users:
models/conversation.rb
# ...
scope :between, -> (sender_id, receiver_id) do
where(author_id: sender_id, receiver_id: receiver_id).or(where(author_id: receiver_id, receiver_id: sender_id)).limit(1)
end
# ...
Here is the view:
views/personal_messages/new.html.erb
<h1>New message to <%= @receiver.name %></h1>
<%= form_for @personal_message do |f| %>
<%= hidden_field_tag 'receiver_id', @receiver.id %>
<%= f.label :body %>
<%= f.text_area :body %>
<%= f.submit %>
<% end %>
The create
action also requires a small change. Currently we don’t take into account that the conversation might not exist, so fix it now:
personal_messages_controller.rb
# ...
def create
@conversation ||= Conversation.create(author_id: current_user.id,
receiver_id: @receiver.id)
@personal_message = current_user.personal_messages.build(personal_message_params)
@personal_message.conversation_id = @conversation.id
@personal_message.save!
flash[:success] = "Your message was sent!"
redirect_to conversation_path(@conversation)
end
# ...
This is it! Our messaging system is done and you can see it in action!
A Bit of Styling
As long as we display the newest messages at the bottom, let’s style the conversation page a bit to make it more user-friendly:
application.scss
#conversation-body {
max-height: 400px;
overflow-y: auto;
margin-bottom: 2em;
}
In most cases, the user is interested in recent messages, so scrolling to the bottom of the messages box is a good idea as well:
javascripts/conversations.coffee
jQuery(document).on 'turbolinks:load', ->
messages = $('#conversation-body')
if messages.length > 0
messages_to_bottom = -> messages.scrollTop(messages.prop("scrollHeight"))
messages_to_bottom()
We basically define a function and call it as soon as the page is loaded. If you are not using Turbolinks, the first line should be
jQuery ->
Lastly, require this CoffeeScript file:
javascripts/application.js
//= require conversations
Adding Support for Emoji
Smileys make conversation on the Internet a bit more colorful (well, unless someone is heavily abusing them). Therefore, why don’t we add support for Emoji in our app? This is easy with the emoji gem. Drop it into the Gemfile:
Gemfile
# ...
gem 'emoji'
# ...
and install by running:
$ bundle install
Add a new helper method found here:
application_helper.rb
# ...
def emojify(content)
h(content).to_str.gsub(/:([\w+-]+):/) do |match|
if emoji = Emoji.find_by_alias($1)
%(<img alt="#$1" src="#{image_path("emoji/#{emoji.image_filename}")}" style="vertical-align:middle" width="20" height="20" />)
else
match
end
end.html_safe if content.present?
end
# ...
This method can be used in any view or partial:
views/personal_messages/_personal_message.html.erb
<p><%= emojify personal_message.body %></p>
<p>at <strong><%= personal_message.created_at %></strong><br>
by <strong><%= personal_message.user.name %></strong></p>
<hr>
You may also present a link to the Emoji cheat sheet somewhere in your app.
Conclusion
OK, the first version of our messaging application is done and working pretty well. In the next part, we will make it more modern by utilizing web sockets powered by ActionCable and implement a “user is online” feature. Meanwhile, if you have any questions, don’t hesitate to contact me.
I thank you for staying with me and see you soon!
Frequently Asked Questions (FAQs) about Building a Messaging System with Rails and ActionCable
How can I add emoji support to my Rails and ActionCable messaging system?
To add emoji support to your Rails and ActionCable messaging system, you need to use a gem like ’emoji_picker’. First, add gem 'emoji_picker'
to your Gemfile and run bundle install
. Then, you can use the emoji_picker
helper method in your form. For example, if you have a form for creating messages, you can add emoji_picker :message, :content
to your form. This will add an emoji picker to your form, allowing users to select emojis when creating messages.
How can I integrate Twilio with my Rails and ActionCable messaging system?
Twilio can be integrated into your Rails and ActionCable messaging system to send SMS notifications when a new message is received. First, you need to add the Twilio Ruby gem to your Gemfile with gem 'twilio-ruby'
and run bundle install
. Then, you need to set up a Twilio account and get your Account SID and Auth Token. You can use these credentials to send SMS notifications from your Rails application. For example, you can create a new Twilio client with client = Twilio::REST::Client.new(account_sid, auth_token)
and use client.messages.create
to send an SMS.
How can I store emoji in a Rails app with a MySQL database?
To store emoji in a Rails app with a MySQL database, you need to set the correct character set and collation for your database. You can do this by adding encoding: utf8mb4
and collation: utf8mb4_bin
to your database.yml file. Then, you need to run rake db:drop db:create db:migrate
to recreate your database with the new settings. This will allow you to store emoji in your database.
How can I build a simple chat messaging system in Rails?
Building a simple chat messaging system in Rails involves creating a new Rails application, setting up ActionCable for real-time messaging, creating a Message model and controller, and creating views for displaying and creating messages. You can use the rails new
command to create a new Rails application, and rails generate model Message content:text
and rails generate controller Messages
to create a Message model and controller. Then, you can set up ActionCable by adding mount ActionCable.server => '/cable'
to your routes.rb file and creating a new channel for messages.
How can I use the ChatGem in my Rails and ActionCable messaging system?
The ChatGem is a Ruby gem that provides a simple and easy-to-use API for building chat applications. To use the ChatGem in your Rails and ActionCable messaging system, you need to add gem 'chat_gem'
to your Gemfile and run bundle install
. Then, you can use the ChatGem’s API to create chat rooms, send messages, and manage users. For example, you can use ChatGem::Room.create(name: 'My Chat Room')
to create a new chat room, and ChatGem::Message.create(content: 'Hello, world!', room: my_chat_room)
to send a message.
Ilya Bodrov is personal IT teacher, a senior engineer working at Campaigner LLC, author and teaching assistant at Sitepoint and lecturer at Moscow Aviations Institute. His primary programming languages are Ruby (with Rails) and JavaScript. He enjoys coding, teaching people and learning new things. Ilya also has some Cisco and Microsoft certificates and was working as a tutor in an educational center for a couple of years. In his free time he tweets, writes posts for his website, participates in OpenSource projects, goes in for sports and plays music.