Ruby
Article

Build a Messaging System with Rails and ActionCable

By Ilya Bodrov-Krukowski

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 conversation
  • recevier_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 message
  • conversation_id – indexed field specifying the parent conversation
  • user_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!

  • Aywac

    Thanks for the well explained article, waiting for the next part.

    • Ilya Bodrov

      You are welcome! Everything is pretty much ready and should be published in 5-7-10 days. I publish updates in my Twitter about new articles so feel free to follow :)

  • Atapys

    Look polymorphic associations. I think it will help you.

    • Ilya Bodrov

      That’s an option as well :) We’ve already discussed this via e-mail, so I believe Nicolas decided to go with role-based system for simplicity

  • Atapys

    Hi, Ilya. Thanks for great article.
    Could you give an example how to implement notifications to the user, for example facebook?(Send, receive, mark read one, mark read all) I have ideas, but I would like to know your opinion. Thanks in advance.

    • Ilya Bodrov

      Well, notifications acts as messages (mostly). We can simply add additional attributes like “read” (boolean) and work with them. Of course, things may turn different when starting to implement them but all in all I don’t see huge differences

      • Atapys

        Thanks, Ilya.

  • Wce Komal

    waiting for next part

    • Ilya Bodrov

      It’s ready, so coming out really soon :) I post updates about new articles in my Twitter as well

  • lilipupu pupu

    Seems a great job, I will your this solution soon. Thanks for your explanation

    • Ilya Bodrov

      You are welcome!

  • Austin Ingram

    You should use app/views/application rather than app/views/shared for the _menu partial, rails will look for a controller-specific partial first then fall back to application. Allows easy controller-specific overriding of the menu.

    • Austin Ingram

      Your association naming seems a bit wonky to me, having an ‘author’ of a ‘Conversation’, but a ‘user’ of a PersonalMessage. A PersonalMessage most definitely has an author, but a conversation would have a starter or started_by

      • Ilya Bodrov

        This is of course not a final and the ideal solution where I ignored cases like “message not found” and other stuff. Of course I expect users to twist and bend it as they see fit. This more like a guidance to the general process of implementing described features. Anyways, thank you for these notes!

    • Austin Ingram

      In conversations_controller, I noticed this:

      def set_conversation
      @conversation = Conversation.find_by(id: params[:id])
      end

      I believe it should be:

      def set_conversation
      @conversation = Conversation.find(params[:id])
      end

      find(id) will raise ActiveRecord::RecordNotFound if the conversation isn’t found (which triggers 404 automatically), whereas find_by(id: id) will not, leading to a NoMethodError in show when you try to call personal_messages on nil, which ends up as a 500 error, as it’s never handled.

      • Austin Ingram

        This would also allow you to drop the @conversation && bit from the condition in check_participating!

    • Austin Ingram

      def participates?(user)
      author == user || receiver == user
      end

      Instead of this, I would add a method to conversation, ‘participants’ that returns [author, receiver] and just do participants.include?(user). Looks cleaner & would ease adding ‘group’ chats later.

  • Arpit Agarwal

    Rails beginner here. Can somebody explain me the below code from the tutorial:

    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}

    I did not understood the validates part, from where author and receiver came and why do we have to write this validation. It would be very helpful if someone can explain or point me in a right direction to understand it.

    • Ilya Bodrov

      It means that the combination “author-receiver” has to be unique. We can’t have 2 conversations with the same author and receiver. That’s pretty much it.

  • Hi Ilya,

    Really well-written tutorial – thank you! I do seem to be running into an issue though. I’ve created a new app, spun up the server, and created two users: bob and alice. When either user tries to message the other by clicking `send a message`, I am brought to the conversations#index (as expected), but no conversation has been created. The URL briefly flashes “http://localhost:3000/personal_messages/new?receiver_id=2”, but then defaults to the root and only dislays the menu bar and the ‘Your Conversations’ heading. I’m having a hard time debugging this – any advice as to why this may not have been created?

    • Here’s what was logged, in case it helps.

      Started GET “/personal_messages/new?receiver_id=2” for ::1 at 2016-12-28 16:50:28 -0500
      Processing by PersonalMessagesController#new as HTML
      Parameters: {“receiver_id”=>”2”}
      User Load (0.3ms) SELECT “users”.* FROM “users” WHERE “users”.”id” = ? ORDER BY “users”.”id” ASC LIMIT ? [[“id”, 1], [“LIMIT”, 1]]
      Conversation Load (0.2ms) SELECT “conversations”.* FROM “conversations” WHERE “conversations”.”id” IS NULL LIMIT ? [[“LIMIT”, 1]]
      Redirected to http://localhost:3000/
      Filter chain halted as :find_conversation! rendered or redirected
      Completed 302 Found in 4ms (ActiveRecord: 0.5ms)

      Started GET “/” for ::1 at 2016-12-28 16:50:28 -0500
      Processing by ConversationsController#index as HTML
      User Load (0.3ms) SELECT “users”.* FROM “users” WHERE “users”.”id” = ? ORDER BY “users”.”id” ASC LIMIT ? [[“id”, 1], [“LIMIT”, 1]]
      Rendering conversations/index.html.erb within layouts/application
      Conversation Load (0.3ms) SELECT “conversations”.* FROM “conversations” WHERE ((conversations.author_id = 1 OR conversations.receiver_id = 1)) ORDER BY updated_at DESC
      Rendered collection of templates [0 times] (0.0ms)
      Rendered conversations/index.html.erb within layouts/application (2.6ms)
      Rendered shared/_menu.html.erb (1.1ms)
      Completed 200 OK in 52ms (Views: 45.4ms | ActiveRecord: 0.6ms)

      • Ah, sorry. I was able to check my controllers against the controllers in the repo you posted. Found an error in my personal_messages_controller. Thanks again!

  • Тарас Білоус

    Ilya, thank you!
    I think you will wanted to write: has_many :received_conversations, class_name: ‘Conversation’, foreign_key: ‘receiver_id’
    but you will wrote: has_many :received_conversations, class_name: ‘Conversation’, foreign_key: ‘received_id’
    I’m right?

    • Ilya Bodrov

      You are welcome, though I don’t see any difference between these lines :)

      • Тарас Білоус

        foreign_key: ‘receiver_id’ != foreign_key: ‘received_id’ ;-)

        P.S. Dear Ilya, do you have repo for this article?

        • Ilya Bodrov

          Oh yes, sorry, I did not notice that one letter. No, that line is correct because this key corresponds to the one we added during a migration.

          Sure, I always list repo and working demo at the beginning. For example, here is the line from user.rb https://github.com/bodrovis/Sitepoint-source/blob/master/Custom_Messaging_System/app/models/user.rb#L8

          Here is the demo https://sitepoint-custom-messaging.herokuapp.com/users/sign_in

          • Тарас Білоус

            But In migration you are wrote ‘rails g model Conversation author_id:integer:index receiver_id:integer:index’.
            Also in the article find_converstion method is in conversations_controller.rb. But he is for personal_messages_controller.rb

            Thank you Ilya. It is a great job. You really helped me for implement messenger in my project. if you want i can add rspec specs to your example later

          • Ilya Bodrov

            Yeah, that’s a confusion and seems like a typo.

            Adding RSpec will be nice exercise, do send me your PR :)

Recommended
Sponsors
Get the latest in Ruby, once a week, for free.