Build a Messaging System with Rails and ActionCable
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!