Ruby
Article
By Ilya Bodrov-Krukowski

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

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!

Recommended
Sponsors
The most important and interesting stories in tech. Straight to your inbox, daily. Get Versioning.
Login or Create Account to Comment
Login Create Account