Rails Chat Application - Part I
Bài đăng này đã không được cập nhật trong 3 năm
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.
All rights reserved