はしばみあきら blog

プログラミングアウトプットするブログ。202010スタート

【Rails】ActionCableでチャットアプリ・グループや特定のユーザーとチャットする

前回、1からActionCableを用いたチャットアプリを作りました

現状ではチャットが全てのユーザーに対して送られているアプリです

そのため、特定のユーザーとチャットしたり、または複数人でチャットしたりと言う機能を作っていきたいと思います

前回作ったアプリを基に、コードを追加したりしていきます

前回作成したアプリはgithubからダウンロードできます

github.com

今回はフォロー機能も実装し、相互フォロー状態のユーザー同士でチャットできるルームを作るなどしていきます

ではいきましょう

フォロー機能を作る

フォローフォロワー機能についてはこちらがとても分かりやすいです

【初心者向け】丁寧すぎるRails『アソシエーション』チュートリアル【幾ら何でも】【完璧にわかる】

細かい説明は省きますので、コードをつらつらと作って書いていきます

$ rails g model Relationship following_id:integer follower_id:integer
$ rails db:migrate
$ rails g controller relationships create destroy
  # ユーザー機能
  resources :users, only: [:show] do
    resources :relationships,   only: [:create, :destroy]
    get :follows,   on: :member
    get :followers, on: :member
  end
class Relationship < ApplicationRecord
  # :class_name - 関連するモデルクラス名を指定。関連名と参照先のクラス名を分けたい場合に使う
  belongs_to :following, class_name: "User"
  belongs_to :follower,  class_name: "User"
end
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :messages
  has_many :entries
  has_many :rooms, through: :entries

  # フォロー・フォロワーの情報を集める
  # has_many :relatinshipだと2通り書かなくてはならず名前被りが起こるので中間テーブル名を再定義する
  # =========== 自分がフォローしているユーザーとの関連 ==============
  has_many :active_relationships, class_name: "Relationship", foreign_key: :following_id
  has_many :followings, through: :active_relationships, source: :follower
  # ===========================================================
  # ============ 自分がフォローされるユーザーとの関連 ===============
  has_many :passive_relationships, class_name: "Relationship", foreign_key: :follower_id
  has_many :followers, through: :passive_relationships, source: :following
  # ===========================================================

  # Userがfollow済みかどうか判定
  def followed_by?(user)
    passive_relationships.find_by(following_id: user.id).present?
  end
end
class RelationshipsController < ApplicationController
  def create
    follow = current_user.active_relationships.build(follower_id: params[:user_id])
    follow.save
    redirect_back(fallback_location: rooms_path)
  end

  def destroy
    follow = current_user.active_relationships.find_by(follower_id: parmas[:user_id])
    follow.destroy
    redirect_back(fallback_location: rooms_path)
  end
end
class UsersController < ApplicationController
  def show
  end

  def follows
    user = User.find(params[:id])
    @users = user.followings
  end

  def followers
    user = User.find(params[:id])
    @users = user.followers
  end
end
<% if user_signed_in? %>
  <%= link_to "ログアウト", destroy_user_session_path, method: :delete %>
  <p>
    YourName: <%= current_user.name %>
  </p>
<% end %>

<h2>All Chats</h2>

<div class="message">
  <%= render partial: 'rooms/messages', locals: { messages: @messages} %>
</div>

<div id="add-message"></div>

<!-- 投稿フォーム -->
<input type="text" data-behavior="room_speaker" data-user=<%= current_user.id %>>

<div class="follow-wraper">
  <div class="all-user">
    <h4>ユーザー 一覧</h4>
    <% @users.each do |user| %>
      <ul>
        <li>
          <%= user.name %>
          <% if user.followed_by?(current_user) %>
            <span>フォロー済み</span>
          <% else %>
            <span><%= link_to "フォローする", user_relationships_path(user.id), method: :post %></span>
          <% end %>
        </li>
      </ul>
    <% end %>
  </div>
  <div class="following-user">
    <h4>フォロー中ユーザー</h4>
    <ul>
      <% @followings.each do |f| %>
        <li>
          <%= f.name %>
        </li>
      <% end %>
    </ul>
  </div>
  <div class="followed-user">
    <h4>あなたをフォローしているユーザー</h4>
    <ul>
      <% @followers.each do |f| %>
        <li>
          <%= f.name %>
          <span><%= link_to "フォローする", user_relationships_path(f.id), method: :post %></span>
        </li>
      <% end %>
    </ul>
  </div>
</div>
class RoomsController < ApplicationController
  def index
    @messages = Message.all
    # 自分以外のユーザー一覧
    @users = User.where.not(id: current_user.id)
    # フォロー中ユーザー
    @followings = current_user.followings
    # フォローされているユーザー
    @followers = current_user.followers
  end

  def show
  end
end
.follow-wraper{
  display: flex;
  justify-content: space-between;
}

ここまででフォロー・フォロワー機能ができました

room.indexのviewはこんな感じになっています
※ ユーザーを何人分か登録してあります

f:id:hashibamiakira:20201112160159p:plain

それではユーザーとのルームを作っていきます

ActionCableに入る前に、コントローラーとviewを作ります

ルームを作る

自分と相手がフォローし合っている状態でリンクを持つようにします

もし、ルームがまだないのであれば新規作成

もうあるのであれば、そこに飛ぶようにします

index.htmlの、フォローしているユーザーの部分でリンクを作ります

  <div class="following-user">
    <h4>フォロー中ユーザー</h4>
    <ul>
      <% @followings.each do |f| %>
        <% if current_user.followed_by?(f) %>
          <li>
            <%= f.name %>
            <%= link_to "チャットルームへ", room_path(f) %>
          </li>
        <% else %>
          <li>
            <%= f.name %>
          </li>
        <% end %>
      <% end %>
    </ul>
  </div>

ルームのshowへ遷移するリンクを作りました

次はroom.controllerで、ルームがあるかどうかの判定と、その後の動きを記述します

ちょっと難しい書き方です

記述の内容は、こちらの記事を参考にしてありますので、どういうことをしているのか知りたい方はどうぞ

RailsでややこしいDM機能を1万字でくわしく解説してみた

  def show
    @user = User.find_by(id: params[:id])
    current_room = Entry.where(user_id: current_user.id)
    another_room = Entry.where(user_id: @user.id)
    unless @user.id == current_user.id
      current_room.each do |cr|
        another_room.each do |ar|
          # ルームが存在する時 かつ ルームのユーザーIDが2人(グループとの差別化)
          if cr.room_id == ar.room_id && Room.find_by(id: cr.room_id).users.ids.size == 2
            @is_room = true
            @room = Room.find_by(id: cr.room_id)
            @messages = @room.messages
          end
        end
      end
      # なければ新規作成
      unless @is_room
        @room = Room.create
        Entry.create(user_id: current_user.id, room_id: @room.id)
        Entry.create(user_id: params[:id].to_i, room_id: @room.id)
      end
    end
    @messages = @room.messages
  end

コントローラーもできたのでviewを記述していきます

<h2><%= @user.name %> さんとのチャットルーム</h2>

<% @messages.each do |message| %>
  <%= message.message %>
<% end %>

<div id="add-user-chat"></div>

<input type="text" data-behavior="room_speaker">

全体チャットのフォームとあまり変わらない記述ですね

ここから個別にチャットを送るためにはルームのidが必要となって来ます

ではActionCableに入っていきます

ActionCableで特定のユーザー・ルームにチャットを送る

まずはhtmlからjsにルームのidを渡します

showページでは@roomにそのルームの情報が入るので、@room.idとすることでルームのidを取得できます

この情報を一旦showのh2タグに持たせます

また、id名を指定しておくことで、js側で要素を取得できるようにします

<h2 id="user-chat-room" data-room="<%= @room.id %>"><%= @user.name %> さんとのチャットルーム</h2>

coffee側の記述にいきます

App.cable.subscriptions.create の後にパラメータをも出せることができます

coffeeは閉じダグをインデントで判定してるようなので気をつけましょう

$ ->
  App.room = App.cable.subscriptions.create { channel: "RoomChannel", room: $('#user-chat-room').data('room') },
    connected: ->
      # 通信が確立された時の処理

    disconnected: ->
      # 通信が切断された時の処理

    received: (data) ->
      # データが送信されてきた時の処理
      $('#add-message').append("<p>" + data["message"] + "</p>");

    speak: (message, user) ->
      # channelのspeakアクションにmessageパラメータを渡す
      @perform 'speak', {message: message, user: user}

  # チャットを送る
  $(document).on 'keypress', '[data-behavior~=room_speaker]', (event) ->
    # return(Enter)が押された時
    if event.keyCode is 13
      #channel speakへ、event.target.valueを引数に
      App.room.speak event.target.value, $('[data-user]').attr('data-user')
      # inputの中身を空に
      event.target.value = ''
      event.preventDefault()

あっと!エラーが出た!

どうやらturbolinksが生きている様子

ということでapplication.htmlから削除します

    <%= stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>

  ↓

    <%= stylesheet_link_tag    'application', media: 'all' %>
    <%= javascript_include_tag 'application' %>

エラーが消えました

ではchannelの方にいきます

class RoomChannel < ApplicationCable::Channel
  def subscribed
    # 接続
    stream_from "room_channel_#{params['room']}"
  end

  def unsubscribed
    # 切断
  end

  # coffeeから送られて来たmessage(event.target.value)を受け取る
  # inputタグの文字がメッセージカラムに保存される
  def speak(message)
    Message.create(message: message['message'], user_id: message['user'].to_i, room_id: params['room'])
  end
end

実際のparamsの値はこんな感じ

f:id:hashibamiakira:20201112174036p:plain

無事channelにパラメータを渡すことができました

このidの情報でどのメッセージがどのチャンネルに配信されるかを特定します

次は保存後の動きです

前回作ったアプリでは、inputに入力した情報をそのまま送っていました

今回はjobというものを通してDBに保存したメッセージを送るようにしていきます

modelに、メッセージの保存に成功した後jobにデータを渡すように記述します

class Message < ApplicationRecord
  # メッセージの保存ができた時、jobにデータを渡す
  after_create_commit { MessageBroadcastJob.perform_later self}

  belongs_to :user
  belongs_to :room, optional: true
end

channelでmodelを通した時に、jobにデータを渡します

保存されたmessageはselfとしてjobに渡る...はず

jobはターミナルから作成します

$ rails g job MessageBroadcast

後からappendでタグを直書きしていくのも大変なので、データを基に部分テンプレートをレンダリングするようにします

jobから部分テンプレートのhtmlをjsに返します

まずはjob

class MessageBroadcastJob < ApplicationJob
  queue_as :default

  def perform(message)
    ActionCable.server.broadcast "room_channel_#{message.room_id}", message: render_message(message)
  end

  private

  def render_message(message)
    ApplicationController.renderer.render partial: 'rooms/user_message', locals: { message: message}
  end
end

ApplicationController.renderer.render は、コントローラ以外の場所でレンダリングする時の記述らしいです

送られて来たmessageのデータを、同じルームidのチャンネルへ配信します

次にrooms/_user_message.html.erb を呼び出し

<p><%= message.message %></p>

とりあえず付け足すだけなのでこれだけにしておきます

js側に送られた部分テンプレートをviewに付け足す記述をします

coffee側へ

    received: (data) ->
      # データが送信されてきた時の処理
      $('#add-message').append data['message']

これでとりあえず完成です!

前回とはjob等の変更点もありますが、出来上がりました

実際の挙動はこんな感じ

ユーザー同士のチャットは全体には上がらず、全体のチャットはユーザー同士の方には上がりません

いい感じですね!

あとはhtmlやcssでいい感じにしていけばOKです

ただ今のままだと、リロードをかけると全体のチャットルームにユーザー同士のチャットが反映されてしまいますね

とりあえず、room_idのないメッセージは表示しない、みたいにしておきます

  def index
    @messages = Message.where(room_id: nil)

 :

これでユーザー間のチャット機能はできるようになりましたね

これを応用すればグループ機能も作れるし、画像なんかのファイルを送ることもできます

デプロイ後の挙動も確認したいところです

次回はまたActionCable関係を更新していこうと思います!