Rails Chat Application - Part I

Chắc hẳn tất cả các bạn lập trình viên đều đã từng ao ước viết một ứng dụng chát giống như Facebook Messager. Trong bài viết này mình sẽ hướng dẫn các bạn viết một ứng dụng chat real time sử dụng ActionCable của Rails 5, nghĩa là khi một người dùng send message thì tất cả những thành viên còn lại sẽ nhận được message đó ngay mà không cần refresh lại trang. Kết quả của ứng dụng này sẽ trông như sau:

Bài viết này sẽ chia làm hai phần:

  • Phần 1: Xây dựng một ứng dụng chat cơ bản, không sử dụng ActionCable của Rails.
  • Phần 2: Thêm ActionCable của Rails 5 để làm cho ứng dụng chát ở phần 1 có thêm chức năng real time.

Basic setup

Ứng dụng sẽ viết trên Rails 5 nên bước đầu tiên kiểm tra xem version hiện tại của Rails.

rails -v 
Rails 5.0.0.1

Kiểm tra version của ruby

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

Create một ứng dụng Chat

rails new chat
cd chat

Thêm Gemfile và tạo dữ liệu Seed

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

Chạy các lệnh setup devise sau:

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

Thêm authenticate_user! filter vào ApplicationController:

class ApplicationController < ActionController::Base
  before_action :authenticate_user!
 
  protect_from_forgery with: :exception
end

Create HomeController:

rails g controller home index

Config cho HomeController làm root path:

Rails.application.routes.draw do
  devise_for :users
  root 'home#index'
end

Thêm Jquery vào application.js

//= require jquery
//= require jquery_ujs
//= require_tree .

File layout application.html.erb sẽ có dạng như sau:

<!DOCTYPE html>
<html>
  <head>
    <title>Chat</title>
    <%= csrf_meta_tags %>
    <%= stylesheet_link_tag    'application', media: 'all' %>
    <%= javascript_include_tag 'application' %>
  </head>
  <body>
    <div class="container">
      <%= yield %>
    </div>
  </body>
</html>

Create dữ liệu test trong seed.rb file:

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

Chạy lệnh:

rake db:seed

Thêm Bootstrap

Add Gemfile:

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

# Run command

bundle install

Đổi tên file application.css thành application.scss và import thêm bootstrap vào.

/*
 *= require_tree .
 *= require_self
 */
 
@import "bootstrap-sprockets";
@import "bootstrap";

Thêm model cho ứng dụng

Trong ứng dụng chat này chúng ta sẽ sử dụng 3 model.

  • User
  • Message
  • Conversation

Thêm model conversation bằng lệnh sau.

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

Update generated file và thêm index:

add_index :conversations, [:recipient_id, :sender_id], unique: true

Tương tự với model Message

rails g model message body:text user:references conversation:references
rake db:migrate

Thêm quan hệ cho model User

has_many :messages
has_many :conversations, foreign_key: :sender_id

Khai báo model Conversation như sau:

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

Method opposed_user sẽ dùng để phân biệt user hiện tại là người gửi hay nhận message sẽ dùng ở phía dưới.

Thêm view cho home page.

Create HomeController với nội dung như sau:

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

Create file home/index.html.erb:

<div class="row">
  <div class="col-md-9">
    <ul id="conversations-list">
      <% @conversations.each do |conversation| %>
        <%= render 'conversations/conversation', conversation: conversation, user: current_user %>
      <% end %>
    </ul>
  </div>
 
  <div class="col-md-3">
    <div class="panel panel-primary">
      <div class="panel-heading">
        <h3 class="panel-title">User list</h3>
      </div>
      <div class="panel-body">
        <ul>
          <% @users.each do |user| %>
            <li><%= user.email %></li>
          <% end %>
        </ul>
      </div>
    </div>
  </div>
</div>

Thêm file conversations/_conversation.html.erb

<li>
  <div class="panel panel-default" data-conversation-id="<%= conversation.id %>">
    <div class="panel-heading">
      <%= link_to conversation.opposed_user(user).email, '', class: 'toggle-window' %>
      <%= link_to "x", '', class: "btn btn-default btn-xs pull-right" %>
    </div>
 
    <div class="panel-body" style="display: none;">
      <div class="messages-list">
        <ul>
          <%= render 'conversations/conversation_content', messages: conversation.messages, user: user %>
        </ul>
      </div>
    </div>
  </div>
</li>

Thêm file conversations/_conversation_content.html.erb

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

Thêm file messages/_message.html.erb

<li>
  <div class="row">
    <div class="<%= user.id == message.user_id ? 'message-sent' : 'message-received' %>">
      <%= message.body %>
    </div>
  </div>
</li>

Một chút css cho application.scss file:

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

Chúng ta sẽ có kết quả như sau:

Starting a conversation

Bắt đầu bằng việc khai báo trong routes.rb file:

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

Update 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

Update ConversationsController như sau:

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

Update dòng 18 trong file home.index.html từ:

<li><%= user.email %></li>

Thành:

<li><%= link_to user.email, conversations_path(user_id: user), remote: true, method: :post %></li>

Create 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);

Như vậy chúng ta đã có kết quả như sau:

Thêm chức năng xóa Conversation

Khai báo thêm ở 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

Thay đổi dòng 5 trong file _converastion.html.erb thành:

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

Thêm method close trong ConversationController:

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

Thêm file close.js.erb

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

Thêm jquery cho sự kiện close:

//= 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);
    }
  });
})();

Sending a message

Khai báo thêm routes messages.

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

Thay đổi file _converastion.html.erb như sau:

<li>
  <div class="panel panel-default" data-conversation-id="<%= conversation.id %>">
    <div class="panel-heading">
      <%= 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 %>
    </div>
 
    <div class="panel-body" style="display: none;">
      <div class="messages-list">
        <ul>
          <%= render 'conversations/conversation_content', messages: conversation.messages, user: user %>
        </ul>
      </div>
      <%= form_for [conversation, conversation.messages.new], remote: true do |f| %>
        <%= f.hidden_field :user_id, value: user.id %>
        <%= f.text_area :body, class: "form-control" %>
        <%= f.submit "Send", class: "btn btn-success" %>
      <% end %>
    </div>
  </div>
</li>

Thêm file 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

Thêm file create.js.erb

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('');

Thêm css cho file application.scss

.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;
}

Kết quả cuối cùng hiện được:

Tóm tắt

Do bài viết quá dài nên mình tạm dừng ở đây, sau phần này chúng ta đã có một ứng dụng chát đơn giản, có thể mở hoặc đóng với từng user khác nhau, tuy vậy để nhận được message chúng ta vẫn cần refresh lại trang. Trong phần sau mình sẽ sử dụng ActionCable của Rails 5 để làm cho ứng dụng có thể real time nghĩa là không cần refresh lại trang mà user vẫn nhận được message ngay tại thòi điểm gửi.