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 ...
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.
Ứ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
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
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";
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.
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:
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:
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); } }); })();
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:
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.