実装すること
ActionCableを使用して特定のユーザーとリアルタイムチャットをできるようにしていきます。
完成イメージ
ActionCableとは
RailsにおいてWebsocketによる双方向通信を実現してくれているものです。
(Webにおいて双方向通信を低コストで行うための仕組み)
参考:Railsガイドhttps://railsguides.jp/action_cable_overview.html
ER図
「EntryテーブルとDirectMessageテーブル」が「UserテーブルとRoomテーブル」の中間テーブルになっています。
ページ設計
①ユーザーの詳細ページ(users/show)から新規でダイレクトメッセージを送ることができる。
②チャットルーム(rooms/show)に入ると、
(1)メッセージフォームまでスクロールされる(新着メッセージは下に蓄積されていくため)
(2)チャットルームでは、ログインユーザーが右に相手が左に表示される
③チャットルーム一覧(rooms/index)はヘッダーのリンクから飛べる
Gemの追記
jQueryを使えるようにします。
gem 'jquery-rails'
//
//= require rails-ujs
//= require activestorage
//= require turbolinks
//= require jquery ←追記
//= require jquery_ujs ←追記
//= require_tree .
$ bundle install
モデルの作成
Userモデルはデバイスを元に作成したものとします。
作成手順は下記リンク先で説明しております。
[Rails]Ajaxを用いて非同期で投稿機能といいね機能の実装https://qiita.com/yuto_1014/items/78d8b52d33a12ec33448
それではRoomモデル、Entryモデル、DirectMessageモデルを作成していきます。
$ rails g model Room name:string
$ rails g model Entry user_id:integer room_id:integer
$ rails g model DirectMessage content:string user_id:integer room_id:integer
アソシエーションの確認
多対多の関連がある時はthroughオプション
をつけてあげると中間テーブルを経由して関連先のモデルを取得できるようになります。
Userモデル
has_many :entries
has_many :direct_messages
has_many :rooms, through: :entries
Roomモデル
dependent: :destroy
で、roomの特定のレコードが消えれば、そのレコードの紐づいたentryレコードとdirect_messageレコードも消えるようにしています。
has_many :entries, dependent: :destroy
has_many :direct_messages, dependent: :destroy
has_many :users, through: :entries
Entryモデル
belongs_to :user
belongs_to :room
DirectMessageモデル
データが作成されたら非同期でブロードキャスト処理を実行するようにします。
after_create
ではなく、after_create_commit
を使っている点に注意してください。
トランザクションをコミットしたあとでブロードキャストしないと、他のクライアントからデータが見えない恐れがあります。
belongs_to :user
belongs_to :room
#ブロードキャスト
after_create_commit { DirectMessageBroadcastJob.perform_later self }
コントローラーの作成
$ rails g controller users
$ rails g controller rooms
ルーティングの作成
devise_for :users
resources :users
resources :rooms
ユーザー詳細ページ(コントローラー・ビューの編集)
users_controller
def show
@user = User.find(params[:id])
#チャット
if user_signed_in?
#Entry内のuser_idがcurrent_userと同じEntry
@currentUserEntry = Entry.where(user_id: current_user.id)
#Entry内のuser_idがMYPAGEのparams.idと同じEntry
@userEntry = Entry.where(user_id: @user.id)
#@user.idとcurrent_user.idが同じでなければ
unless @user.id == current_user.id
@currentUserEntry.each do |cu|
@userEntry.each do |u|
#もしcurrent_user側のルームidと@user側のルームidが同じであれば存在するルームに飛ぶ
if cu.room_id == u.room_id then
@isRoom = true
@roomId = cu.room_id
end
end
end
#ルームが存在していなければルームとエントリーを作成する
unless @isRoom
@room = Room.new
@entry = Entry.new
end
end
end
end
ユーザー詳細ページ(users/show.html.erb)
・<% if @isRoom == true %>
で、roomが存在していれば既存のroomへ飛び、まだroomが存在していなければroomを作成するようにしています。
・<%= fields_for @entry do |e| %>
で、roomと同時にentryも作成するようにしています。
<% if user_signed_in? %>
<% unless @user.id == current_user.id %>
<!-- 既にroomが存在していれば既存のroomへ -->
<% if @isRoom == true %>
<!-- メールアイコンでroomに飛べるようにする -->
<%= link_to room_path(@roomId) do %>
<button id="dm_submit"><i class="fas fa-envelope"></i></button>
<% end %>
<% else %>
<!-- roomが存在していなければroomを作成する -->
<%= form_for @room, url: rooms_path do |f| %>
<%= fields_for @entry do |e| %>
<%= e.hidden_field :user_id, :value=> @user.id %>
<% end %>
<button type="submit" id="dm_submit"><i class="fas fa-envelope"></i></button>
<% end %>
<% end %>
<% end %>
<% end %>
ルームの作成・閲覧ページ(コントローラー・ビューの編集)
rooms_controller
・**『index』では、ログインユーザーのroom一覧の相手の名前を表示しています。
ログインユーザーが所属しているroomの中でログインユーザー以外のユーザー名を取って来ています。
・『show』は、roomの詳細を表示しています。
roomが存在していれば、そのroomに紐づいたdirect_messagesとentriesを取ってきます。
・『create』で、roomを作成します。
roomの作成と同時に、そのroomに紐づいたentryユーザーを2名(ログインユーザーとユーザー詳細ページのユーザー)を作成します。
・『destroy』**で、roomを削除します。
def index
@user = current_user
@currentEntries = current_user.entries
#@currentEntriesのルームを配列にする
myRoomIds = []
@currentEntries.each do |entry|
myRoomIds << entry.room.id
end
#@currentEntriesのルーム且つcurrent_userでないEntryを新着順で取ってくる
@anotherEntries = Entry.where(room_id: myRoomIds).where.not(user_id: @user.id).order(created_at: :desc)
end
def show
@room = Room.find(params[:id])
#ルームが作成されているかどうか
if Entry.where(:user_id => current_user.id, :room_id => @room.id).present?
@direct_messages = @room.direct_messages
@entries = @room.entries
else
redirect_back(fallback_location: root_path)
end
end
def create
@room = Room.create(:name => "DM")
#entryにログインユーザーを作成
@entry1 = Entry.create(:room_id => @room.id, :user_id => current_user.id)
#entryにparamsユーザーを作成
@entry2 = Entry.create(params.require(:entry).permit(:user_id, :room_id).merge(:room_id => @room.id))
redirect_to room_path(@room.id)
end
def destroy
room = Room.find(params[:id])
room.destroy
redirect_to users_rooms_path
end
ルーム一覧(rooms/index.html.erb)
<h2>メッセージ一覧</h2>
<% @anotherEntries.each do |e| %>
<table class="table table-striped">
<tr>
<td>
<!-- 名前からroomの詳細に飛べるようにリンク化 -->
<%= link_to room_path(e.room.id) do %>
<%= attachment_image_tag e.user, :profile_image, format: 'jpeg', class: "rounded-circle", fallback: "no_image.jpg", size: "30x30" %>
<%= e.user.name %>
<% end %>
</td>
<td>
<!-- 最新メッセージ内容の最初の7文字を表示する -->
<% dm = DirectMessage.find_by(id: e.room.direct_message_ids.last).content %>
<%= truncate(dm, length: 10) %>
</td>
<td>
<h6 style="color: #C0C0C0;"><%= e.updated_at.strftime("%Y/%m/%d %H:%M") %></h6>
</td>
<td>
<!-- ゴミ箱アイコンクリックでroomを消せるようにする -->
<%= link_to room_path(e.room.id), method: :delete do %>
<i class="fas fa-trash" style="color: black;"></i>
<% end %>
</td>
</tr>
</table>
<% end %>
ルーム詳細(rooms/show.html.erb)
<% @entries.each do |e| %>
<!-- eachで回したentryユーザーがログインユーザーであれば、@classに"current_user"の文字列を代入する。ログインユーザーであれば、空白を代入する -->
<% current_user == e.user ? @class = "current_user" : @class = "" %>
<!-- eachで回したentryユーザーがログインユーザーでなければ、entryユーザー名を表示する -->
<% if e.user != current_user %>
<h5 class="text-left <%= @class %>" id="<%= @class %>" style="font-size: 30px;" data-id="<%= e.user.id %>"><%= link_to "@#{e.user.name}", user_path(e.user_id) %></h5>
<% else %>
<h5 class="text-left <%= @class %>" id="<%= @class %>" data-id="<%= e.user.id %>"></h5>
<% end %>
<% end %>
<!-- メッセージ内容は、パーシャルにします -->
<div id="direct_messages" data-room_id="<%= @room.id %>">
<%= render @direct_messages %>
</div>
<!-- メッセージフォーム -->
<form>
<label style="color: white;" id="target">新しいメッセージを作成</label><br>
<input type="text" id="chat-input" data-behavior="room_speaker" class="form-control">
</form>
<script>
//トークルーム遷移時に入力フォーム記載場所にスクロールする(最新トークは下にあるため)
var element = document.getElementById('target'); // 移動させたい位置の要素を取得
var rect = element.getBoundingClientRect();
var position = rect.top;
setTimeout( function() {
scrollTo(0, position);}
, 1000);
</script>
メッセージ内容(direct_messages/_direct_message.html.erb)
<p class="<%= direct_message.id %>" style= "color: white; margin-top: 0;">
<%= attachment_image_tag direct_message.user, :profile_image, fallback: "no_image.jpg", class:"profile-img-circle", size: "40x40" %>
<%= direct_message.user.name %>
</p>
<p class="<%= direct_message.id %>">
<span class="balloon1-top">
<h7 class="dm_content"><%= direct_message.content %></h7><br>
<h7 style="color: #C0C0C0;"><%= direct_message.created_at.strftime("%Y/%m/%d %H:%M") %></h7>
</span>
</p><br>
<!-- メッセージの表示位置を指定する -->
<script>
//メッセージのユーザーidを取ってくる
var direct_message = <%= direct_message.user.id %>;
//id="current_user"の内容を取得
var dm_user = document.getElementById('current_user');
//dm_userのdata-idを取得する
var current_user = dm_user.getAttribute('data-id')
//dm_userがログインユーザーであれば右に表示する
if(direct_message == current_user){
$('p.' + <%= direct_message.id %>).css('text-align', 'right');
}else{
$('p.' + <%= direct_message.id %>).css('text-align', 'left');
}
</script>
roomチャンネルの作成
各ユーザーは、複数のケーブルチャネルにサブスクライブできます。各チャネルには機能の論理的な単位がカプセル化され、そこで行われることは、コントローラが通常のMVPセットアップで行うことと似ています。
参考:Railsガイドhttps://railsguides.jp/action_cable_overview.html
今回はspeakメソッドを持っているroomチャネルを作成します。
$ rails g channel room speak
これを実行することで下記のファイルが作成されます。
・room_channel.rb
に、サーバー側の記述を記載します。
・room.coffee
に、クライアント側の記述を記載します。
app/channels/room_channel.rb
app/assets/javascripts/channels/room.coffee
room_channel
・speakメソッド
で、メッセージ内容(content)、user_id、room_idを作成します。
class RoomChannel < ApplicationCable::Channel
#接続されたとき
def subscribed
# stream_from "some_channel"
stream_from "room_channel_#{params['room']}"
end
#切断されたとき
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def speak(data)
DirectMessage.create! content: data['direct_message'], user_id: current_user.id, room_id: params['room']
end
end
room.coffee
document.addEventListener 'turbolinks:load', ->
if App.room
App.cable.subscriptions.remove App.room
App.room = App.cable.subscriptions.create { channel: "RoomChannel", room: $('#direct_messages').data('room_id') },
#通信が確立された時
connected: ->
#通信が切断された時
disconnected: ->
#値を受け取った時
received: (data) ->
#投稿を追加
$('#direct_messages').append data['direct_message']
#サーバーサイドのspeakアクションにdirect_messageパラメータを渡す
speak: (direct_message) ->
@perform 'speak', direct_message: direct_message
$('#chat-input').on 'keypress', (event) ->
#return キーのキーコードが13
if event.keyCode is 13
#speakメソッド,event.target.valueを引数に.
App.room.speak event.target.value
event.target.value = ''
event.preventDefault()
jobの作成・編集
非同期でブロードキャストするためのDirectMessageBroadcastジョブを作成します。
$ rails g job DirectMessageBroadcast
direct_message_broadcast_job.rb
performメソッド
でブロードキャストを実行します。
このとき、direct_messageに単純な文字列ではなく、direct_messages/direct_messagパーシャルのHTMLを返しています。
ApplicationController.renderer.renderメソッドを使うと、コントローラ以外の場所でビューをレンダリングできます。
class DirectMessageBroadcastJob < ApplicationJob
queue_as :default
def perform(direct_message)
ActionCable.server.broadcast "room_channel_#{direct_message.room_id}", direct_message: render_direct_message(direct_message)
end
private
def render_direct_message(direct_message)
ApplicationController.renderer.render partial: 'direct_messages/direct_message', locals: { direct_message: direct_message }
end
end
ログインユーザーの情報を取得する(connection.rb)
ApplicationCable::Connectionクラスを使って、認証情報を定義します。
identified_byはコネクションを識別するキーとなるものです。connectメソッドはコネクションの接続時に呼ばれるメソッドです。ここではコネクションの識別キーとして、ログイン時に設定したCookieからuser_idを取り出しています。
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
protected
def find_verified_user
if verified_user = User.find_by(id: cookies.signed['user.id'])
verified_user
else
reject_unauthorized_connection
end
end
end
end
最後に
最後までご覧いただきありがとうございます。
初学者ですので間違っていたり、分かりづらい部分もあるかと思います。
何かお気付きの点がございましたら、お気軽にコメントいただけると幸いです。
参考
ActionCableを用いてリアルタイムチャットの実装
https://freecamp.life/rails-realtimechat/
Rails 5 + ActionCableで作る!シンプルなチャットアプリ(DHH氏のデモ動画より)
https://qiita.com/bisque33/items/1360477c2260b361ec03
[Rails5]Action Cableのサンプルを読み解いてみる
https://qiita.com/bisque33/items/1360477c2260b361ec03
リアルタイムチャットは誰でもつくれる~Action CableでDM機能を作ろう~
https://qiita.com/OgawaNorihiro/items/6d9f85d8e89d1def4f15
【Ruby on Rails】DM機能でDM相手の一覧ページを作成!
https://novice-programmer.com/dm_index/
Railsガイド Action Cable の概要
https://railsguides.jp/action_cable_overview.html