スポンサーリンク

【Ruby on Rails】Action Cableを用いてリアルタイムチャットアプリを作成してみた!!

みなさんこんにちは!
のむ(@nomu_engineer)です!

今回の記事では、Action Cableを使ってリアルタイムチャットアプリを作成したので、機能の実装手順をまとめました!

この記事を読むメリット
・Action Cableについて理解が深まる
・Action Cableを用いたチャットアプリ の作成ができるようになる

この記事を読むおすすめの読者
・プログラミング初心者
・駆け出しエンジニア
・Action Cableを用いたアプリの実装をしたい方

開発環境
・macOS Catalina 10.15.5
・Ruby 2.6.5
・Ruby on Rails 6.0.0

アプリの概要

それでは早速本題の方に入っていきたいと思います!

Action Cableとは

「そもそもAction Cableってなんだ??」と思った方がいるかと思いますので、少し解説したいと思います。

Action CableとはWebSocketsとバックエンドであるRailsをシームレスに繋ぐための、フルスタックなフレームワークのことです。

WebSocketsとは、Webにおいて低コストで双方向通信を行うための、プロコトルです。
リアルタイムのチャット機能実装には、双方向通信の存在は欠かせません。

次に、シームレスというのは、日本語に直すと、つなぎ目がないという意味になります。
つなぎ目がないくらい、密に、自然な感じでWebSocketsとRailsを繋ぐという意味でのこの表現です。

つまるところ、Action Cableとは双方向通信を低コストで行うために、WebSocketsとRailsをつなぎ目がないくらい、自然な感じで統合するためのフレームワークという認識でOKです。

実際に完成するアプリの仕様について

次に、今回の記事内容を実際に実装することでどのようなアプリが完成するのかを簡単に説明したいと思います。

イメージとしてはLINEのトーク機能です。
LINEの中でも、ラインのトークルーム内でのチャット機能のみの実装となりますので、友達追加機能等の実装を考えている場合は、この記事には書いてないのでご注意ください。

LINEと同じようなチャット機能の実装をする上で、ポイントなるのは以下のことです。

・受け手が、メッセージを下さいとリクエストを送らなくてもメッセージが表示されるようにすること
・メッセージの送信にも非同期通信を用いる。(speakメソッドは使わない。)
・トークルーム画面に遷移した際に、最新のメッセージが表示されるようにすること
・無限スクロール機能(遅延読み込み機能)の実装
・入力フォームはユーザーの改行に合わせて、縦幅を広げる
・メッセージの未入力時は投稿ボタンの無効化にすること

JavaScriptの記述も多くなってくるので、難しく感じることもあると思いますが、僕もできるだけ、わかりやすく丁寧にまとめるので、最後までお付き合いください!!
※すでにアプリケーションは立ち上げられていることを前提として話します。

事前準備

トップページの作成

最初にトップページ用のコントローラーとビューを作成しておきます。

ここを作成しておかないと、ログインした後に遷移するページがないため、リダイレクトによる、無限ループを喰らうからです。

% rails g controller rooms index
Rails.application.routes.draw do
  root 'rooms#index'
end

Bootstrap4の導入

デザイン部分を楽に仕上げることができるため、Bootstrapを使います。
Bootstrapについての知識がない方でも実装できるレベルのことしかしないため安心してください。
(僕もこの実装がBootstrapデビューでした。)

% yarn add bootstrap jquery popper.js

config/webpack/environment.jsを編集していきます。

const { environment } = require('@rails/webpacker')

const webpack = require('webpack')

environment.plugins.append('Provide', new webpack.ProvidePlugin({
    $: 'jquery',
    jQuery: 'jquery',
    Popper: ['popper.js', 'default']
}))

module.exports = environment

続いて、app/javascript/packs_application.jsの1番したに以下の記述を加えます。

require("bootstrap/dist/js/bootstrap")

最後にapplication.cssの拡張子である、cssをscssに変更して、以下の記述を加えます。

@import "bootstrap/scss/bootstrap";

ユーザー管理機能の実装

次に、ユーザー管理機能の実装を行います。

ユーザー管理機能の実装にはdeviseを用います。

基本的なライブラリの追加

まずは、ログイン機能などを実装するにあたって、必要になるライブラリの導入から行います。

ログイン機能

gem "devise"

日本語化

gem "rails-i18n"
gem "devise-i18n"

上記のgemをGemfileに記述したら、インストールします。

% bundle install

インストールが完了したら、devise関連のファイルのインストールを行います。

%  rails g devise:install

データベースの作成

rails db:create

モデルの作成

%  rails g devise user

マイグレーションファイルの編集

今回はdeviseのデフォルトにある、メールアドレスとパスワード以外にニックネームのカラムを追加しようと思うので、マイグレーションファイルの編集を行います。

t.string :nickname, null: false

マイグレートの実行

マイグレーションファイルの編集が終わったら、データベースに反映するためにマイグレートを行います。

rails db:migrate

deviseのコントローラーの作成

% rails g devise:controllers users

一旦ここまでで、deviseの基本的なライブラリの導入は終了になります。

deviseの日本語化対応

ここからdeviseを日本語化に対応していきます。

最初の段階で、deviseを日本語化にするためのGemは追加しているので、やることはそんなに多くはありません。

config/application.rbを以下の記述に変更します。

config.i18n.default_locale = :ja
config.time_zone = "Asia/Tokyo"

上記の記述をすることで、deviseを日本語化に対応することが出来ます。

サインアップ画面等において、エラー文を日本語化させることもできますので、実装したい方は下記の記事を参考にして見てください

エラー文を日本語化にする方法

ここまでで、日本語化に対応の実装は終了です。

次に、ログインしていないと各ページに遷移できないように設定します。
app/controllers/application_controller.rbに記述を加えます。

class  ApplicationController < ActionController::Base
  before_action :authenticate_user!
end

この記述を行うことで、ログインしていない場合は、ログインページに自動でリダイレクトされるようになります。

viewファイルに入力欄の追加

今回の作成するアプリにおいては、デフォルトのメアドとパスワード以外に、ニックネームの登録もできるように、マイグレーションファイルに追記しました。

その追記のおかげで、ニックネームのカラムは作成できますが、実際に入力欄はそれだけでは出来上がりません。

そのため、新規登録画面のviewファイルのフォームにニックネームの入力欄を作って上げます。

①app/views/devise/registrations/new.html.erb
②app/views/devise/registrations/edit.html.erb

上記の2個のファイルに以下の記述を追記します。

<div class="field">
  <%= f.label :nickname, class:"label_field" %><br />
  <%= f.text_field :nickname, autofocus: true, autocomplete: "nickname", class:"field_input" %
</div>

ストロングパラメーターの実装

ストロングパラメーターとして、データを受け取れるように、コントローラーの編集も行います。

app/controllers/application_controller.rbファイルに以下の記述を追記します。

class ApplicationController < ActionController::Base
  before_action :authenticate_user!
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected

  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up, keys: [:nickname])
    devise_parameter_sanitizer.permit(:account_update, keys: [:nickname])
  end

end

ここまででユーザー管理機能の実装は一通り終了となります。

サーバーを起動して、正常に作動するか確認しおきましょう。

ヘッダーの作成

ログイン関連のリンクを所持しているヘッダーの作成を行います。
Bootstrapを用いているので、ヘッダーのクラスに「sticky-top」をつけるだけで位置の固定ができます。

まずはapp/views/layouts/application.html.erbを以下のように編集します。

<!DOCTYPE html>
<html>
  <head>
    <title>Family</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <meta name="viewport" cotent="width=device-width,initial-scale=1">  #ここを追加

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

  <body>
    <%= render "shared/header"%>  #ここを追加
    <%= yield %>
  </body>
</html>

次にapp/views/shared/_header.html.erbを作成して、以下のように記述します。

<header class="sticky-top">
  <nav class="navbar navbar-expand-sm navbar-light bg-light">
    <%= link_to "Family", root_path, class: 'navbar-brand' %>
    <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="ナビゲーションの切替">
      <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNav">
      <ul class="navbar-nav">
        <% if user_signed_in? %>
          <li class="nav-item active">
            <%= link_to 'アカウント編集', edit_user_registration_path, class: 'nav-link' %>
          </li>
          <li class="nav-item active">
            <%= link_to 'ログアウト', destroy_user_session_path, method: :delete, class: 'nav-link' %>
          </li>
        <% else %>
          <li class="nav-item active">
            <%= link_to "新規登録", new_user_registration_path, class: 'nav-link' %>
          </li>
          <li class="nav-item active">
            <%= link_to "ログイン", new_user_session_path, class: 'nav-link' %>
          </li>
        <% end %>
      </ul>
    </div>
  </nav>
</header>

これでヘッダーは完成です。
全ページでヘッダーの表示がされます。

ルーム管理機能の実装

次にルーム管理機能の実装を行っていきます。

やることは以下の通りです。

・モデル、テーブルの作成
・ルーム作成画面の作成(new)
・ルーム一覧ページの作成(index)
・トークルームの作成(show)

・ルーティングの設定
・ログインしたら、ルーム作成画面に遷移するように実装

モデル、テーブルの作成

% rails g model room

今回はroomという名前で作りますが、ここは任意のもので大丈夫です。(最初に作成したコントローラーとビューと同じ名前であれば。)

モデルを作成したら、マイグレーションファイルも同時に作成されるので、マイグレーションファイルの編集を行います。

今回は、ルーム名のみをカラムとして持たせるので記述は以下の通りです。

class CreateRooms < ActiveRecord::Migration[6.0]
  def change
    create_table :rooms do |t|
      t.string :name, null: false
      t.timestamps
    end
  end
end

usersテーブルとの紐付けは多対多になるので、後ほど中間テーブルを作成することになります。

マイグレファイルの変更が完了したら、マイグレートの実行を行います。

ルーム作成画面の作成

続いて、ルームの作成機能を実装します。

まずはコントローラーを以下のように編集します。

class RoomsController < ApplicationController
  def index
  end

  def new
    @room = Room.new
    @room.users << current_user
  end

  def create
    @room =Room.new(room_params)
    if @room.save
      redirect_to root_path
    else
      render :new
    end
  end

  private

    def room_params
      params.require(:room).permit(:name).merge(user_ids: current_user.id)
    end
end

コードの内容を簡単にまとめると、newアクションで空のインスタンス(オブジェクト)を作成します。

この時は何も情報を持たない、空のインスタンス(オブジェクト)です。

ユーザーが入力した情報をパラメーターを通して受け取り、createアクションを用いて保存します。
if文で条件分岐しているのは、保存に成功した場合と、成功しなかった場合で、その後の処理内容を変えるためです。

保存に成功した場合は、トップページに遷移、失敗した場合はrender :newとして、もう一度ルーム名を入力してもらう形になります。

次は、ビューファイルの作成です。

アクション名と同じビューファイルを作成します。

<%= form_with model: @room, local: true do |f| %>
   <%= f.label :name %><br />
   <p><%= f.text_field :name %></p>
   <p><%= f.submit "ルームの作成" %><p>
 <% end %>

今回はルームに関してはルーム名しか情報を持たないので、フォーム部品はルーム名一つ分となります。

ルーム一覧表示画面の作成

次にルーム一覧表示機能を実装します。

まずはコントローラーを編集します。

class RoomsController < ApplicationController
  def index
    @rooms = Room.all.order(:id)
  end

-- 以下省略 --

end

続いてビューファイルの編集を行います。

<ul class="family_lists">
  <% @rooms.each do |room| %>
    <li class="family_list">
      <%= link_to "#{room.name}", room_path(room), class:"family_button" %>
    </li>
  <% end %>
</ul>

コードを簡単に説明すると、each文を用いて、@rooms内に入っている要素(各ルーム)を全て取得して、表示しています。

表示するのはルームの名前にするので、link_tの第一引数には「room.name」としています。

リンク先はトークルーム(show)とするため、第二引数にはroom_path(room)と指定することで、クリックしたルーム名のshowページに遷移することが出来ます。

第二引数のパスの指定は、ターミナルで「rails routes」と入力して確認してください。

指定しているクラス名はCSSの適用の際に使うために設定しているので、付けなくても問題ないです。
(この記事ではCSSの適用までは行いません。)

ルーム詳細画面の作成

続いてトーク画面となる、ルーム詳細画面の実装をしていきます。

まずはコントローラーから。

def show
  @room = Room.find(params[:id])
  @messages = @room.messages.includes(:user).order(:id).last(100)
  @message = current_user.messages.build
end

まずは、「id」から辿って、データベースより同じ「id」のデータ(room)を取得して、インスタンス変数に代入します。

ルーム内に表示するメッセージはルームに属しているメッセージを取得します。
そのため、「インスタンス変数.messages」とします。

includesを用いるのは、N+1問題を解決するためです。

includesがない場合は、メッセージ1個1個から、そのメッセージを投稿したユーザーは誰なのかという情報をデータベースからとってくることになる。

そのため、メッセージの数だけ、データベースにアクセスすることになるから、読み込みに時間がかかってしまう。

includesを用いて、message.userを先読みすることで、データベースへのアクセスを1回のみにすることができるので、基本はincludesを用いてユーザー情報を取得するようにしましょう!

メッセージの取得は各ルームごとに行うので、後々showアクションに戻ってきて、メッセージ取得のための記述を行います。

続いて、ビューファイルの編集を行います。

<div id="message-container" data-room_id = "<%= @room.id %>">
  <%= render @messages %>
</div>
<%= render 'footer' %>

「render @messages」の記述があると思いますが、この記述は部分テンプレートを読み込むための記述です。
「render @messages」だけでなぜ読み込むことができるのかというのはこちらの記事を参照ください。

部分テンプレート、renderメソッドの使い方

「render “footer”」とあると思いますが、これは送信ボタンをパーシャルとして引っ張ってくるための記述です。

footerもパーシャルとして別のファイルから引っ張ってきます。
footerにはform_withを用いて、メッセージの送信ボタンを作成します。

app/views/rooms/_footer.hteml.erbを以下のように編集します。

<footer class="fixed-bottom" id="footer">
  <%= form_with model: @message do |f| %>
    <div class="form-group">
      <%= f.text_area :content, class: 'form-control', rows: '1', maxlength: '500' %>
    </div>

    <div class="form-group">
      <%= f.submit '送信', class: 'btn btn-primary btn-block', id: 'message-button' %>
    </div>
  <% end %>
</footer>

続いて、「<%= render @messages %>」に挿入する部分テンプレートを作成します。

以下の2ステップを踏んで実装します。

①messagesディレクトリの中に「_message.html.erb」を作成
②ニックネームとメッセージ内容を表示するようにする。

<div class="message" id="message-<%= message.id %>">
  <p><%= message.user.nickname %></p>
    <%= simple_format(h message.content) %>
</div>

上記の記述で、ニックネームとメッセージを取得して、表示することが出来ます。

simple_formatメソッドというのはユーザーが入力した情報に改行が含まれている場合に、その改行を反映してくれるためのものです。

しかし、simple_formatメソッドのみでは、HTMLタグが反映されてしまうので、hメソッドを用いてエスケープします。
これにより、文中に<h1>タグで囲まれた文字列があっても、<h1>が反映されることなく、<h1>タグも一つの文字列として表示されるようになります。

ルーティングの設定

routes.rbに以下のresourcesを追加します。

resources :rooms, only: [:index, :new, :show, :create]

ログイン後、ルーム作成画面に遷移するように実装

ここまでの実装で大方、ルーム管理機能の実装は完了です。
次は、ログインした後に、ルーム作成の画面(newページ)に遷移するように設定します。

app/controllers/application_controller.rbの1番下に以下を追記します。

def after_sign_in_path_for(resource)
  new_room_path
end

after_sign_in_path_forはdeviseのメソッドで、ログイン後に遷移したいページを指定することで、ログイン後に遷移するページの指定が可能になります。

今回はログインした後に、ルーム作成のページに遷移してもらうために、new_room_pathという指定をしています。

メッセージ投稿機能

次はメッセージ投稿機能の実装を行っていきます。

Action Cableを用いた場合は、speakメソッドを用いて実装している記事が多いのですが、今回はspeakメソッドを使わずに実装していきます。

やることは以下の通りです

・モデル、テーブルの作成
・コントローラー作成、編集
・ルーティングの設定
・メッセージ送信後にフォームに入力した文字列の削除昨日(JavaScript)

それでは早速実装の方に入っていきたいと思います。

モデル、テーブルの作成

まずは、モデルとテーブルの作成を行なっていきます。

% rails g model message

マイグレーションファイルの編集を行います。
メッセージはルームとユーザーに所属するので、外部キー制約を用います。

外部キー制約というのは、簡単に言えば、外部のテーブルの情報を参照できるようにするための制約です。
ユーザーとルームのテーブルの情報を参照できるようにするために、今回は外部キー制約を用います。

それにプラスして、テキスト型のカラム名が「content」のカラムを作成します。

class CreateMessages < ActiveRecord::Migration[6.0]
  def change
    create_table :messages do |t|
      t.references :user, null: false
      t.references :room, null: false
      t.text :content, null: false
      t.timestamps
    end
  end
end

上記のようにマイグレーションファイルを編集したら、ターミナルでマイグレートの実行を行います。

コントローラーの作成、編集

続いて、コントローラーの作成と編集を行なっていきます。

rails g controller messages

コントローラーを作成したら、メッセージを保存できるように、編集します。

class MessagesController < ApplicationController
  def create
    @room = Room.find(params[:room_id])
    @message = current_user.messages.create!(message_params)
  end

  private
  def message_params
    params.require(:message).permit(:content).merge(room_id: @room.id)
  end
end

ルーティングの設定

まずはルーティングの設定から行っていきます。
投稿するメッセージはルームのビューに表示するため、ルーティングはルームにネストさせて設定します。

ネストすることで、パスからルームのidを取得できるためいろいろと便利です。
今回のように、自身(メッセージ自身)でページ(viewページ)を持たない場合はネストさせる必要がある(ネストさせると便利)という認識で構えといてもらうくらいでも大丈夫かと思います。

routes.rbを以下のように編集します。

Rails.application.routes.draw do
  resources :rooms, only: [:index, :new, :show, :create] do
    resources :messages, only: [:create]
  end
  root to: "rooms#index"
  
  devise_for :users, controllers: {
    registrations: "users/registrations",
    sessions: "users/sessions"
  }
end

roomsのresourcesにdoとendを追加し、その間にmessagesのresourcesを設定します。

これによりネストは完了です。

メッセージ送信後にフォーム内の文字列の削除

通常のCRUDを用いた7つの基本的なアクションであれば、ページ遷移するため、入力フォームを空にする必要はありません。

ですが今回は非同期通信でチャットを行うため、メッセージ送信後に入力フォームを空にしないと、いつまでも入力フォーム内にメッセージが残ってしまいます。

そこで、JavaScriptを用いて、メッセージが保存されれば文字列が消えるようにプログラムを書きます。

まずはファイルを作ります。

app/views/messages/create.js.erbを作成します。

このファイルはmessagesコントローラーのcreateアクションの実行後に読み込まれるファイルになります。

app/views/messages/create.js.erbに以下の記述を追加します。

document.getElementById('message_content').value = ''

アソシエーションの設定

ここまで実装できたら、あとはアソシエーションの組むことで、メッセージの送信まで行えるようになります。

アソシエーションの関係をまとめると以下の通りになります。

・ルームとユーザーは多対多(中間テーブルの作成)
・ユーザーは沢山のメッセージを持つ
・ルームは沢山のメッセージを持つ

中間テーブルの作成

まずは中間テーブルの作成から行います。

% rails g model room_user

作成されたマイグレーションファイルに外部キー制約を設定します。

class CreateRoomUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :room_users do |t|
      t.references :room, foreign_key: true
      t.references :user, foreign_key: true
      t.timestamps
    end
  end
end

外部キー制約を設定したら、マイグレートを実行します。

マイグレートの実行終わったらそれぞれのモデルに、アソシエーションの記述をしていきます。

アソシエーションの設定

・メッセージモデル

class Message < ApplicationRecord
  belongs_to :user
  belongs_to :room
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 500 }
end

・ユーザーモデル

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  
  validates :nickname, presence: true
  has_many :messages
  has_many :room_users
  has_many :rooms, through: :room_users
end

ルームモデル

class Room < ApplicationRecord
  has_many :room_users
  has_many :users, through: :room_users
  has_many :messages
end

中間テーブル(room_userモデル)

class RoomUser < ApplicationRecord
  belongs_to :room
  belongs_to :user
end

以上でテーブル同士の関連付けは完了です。

これでメッセージの投稿ができるようになりました。
フォームに文字列を入力して、送信ボタンを押してみましょう!

文字列が消えた後にページ更新を行い投稿メッセージが反映されればOKです。

ここからが本題のAction Cableになります。

Action Cableの実装

ここからが本題です。
Aciton Cableを使うことで、プロコトルをHTTPからWebSocketsにアップデートし、フロント側とサーバー側が監視試合う状態(双方向通信ができる状態)を作ります。

まずはターミナルでコマンドを入力して、チャネルを作り出します。

% rails g channel Room

このコマンドの入力で以下の二つが出力されるか確認してください。
#     create  app/channels/room_channel.rb
#     create  app/javascript/channels/room_channel.js

上記のコマンドを入力したら、ルーティングの設定を行います。

ルーティングを以下の通りに編集しましょう!

Rails.application.routes.draw do
  mount ActionCable.server => "/cable" ←追記した部分
  resources :rooms, only: [:index, :new, :show, :create] do
    resources :messages, only: [:create]
  end
  root to: "rooms#index"

  devise_for :users, controllers: {
    registrations: "users/registrations",
    sessions: "users/sessions"
  }
end

ここまででお互いに監視し合う状態は完成です。

ここからはAction Cableを用いたチャット機能の実装を行います。

Action Cableを用いてルームにいる全員が投稿メッセージをリアルタイムで受信できるようにします。

最初に、配信する部屋名というものを決めます。

app/channels/room_channel.rbを編集します。

class RoomChannel < ApplicationCable::Channel
  def subscribed
    stream_from "room_channel" ←ここを編集しています。
  end

  def unsubscribed
    
  end
end

次にブロードキャストを用いて、投稿されたメッセージがルーム全員に送信されるようにプログラムを組みます。

app/controllers/messages_controller.rbを以下のように編集します。

class MessagesController < ApplicationController
  def create
    @room = Room.find(params[:room_id])
    @message = current_user.messages.create!(message_params)
    ActionCable.server.broadcast "room_channel", message: @message.template ←編集箇所
  end

  private
  def message_params
    params.require(:message).permit(:content).merge(room_id: @room.id)
  end
end

続いて、メッセージモデルを編集します。

class Message < ApplicationRecord
  belongs_to :user
  belongs_to :room
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 500 }

  def template
    ApplicationController.renderer.render partial: "messages/message", locals: { message: self }
  end
end

最後に、app/javascript/channels/room_channels.jsを編集します。

import consumer from "./consumer"

turbolinks の読み込みが終わった後にidを取得しないと,エラーが出ます。
document.addEventListener('turbolinks:load', () => {

    js.erb 内で使用できるように変数を定義しておく
    window.messageContainer = document.getElementById('message-container')

    以下のプログラムが他のページで動作しないように
    if (messageContainer === null) {
        return
    }

    consumer.subscriptions.create("RoomChannel", {
        connected() {
        },

        disconnected() {
        },

        received(data) {
            サーバー側から受け取ったHTMLを一番最後に加える
            messageContainer.insertAdjacentHTML('beforeend', data['message'])
        }
    })
})

ここまででチャットの基礎は完成です。

サーバーを起動して、異なる二つのブラウザを開きローカルホストにアクセスしましょう!

同じルームに入って、片方でメッセージを送信すれば、もう片方で出力されればOKです。

ユーザビリティを考えた実装

ここまでで、リアルタイムのチャット機能の実装は完了です。
ここからは、ユーザビリティを考えた+αのことについて実装していきたいと思います。

内容としては以下の通りです。

・メッセージが多くなった場合に最新のメッセージが見えなくなってしまう。
・フォームが空欄の場合はボタンを無効化する。
・入力フォームをユーザーの改行と合わせて、幅を増加させる。
・無限スクロール機能(ページの最上部に到達すると、次に新しいメッセージの取得を行う)

それでは早速、実装していきたいと思います。

常に最新メッセージの表示

メッセージが多くなると最新のメッセージが見えなくなってしまうので、改善していきます。
考え方としては、ページを開いた時に、ページを1番下にスクロールするように実装することです。

app/javascript/channels/room_channels.jsのファイルを編集していきます。

import consumer from "./consumer"

document.addEventListener("turbolinks:load", () => {
  window.messageContainer = document.getElementById("message-container")
  if (messageContainer === null) {
    return
  }

  consumer.subscriptions.create("RoomChannel", {
    connected() {
    },

    disconnected() {
    },

    received(data) {
      messageContainer.insertAdjacentHTML("beforeend", data["message"])
    }
  })
  ----------------- 以下の行を追加 ----------------
  const documentElement = document.documentElement
  window.messageContent = document.getElementById("message_content")
  window.scrollToBottom = () => {
    window.scroll(0, documentElement.scrollHeight)
  }

  scrollToBottom()
  ------------------ 以上を追加 ------------------
})

上記のコードを記述した後、さらにメッセージを投稿した後も最新メッセージが見れるように、1番下にスクロールします。

そのために、app/views/messages/create.js.erbを以下のように編集します。

messageContent.value = ''
scrollToBottom()

まずは、下記のコードの説明からしたいと思います。

const documentElement = document.documentElement
  window.messageContent = document.getElementById("message_content")
  window.scrollToBottom = () => {
    window.scroll(0, documentElement.scrollHeight)
  }

  scrollToBottom()

このコード流れとしましては、まずはじめに、入力フォームのタグのidを下記の記述で取得します。
取得したidはjs.erbファイル内でも使うことができるようにするために、代入します。

window.messageContent = document.getElementById("message_content")

次に、1番下まで移動するための関数を定義して、変数に代入することで、同じくjs.erbファイル内で関数を使えるようにします。
それが下記の記述です。

window.scrollToBottom = () => {
    window.scroll(0, documentElement.scrollHeight)
}

上記の記述ができたら、あとは1番下までスクロールするために、関数を呼び出します。

それが1番したのコードです。

scrollToBottom()

フォームが空欄の場合の投稿ボタンの無効化

現段階の実装では、フォームが空欄でも送信ボタンを押すことができ、サーバーにリクエストを出すことができる状態となっています。

これをフォームが空欄の際は投稿ボタンを無効化にして、送信できないように実装します。

ポイントとしては、以下の通りです。

・フォームが空のときは無効化にしておく。
・文字が入力されたら、フォームボタンを有効化する。
・メッセージが送信されたら、またボタンを無効化する。

今回はBootstrap4を導入しているので、クラスにdisabled属性を追加することでボタンを無効化することができます。

単にdisabled属性の追加、削除で機能を実装できるが、メッセージ送信後にボタンの無効化をすることができないため、少し工夫して実装していきます。

まずは、app/views/_footer.html.erbを以下のように編集します。

<%= f.submit '送信', class: 'btn btn-primary btn-block disabled', id: 'message-button' %>

クラスにdisabled属性を追加しただけです。

次にroom_channel.jsを編集します。

import consumer from "./consumer"

document.addEventListener("turbolinks:load", () => {
  window.messageContainer = document.getElementById("message-container")
  if (messageContainer === null) {
    return
  }

  consumer.subscriptions.create("RoomChannel", {
    connected() {
    },

    disconnected() {
    },

    received(data) {
      messageContainer.insertAdjacentHTML("beforeend", data["message"])
    }
  })

  const documentElement = document.documentElement
  window.messageContent = document.getElementById("message_content")
  window.scrollToBottom = () => {
    window.scroll(0, documentElement.scrollHeight)
  }

  scrollToBottom()

---------- 以下を追加 -----------
  const messageButton = document.getElementById("message-button")

  const button_activation = () => {
    if (messageContent.value === "") {
      messageButton.classList.add("disabled")
    } else {
      messageButton.classList.remove("disabled")
    }
  }

  messageContent.addEventListener("input", () => {
    button_activation()
  })

  messageButton.addEventListener("click", () => {
    messageButton.classList.add("disabled")
  })
----------- 以上を追加 --------
})

追加したコードの流れとしましては、以下の通りです。

・送信ボタンのエレメント要素の取得
・入力フォームが空欄ならボタンを有効化、空欄なら無効化の関数を設定
・フォームに文字を入力した際の、イベントリスナーを定義
・送信ボタンが押されたときの、イベントリスナーと関数を定義

ボタンが無効化されているときは視認しやすくするために、ボタンの色を灰色にします。

app/assets/stylesheets/application.scssに以下を追記します。

.btn-primary.disabled {
  background-color: #6c757d;
  border-color: #6c757d;
  cursor: auto;
}

これで、入力フォームが空のときはボタンが無効化され、送信ボタンを押してもデータがサーバー側にいくことはなくなりました。
また、ボタンを灰色にすることで、直感的にわかるようになってます。

ユーザーの改行に合わせて、入力フォームの幅も増加

現時点では、文字の入力中に改行しても、入力欄が1行しかないため、複数行のメッセージの入力がしにく状態になっています。
これはユーザビリティに欠けるので、ユーザーが改行した時は、入力フォームを増やし、行数が減った時は入力フォームの幅も減るようにプログラムを実装します。

ポイントとしては、フォームにある改行の個数からフォームの行数を決定するという考え方です。
最大行数は10として、プログラムを書きます。

それでは早速コードを書いていきましょう!

import consumer from "./consumer"

document.addEventListener("turbolinks:load", () => {
  window.messageContainer = document.getElementById("message-container")
  if (messageContainer === null) {
    return
  }

  consumer.subscriptions.create("RoomChannel", {
    connected() {
    },

    disconnected() {
    },

    received(data) {
      messageContainer.insertAdjacentHTML("beforeend", data["message"])
    }
  })

  const documentElement = document.documentElement
  window.messageContent = document.getElementById("message_content")
  window.scrollToBottom = () => {
    window.scroll(0, documentElement.scrollHeight)
  }

  scrollToBottom()

  const messageButton = document.getElementById("message-button")
  const button_activation = () => {
    if (messageContent.value === "") {
      messageButton.classList.add("disabled")
    } else {
      messageButton.classList.remove("disabled")
    }
  }

  messageContent.addEventListener("input", () => {
    button_activation()
    changeLineCheck()  ←追加
  })

  messageButton.addEventListener("click", () => {
    messageButton.classList.add("disabled")
    changeLineCount(1)  ←追加
  })

---------- 以下を追加 --------------
  const maxLineCount = 10

  const getLineCount = () => {
    return (messageContent.value + "\n").match(/\r?\n/g).length;
  }

  let lineCount = getLineCount()
  let newLineCount

  const changeLineCheck = () => {
    newLineCount = Math.min(getLineCount(), maxLineCount)
    if (lineCount !== newLineCount) {
      changeLineCount(newLineCount)
    }
  }

  const changeLineCount = (newLineCount) => {
    messageContent.rows = lineCount = newLineCount
  }
---------- 以上を追加 --------------
})

ポイントをまとめると以下のようなります。

・最大行数を変数に定義
・入力フォームの改行の数を返り値とする関数を定義し、変数に代入
・改行の数が、フォームの幅(行数)と一致するか否かをチェックし、もし異なる場合はフォームの幅(行数)を変更する関数を呼び出す関数を定義(ややこしくなってすみません。笑)
・入力フォームの幅を変更する関数の定義

これで、入力フォームの幅を変更する、機能の実装は完了しました。
ですが、ここで一点問題が生じてしまいます。

それは、入力フォームの幅が大きくなることで、下部のメッセージが見えなくなってしまうということです。
この問題を解決するには、入力フォームの縦幅の大きさに合わせて、メッセージ要素のパディングボトムの値を大きくすることで、入力フォームの縦幅が大きなるごとに、メッセージが上に持ち上げられるような実装を行います。

以下のように編集します。

import consumer from "./consumer"

document.addEventListener("turbolinks:load", () => {
  window.messageContainer = document.getElementById("message-container")
  if (messageContainer === null) {
    return
  }

  consumer.subscriptions.create("RoomChannel", {
    connected() {
    },

    disconnected() {
    },

    received(data) {
      messageContainer.insertAdjacentHTML("beforeend", data["message"])
    }
  })

  const documentElement = document.documentElement
  window.messageContent = document.getElementById("message_content")
  window.scrollToBottom = () => {
    window.scroll(0, documentElement.scrollHeight)
  }

  scrollToBottom()

  const messageButton = document.getElementById("message-button")
  const button_activation = () => {
    if (messageContent.value === "") {
      messageButton.classList.add("disabled")
    } else {
      messageButton.classList.remove("disabled")
    }
  }

  messageContent.addEventListener("input", () => {
    button_activation()
    changeLineCheck()
  })

  messageButton.addEventListener("click", () => {
    messageButton.classList.add("disabled")
    changeLineCount(1)
  })

  const maxLineCount = 10

  const getLineCount = () => {
    return (messageContent.value + "\n").match(/\r?\n/g).length;
  }

  let lineCount = getLineCount()
  let newLineCount

  const changeLineCheck = () => {
    newLineCount = Math.min(getLineCount(), maxLineCount)
    if (lineCount !== newLineCount) {
      changeLineCount(newLineCount)
    }
  }
------------------- 以下を追加 ----------------------
  const footer =document.getElementById("footer")
  let footerHeight = footer.scrollHeight
  let newFooterHeight, footerHeightDiff

  const changeLineCount = (newLineCount) => {
    messageContent.rows = lineCount = newLineCount
    newFooterHeight = footer.scrollHeight
    footerHeightDiff = newFooterHeight - footerHeight
    if (footerHeightDiff > 0) {
      messageContainer.style.paddingBottom = newFooterHeight + "px"
      window.scrollBy(0, footerHeightDiff)
    } else {
      window.scrollBy(0,footerHeightDiff)
      messageContainer.style.paddingBottom = newFooterHeight + "px"
    }
    footerHeight = newFooterHeight
  }
------------------- 以上を追加 ----------------------

})

流れとしましては、以下の通りになります。

・フッターのエレメント要素を取得し、フッターの高さを変数に代入
・ユーザーがもじそ入力するたびにイベントリスナされる関数のなかで、新しいフッターの高さとなるものを変数として定義
・最初に設定したフッターの高さと、文字が入力されている状態のフッターの高さに違いがある場合は、メッセージのpaddingBottomを大きくする処理を記述

上記の実装で、入力欄が大きくなっても、メッセージが入力フォームに隠れ内容になりました。

無限スクロール機能の実装

無限スクロール機能とは、最初の読み込みでは少ないメッセージを読み込み、画面の1番上までスクロールしたら、次の新しいメッセージを取得するという機能になります。

実装する理由としましては、メッセージの数が少ない場合は読み込みに大きな負荷がかかることはないのかもしれませんが、もし10万件のメッセージがあった場合、それを読み込もうと思ったらかなりの時間や負荷がかかります。

なので今回の実装では、最初の読み込みで100件のメッセージの読み込みを行い、その後画面を1番上までスクロールした場合に、次の50件を読み込むように設定します。

この処理は非同期通信を用いて行うのですが、ボタンなどが無いため、JavaScriptファイルに直接Ajaxを利用するためのプログラムを記述します。

考え方としては、1番上までスクロールした際にリクエストを出すプログラムをJavaScriptに定義する形になります。

大体の流れは下記の通りになります。

・ルーティングの設定
・コントローラーの設定
・JavaScriptファイルの編集
・新たな「js.erb」ファイルの作成、編集

それでは一つずつ実装していきましょう!

ルーティングの設定

新しいアクションを定義するので、ルーティングを追加します。

以下のコードをルーティングに追加しましょう!

get "/show_additionally", to: "rooms#show_additionally"

コントローラーの編集

新しいアクションを追加するので、コントローラに定義して、処理を記述します。

def show_additionally
  last_id = params[:oldest_message_id].to_i - 1
  @messages = Message.includes(:user).order(:id).where(id: 1..last_id).last(50)
end

コードの大枠としては、JavaScriptにこれから定義するAjax通信を用いて送られてくる、情報を受け取り、処理するプログラムです。

流れとしては、

・表示しているメッセージの1番古いidを受け取る。
・そのidより1個古いidを「last_id」として定義するために、「- 1」の計算行う。
・そして、includesを用いて、idが1番から「last_id」の間の最後の50件を引っ張ってきて、インスタンス変数に代入する

これで、データの取得に関する設定は終了です。

次に、フロント側の実装です。

room_channels.jsファイルの編集

下記のコードをroom_channels.jsファイルの1番下に記述します。

  let oldestMessageId

  window.showAdditionally = true
  window.addEventListener("scroll", () => {
    if (documentElement.scrollTop === 0 && showAdditionally) {
      showAdditionally = false
      oldestMessageId = document.getElementsByClassName("message")[0].id.replace(/[^0-9]/g, "")
      $.ajax({
        type: "GET",
        url: "/show_additionally",
        cache: false,
        data: {oldest_message_id: oldestMessageId, remote: true}
      })
    }
  }, {passive: true});

流れとしては以下の通りです。

・表示しているメッセージ中で1番古いメッセージのidを代入するための変数を定義
・関数を動かすためのイベントリスナを定義して、関数を設定する。
・if文を用いて、1番上までスクロールされたら、1番古いメッセージのidを変数に代入して、Ajaxを送信を行う処理を記述する。

これで、1番上までスクロールした際にコントローラーに必要な情報を持たせた状態で、コントローラにリクエストを送ることができるようになりました。

最後に、コントローラが受け取った情報を元に引っ張ってきたデータ(メッセージ)を表示するための処理を記述します。

show_additionally.js.erbファイルの作成、編集

app/views/rooms/show_additionally.js.erbファイルを作成します。

ファイルを作成したら以下のように編集します。

var messageHeight = messageContainer.scrollHeight

messageContainer.insertAdjacentHTML("afterbegin", "<%= j(render @messages) %>")
var newMessageHeight = messageContainer.scrollHeight
window.scrollBy(0, newMessageHeight - messageHeight)
<% if @messages.present?%>
  showAdditionally = true
<% end %>

流れとしては以下の通りです。

・「メッセージ一覧の高さ」を変数に代入
・show_additionallyアクションを用いて取得した、データを表示しているメッセージの上に追加
・「新しいメッセージを取得した状態の高さ」を変数に代入
・『「新しいメッセージを取得した状態の高さ」ー「メッセージ一覧の高さ」』の計算を行い、差分を下にスクロールするように処理を記述する。
・メッセージが存在するときだけ読み込み可能とする処理を記述

差分を計算して、その差分を下にスクロールするようにプログラムしたのには訳があります。

それは、その記述がないとメッセージを取得した際に、画面の表示位置が1番上のままになってしまい、取得したメッセージをみるには下にスクロールしないといけなくなるからです。

そうならないようにするために、メッセージを取得した段階で、メッセージ分を下にスクロールすることでこの問題を解決します。

これで実装は完了です。

最後にHerokuにデプロイする方法について軽く説明して終わります。

Herokuにデプロイ

現時点で何も触らずHerokuにデプロイしても正常に動作してくれません。
そのため、「cable.yml」ファイルのプロダクション環境を編集します。

アダプタをローカル環境と同じ、「async」にすることで、ローカル環境と同じようにHeroku上でも動作するようになります。

「cable.yml」ファイルを以下のように編集しましょう!

development:
  adapter: async

test:
  adapter: test

production:
  #adapter: redis
  #url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  #channel_prefix: Family_production
  adapter: async

これで正常のHerokuにデプロイできるかと思います。

最後に

以上で、リアルタイムチャット機能を兼ね備えたチャットアプリ の作成手順についての解説を終わります。

このアプリは実際に、僕がいろいろなサイトを参考にしながら、テックキャンプ の最終課題として作成したアプリの作成手順をまとめたものになります。

他にもCSSなど触ったりしていますが、大まかな機能は記事に書いているものと同じです。

最後まで読んでいただきありがとうございました。

スポンサーリンク




この記事を書いた人