Rails Chat Application – part 1 [tutorial]

Rails Chat Application – part 1 [tutorial]

Have you ever dreamed about creating your own chat application that works like Facebook Messenger? In this tutorial, we will use the Rails 5 feature called ActionCable to build one without using any front-end framework (expect jQuery – but it’s just a light library). Our goal is to create a real time Rails chat application that sends and shows messages to a recipient instantly without any page refresh.

The final application will look like:

rails-chat-application-screenshot-1

This tutorial is split into two parts. In the first part, we will cover and describe the basics of our application without using ActionCable, including how to start a new conversation and send a basic message. In the second part, we will add real time messages and new message notifications. The tutorial is split to provide detailed information about the Rails 5 new feature – ActionCable, and to better describe how our application works and what every single line of the application does.

Basic setup

Make sure that you have Rails 5.0.0 installed. To check that you do, run:

$ rails -v 
Rails 5.0.0.1

If you don’t have the latest version, download it from RubyGems.org running:

$ gem install rails

If you are done with the Rails 5 setup, please create a new application project. We will use SQLite as a database provider.

$ rails new chat
$ cd chat

Now let’s create a separate gemset for our application. To do so, run:

$ touch .ruby-version 
$ echo "ruby-2.3.1" > .ruby-version 
$ touch .ruby-gemset
$ echo "chat" > .ruby-gemset

Then quit from the directory and re-enter to create a new gemset and to install bundler:

$ cd ..
$ cd chat
$ gem install bundler
$ bundle install

Adding devise and seeds

Let’s add a devise to our Gemfile. The file should look like this (I don’t use coffee-script and other gems):

source 'https://rubygems.org'

gem 'rails', '~> 5.0.0', '>= 5.0.0.1'
gem 'sqlite3'
gem 'puma', '~> 3.0'
gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'jquery-rails'
gem 'devise'

group :development, :test do
  gem 'byebug', platform: :mri
end

group :development do
  gem 'listen', '~> 3.0.5'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

Then run the following commands:

$ rails generate devise:install
$ rails generate devise user
$ rake db:migrate

Add the authenticate_user! filter to the ApplicationController:

class ApplicationController < ActionController::Base
  before_action :authenticate_user!

  protect_from_forgery with: :exception
end

Create our home page controller and updates routes.rb file:

$ rails g controller home index
Rails.application.routes.draw do
  devise_for :users
  root 'home#index'
end

Replace the application.js and the layouts/application.html.erb files with (we won’t use turbolinks):

//= require jquery
//= require jquery_ujs
//= require_tree .
  
    Chat
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag    'application', media: 'all' %>
    <%= javascript_include_tag 'application' %>
  
  
    
<%= yield %>

Replace the seed.rb file and fill it with our database:

password = 'pass123'
1.upto(5) do |i|
  User.create(
    email: "user-#{i}@example.com",
    password: password,
    password_confirmation: password
  )
end
$ rake db:seed

Adding bootstrap

As in every tutorial, we will use Twitter Bootstrap in our CSS. Let’s add it:

Add to the Gemfile:

gem 'bootstrap-sass', '~> 3.3.6'

Bundle it:

$ bundle install

Rename application.css to application.scss and replace it with:

/*
 *= require_tree .
 *= require_self
 */

@import "bootstrap-sprockets";
@import "bootstrap";

We won’t use Bootstrap JS features, so we aren’t adding it to our application.

Adding application models

Our application will have three models (we’ve added one already):

  • User
  • Conversation
  • Message

It’s everything that we will need. One conversation has a recipient and a sender – two users. A message has a sender – a user that belongs to a conversation. Of course, one user will have many messages and conversations.

Let’s add code for this schema and models:

$ rails g model conversation recipient_id:integer:index sender_id:integer:index

Update generated file and add an unique index:

add_index :conversations, [:recipient_id, :sender_id], unique: true
$ rails g model message body:text user:references conversation:references
$ rake db:migrate

Now we need to connect everything together. Add to the User class:

has_many :messages
has_many :conversations, foreign_key: :sender_id

Also we need to edit a bit our conversation model:

class Conversation < ApplicationRecord
  has_many :messages, dependent: :destroy
  belongs_to :sender, foreign_key: :sender_id, class_name: User
  belongs_to :recipient, foreign_key: :recipient_id, class_name: User

  validates :sender_id, uniqueness: { scope: :recipient_id }

  def opposed_user(user)
    user == recipient ? sender : recipient
  end
end

We have two users in the model, so we need to recognize each by adding a custom attributes to the belongs_to method.

We also added uniqueness validation. Remember that we also validate it with index in the database, so even without Rails validation, we can’t add a duplicated record too. It’s awesome!

The opposed_user methods returns an opposed user to the requested user. We will need it later.

Adding first views

If you haven’t already noticed, Facebook supports an awesome chat feature. If you open a chat window once, and close a page or a tab in a browser, it’s still active and appearing on the page when you come back to it later. It looks like this:

rails-chat-application-screenshot-2

How could we add this same feature to our chat application? Simple! We’ll keep in a session array with conversation_ids which we opened. While we close a window, we’ll remove a conversation_id from the array.

First, update the index method in the HomeController:

class HomeController < ApplicationController
  def index
    session[:conversations] ||= []

    @users = User.all.where.not(id: current_user)
    @conversations = Conversation.includes(:recipient, :messages)
                                 .find(session[:conversations])
  end
end

We’re also including other models under the @conversations query to avoid N+1 queries.
Under the @users query, we select all users that are not us. They’ll be displayed on the chat list.

Let’s add views. Replace the home/index.html.erb file with:

    <% @conversations.each do |conversation| %> <%= render 'conversations/conversation', conversation: conversation, user: current_user %> <% end %>

User list

    <% @users.each do |user| %>
  • <%= user.email %>
  • <% end %>

Add the conversations/_conversation.html.erb file:

  • <%= link_to conversation.opposed_user(user).email, ”, class: ‘toggle-window’ %> <%= link_to “x”, ”, class: “btn btn-default btn-xs pull-right” %>
  • Add the conversations/_conversation_content.html.erb:

    <% messages.each do |message| %>
      <%= render message, user: user %>
    <% end %>

    Add the messages/_message.html.erb file:

  • <%= message.body %>
  • Add styles to the application.scss file:

    ul {
      padding-left: 0px;
      list-style: none;
    }
    

    What have we just added? Please refresh the page and check.

    rails-chat-application-screenshot-3

    For now, the left part of the page is empty for displayed conversations. On the right, there is a user list that contains all users. We will now be able to start a conversation with any of them!

    Starting a conversation

    We have everything prepared for starting a new conversation, now let’s add some code!

    Start by  adding our new resource to the routes.rb file:

    Rails.application.routes.draw do
      root 'home#index'
    
      devise_for :users
    
      resources :conversations, only: [:create]
    end

    Update the conversation.rb file:

    class Conversation < ApplicationRecord
      has_many :messages, dependent: :destroy
      belongs_to :sender, foreign_key: :sender_id, class_name: User
      belongs_to :recipient, foreign_key: :recipient_id, class_name: User
    
      validates :sender_id, uniqueness: { scope: :recipient_id }
    
      scope :between, -> (sender_id, recipient_id) do
        where(sender_id: sender_id, recipient_id: recipient_id).or(
          where(sender_id: recipient_id, recipient_id: sender_id)
        )
      end
    
      def self.get(sender_id, recipient_id)
        conversation = between(sender_id, recipient_id).first
        return conversation if conversation.present?
    
        create(sender_id: sender_id, recipient_id: recipient_id)
      end
    
      def opposed_user(user)
        user == recipient ? sender : recipient
      end
    end

    We added the between scope. It returns a conversation between two requested users. The get method is also a new code. It tries to get a conversation between two users – and if it’s present, it returns it. If there isn’t any, it creates a new one.

    Add the ConversationsController:

    class ConversationsController < ApplicationController
      def create
        @conversation = Conversation.get(current_user.id, params[:user_id])
        
        add_to_conversations unless conversated?
    
        respond_to do |format|
          format.js
        end
      end
    
      private
    
      def add_to_conversations
        session[:conversations] ||= []
        session[:conversations] << @conversation.id
      end
    
      def conversated?
        session[:conversations].include?(@conversation.id)
      end
    end

    In the create method, we just get a conversation between a current user and requested user. If in the session there is no added conversation_id yet, we’ll add it, if not, we’ll just respond with a js file.

    Update in the home.index.html 18th line, from:

  • <%= user.email %>
  • To:

  • <%= link_to user.email, conversations_path(user_id: user), remote: true, method: :post %>
  • Now, create the conversations/create.js.erb file:

    var conversations = $('#conversations-list');
    var conversation = conversations.find("[data-conversation-id='" + "<%= @conversation.id %>" + "']");
    
    if (conversation.length !== 1) {
      conversations.append("<%= j(render 'conversations/conversation', conversation: @conversation, user: current_user) %>");
      conversation = conversations.find("[data-conversation-id='" + "<%= @conversation.id %>" + "']");
    }
    
    conversation.find('.panel-body').show();
    
    var messages_list = conversation.find('.messages-list');
    var height = messages_list[0].scrollHeight;
    messages_list.scrollTop(height);

    What are we doing here? First of all, we try to find an existing conversation window in the ‘#conversations-list’ div. If it doesn’t exist (length !== 1), we add to the ‘#conversations-list’ div a new conversation. If we add it, we need to reassign to the conversation variable added element. Later, we call the show() method to display a conversation’s window.

    Finally, we check the height of the window and scroll to the bottom of it. If we send a new message, we should always be at the bottom of a conversation.

    The application home page should look like (it depends how many windows you have opened):

    rails-chat-application-screenshot-4

    Closing and minimizing a conversation

    What happens if we open a conversation by a mistake? We can’t close it or even minimize yet. Let’s add these features!

    Update the routes.rb file:

    Rails.application.routes.draw do
      root 'home#index'
    
      devise_for :users
    
      resources :conversations, only: [:create] do
        member do
          post :close
        end
      end
    end

    Replace the 5th line in the _converastion.html.erb file with:

    <%= link_to "x", close_conversation_path(conversation), class: "btn btn-default btn-xs pull-right", remote: true, method: :post %>
    

    Add the close method to the Conversations controller:

    class ConversationsController < ApplicationController
      ...
    
      def close
        @conversation = Conversation.find(params[:id])
    
        session[:conversations].delete(@conversation.id)
    
        respond_to do |format|
          format.js
        end
      end
    
      ...
    end

    It removes the requested converastion_id from the session and closes a window on the front-end.

    Add the close.js.erb file:

    $('#conversations-list').find("[data-conversation-id='" + "<%= @conversation.id %>" + "']").parent().remove();

    This one line finds requested div and removes it from the body tag.

    Add method which is responsible for minimizing a window:

    //= require jquery
    //= require jquery_ujs
    //= require_tree .
    
    (function() {
      $(document).on('click', '.toggle-window', function(e) {
        e.preventDefault();
        var panel = $(this).parent().parent();
        var messages_list = panel.find('.messages-list');
    
        panel.find('.panel-body').toggle();
        panel.attr('class', 'panel panel-default');
    
        if (panel.find('.panel-body').is(':visible')) {
          var height = messages_list[0].scrollHeight;
          messages_list.scrollTop(height);
        }
      });
    })();

    It runs a jQuery toggle() method and scrolls to the bottom of the window if it’s visible.

    Now you should be able to open, close, and minimize a conversation window!

    Sending a message

    Now for the most important feature, sending a new message! It will be very simple, we just need to create a new record and append it to a conversation’s window.

    Let’s edit the routes.rb file:

    resources :conversations, only: [:create] do
        ...
        resources :messages, only: [:create]
    end

    Add the form to the _converastion.html.erb file after the ‘.message-list’ div.

  • <%= link_to conversation.opposed_user(user).email, ”, class: ‘toggle-window’ %> <%= link_to “x”, close_conversation_path(conversation), class: “btn btn-default btn-xs pull-right”, remote: true, method: :post %>
  • Add the MessagesController:

    class MessagesController < ApplicationController
      def create
        @conversation = Conversation.includes(:recipient).find(params[:conversation_id])
        @message = @conversation.messages.create(message_params)
    
        respond_to do |format|
          format.js
        end
      end
    
      private
    
      def message_params
        params.require(:message).permit(:user_id, :body)
      end
    end

    The create method creates a new message object and responds with the js file.

    Create the create.js.erb file inside the messages directory:

    var conversation = $('#conversations-list').find("[data-conversation-id='" + "<%= @conversation.id %>" + "']");
    conversation.find('.messages-list').find('ul').append("<%= j(render 'messages/message', message: @message, user: current_user) %>");
    conversation.find('textarea').val('');

    This JavaScript code finds the requested conversation window and appends a partial with a new message.

    Add styles to the application.scss file to set a max height of a window:

    .messages-list {
      max-height: 200px;
      overflow-y: auto;
      overflow-x: hidden;
    }
    
    
    .message-sent {
      position: relative;
      background-color: #D9EDF7;
      border-color: #BCE8F1;
      margin: 5px 20px;
      padding: 10px;
      float: right;
    }
    
    
    .message-received {
      background-color: #F1F0F0;
      border-color: #EEEEEE;
      margin: 5px 20px;
      padding: 10px;
      float: left;
    }
    

    Now we can test our new feature! It should look like:

    rails-chat-application-screenshot-5

    Conclusion

    In a few steps, we created a fully working chat application without touching any front-end framework. It’s not real time chat yet, but in the next part of the application we will cover ActionCable and use it to build awesome features!

    I hope that you liked this tutorial and you can subscribe to our newsletter for additional information about the next!

    Whole code could be found here.

    From the blog

    You might find these interesting

    Read More
    24 min read

    Local SEO for HVAC Contractors: The 2026 Playbook

    Read More
    26 min read

    HVAC Content Marketing: What to Write and Where to Post

    Read More
    11 min read

    How to Choose an HVAC Marketing Company [2026 Guide]

    Join our newsletter

    Newsletter Signup Inline Form