Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails5「中級」チュートリアル(4-1)インスタントメッセージ: 非公開チャット - 前編(翻訳)

概要

概要

原著者の許諾を得て翻訳・公開いたします。

Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。

注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。

目次


訳注: この少し前あたりの手順から、visit_single_post_spec.rbが失敗しています。翻訳中の検証ではscenarioxscenarioに変えてひとまずペンディングしています。お気づきの点がありましたら@hachi8833までお知らせください。

Rails5「中級」チュートリアル(4-1-1)インスタントメッセージ: 非公開チャット - 前編: チャット機能の作成(翻訳)

訳注: conversationは原則「チャット」と訳しています。

このセクションの目標は、2人のユーザーが非公開で会話できるチャット機能の作成です。

新しいブランチを切ります。

git checkout -B private_conversation

モデルを名前空間化する

まず、必要なモデルをいくつか定義しましょう。さしあたって2つの異なるモデルが必要です。1つは非公開チャット用、もう1つはプライベートメッセージ用です。モデル名をPrivateConversationPrivateMessageとすることも一応可能ですが、すぐに小さな問題に突き当たるでしょう。すべてがうまく動いていても、modelsディレクトリの下に同じようなプレフィックスを持つモデル名がいくつもできてしまうところを想像してみてください。間もなくこのディレクトリの管理がつらくてたまらなくなるでしょう。

ディレクトリ内がカオスになるのを避けるために、名前空間化を使います。

名前空間化するとどうなるかを見ていきましょう。非公開チャットに使うモデルを素直に命名すればPrivateConversationとなり、モデルのファイルはprivate_conversation.rbで、modelディレクトリに置かれます。

models/private_conversation.rb

これを名前空間化したものはPrivate::Conversationになります。ファイル名はconversation.rbで、models/privateディレクトリに置かれます。

models/private/conversation.rb

これのどこが便利なのかおわかりでしょうか?privateプレフィックスを持つファイルはすべてprivateディレクトリに保存されます。モデルをメインのmodelsディレクトリの下にベタッと並べると読むのがつらくなります。

こんなとき、Railsはいつものように開発プロセスを楽しくしてくれます。Railsでは、モデルを保存するディレクトリを指定して、名前空間化されたモデルを作成することができます。

名前空間化されたPrivate::Conversationモデルを作成するには、以下のコマンドを実行します。

rails g model private/conversation

Private::Messageモデルも同様に作成します。

rails g model private/message

modelsディレクトリを見てみると、その下にprivate.rbファイルができているのがわかります。これによってデータベースのテーブル名にプレフィックスを付けることが必須になり、モデルが認識されるようになります。個人的には、こうしたファイルをmodelsディレクトリに置いておくのは好きではなく、モデル自身の内部でテーブル名を指定する方が好きです。モデルの内部でテーブル名を指定するには、self.table_name =でテーブル名を文字列で指定しなくてはなりません。私と同じようにデータベースのテーブル名をこの方法で指定すると、このモデルは次のようになります(GistGist)。

# models/private/conversation.rb
class Private::Conversation < ApplicationRecord
  self.table_name = 'private_conversations'
end
# models/private/message.rb
class Private::Message < ApplicationRecord
  self.table_name = 'private_messages'
end

これでmodelsディレクトリの下のprivate.rbファイルは不要になったので、削除して構いません。

1人のユーザーは非公開チャットを複数行え、チャットには多数のメッセージが含まれます。この関連付けをモデル内で定義しましょう(GistGistGist)。

# models/private/conversation.rb
...
has_many :messages,
         class_name: "Private::Message",
         foreign_key: :conversation_id
belongs_to :sender, foreign_key: :sender_id, class_name: 'User'
belongs_to :recipient, foreign_key: :recipient_id, class_name: 'User'
...
# models/private/message.rb
...
  belongs_to :user
  belongs_to :conversation,
             class_name: 'Private::Conversation',
             foreign_key: :conversation_id
...
# models/user.rb
...
has_many :private_messages, class_name: 'Private::Message'
has_many  :private_conversations,
          foreign_key: :sender_id,
          class_name: 'Private::Conversation'
...

上のclass_nameメソッドは、関連付けられたモデルの名前を定義するのに使われます。こうすることで関連付けに独自の名前が使えるようになり、名前空間化されたモデルであることが認識されます。class_nameメソッドのもうひとつの使い方は「自分自身へのリレーション」の作成です。これは、何らかの階層的な構造を作成して同じモデルのデータを差別化したいときに便利です。

foreign_keyは、データベーステーブル内の関連付けカラム名を指定するのに使います。テーブル内のカラムはbelongs_to関連付け側でのみ作成されますが、このカラムを認識させるために、で2つのモデルの同じ値でforeign_keyを定義しました。

非公開チャットは2人のユーザー間で行えるようにします。ここでは2人のユーザーをそれぞれsenderrecipientとします。user1user2のような名前にしようと思えばできますが、2人のどちらが最初にチャットを開始したかがわかると何かと便利なので、ここではsenderがチャットの作成者となります。

マイグレーションファイルでデータのテーブルを定義します(Gist)。

# db/migrate/CREATION_DATE_create_private_conversations.rb
class CreatePrivateConversations < ActiveRecord::Migration[5.1]
  def change
    create_table :private_conversations do |t|
      t.integer :recipient_id
      t.integer :sender_id

      t.timestamps
    end
    add_index :private_conversations, :recipient_id
    add_index :private_conversations, :sender_id
    add_index :private_conversations, [:recipient_id, :sender_id], unique: true
  end
end

private_conversationsにはユーザーのidを保存することになります。ユーザーidは、belongs_to関連付けやhas_many関連付けが機能するのに必要ですし、2人のユーザー間のチャットを作成するのにももちろん必要です(Gist)。

# db/migrate/CREATION_DATE_create_private_messages.rb
class CreatePrivateMessages < ActiveRecord::Migration[5.1]
  def change
    create_table :private_messages do |t|
      t.text :body
      t.references :user, foreign_key: true
      t.belongs_to :conversation, index: true
      t.boolean :seen, default: false

      t.timestamps
    end
  end
end

メッセージの内容はbodyデータカラムに保存されることになります。2つのモデル間の関連付けを機能させるためのインデックスやidカラムを追加する代わりに、ここではreferenceメソッドで実装をシンプルにしました。

マイグレーションファイルを実行して、developmentデータベースの内部にテーブルを作成します。

rails db:migrate

変更をcommitします。

git add -A
git commit -m "Create Private::Conversation and Private::Message models

- Define associations between User, Private::Conversation
  and Private::Message models
- Define private_conversations and private_messages tables"

「非リアルタイム」チャットウィンドウ

非公開チャットのデータを保存する場所ができましたが、これでおしまいではありません。次はどこから手を付けるべきでしょうか?前のセクションでご説明したように、私は機能の表示を先に作成して、それからそれを動かすロジックを書く方法が好みです。私にとっては、動かしたい画面表示が先にある方が達成すべき作業がはっきりするからです。ユーザーインターフェイスができていれば、これこれこういう操作を行ったときにどう動くべきかは画面を見ればわかるので、それを細かな手順に分割するのは簡単です。形になっていないものを相手にプログラミングする方が面倒だと思います。

非公開チャットのユーザーインターフェイスを作るためにPrivate::Conversationsコントローラを作成します。先ほどもアプリで名前空間化を行いましたので、関連するパーツもすべて同じように名前空間化したいと思います。こうすることで、ソースコードを直感的に眺めやすくなり、理解もしやすくなります。

rails g controller private/conversations

Railsのジェネレータはなかなかカワイイやつです。名前空間化されたモデルや名前空間化されたビューを生成してくれるので、即開発に取りかかれます。

新しいチャットを作成する

新しいチャットを開始する何らかの方法が必要です。このアプリでは、自分と好みの似通った人と会話したいと考えるのが自然でしょう。この機能を配置するのに便利な場所は、単一の投稿ページの内部です。

posts/show.html.erbテンプレートの中に、チャットを開始するフォームをひとつ作成します。<%= @post.content %>の下に以下を追加します(Gist)。

<!-- posts/show.html.erb -->
...
<%= render contact_user_partial_path %>
...

このヘルパーメソッドをposts_helper.rbで定義します(Gist)。

# helpers/posts_helper.rb
...
  def contact_user_partial_path
    if user_signed_in?
      @post.user.id != current_user.id ? 'posts/show/contact_user' : 'shared/empty_partial'
    else
      'posts/show/login_required'
    end
  end
...

このヘルパーメソッドのspecを作成します。

# spec/helpers/posts_helper_spec.rb
...
context '#contact_user_partial_path' do
  before(:each) do
    @current_user = create(:user, id: 1)
    helper.stub(:current_user).and_return(@current_user)
  end

  it "returns a contact_user partial's path" do
    helper.stub(:user_signed_in?).and_return(true)
    assign(:post, create(:post, user_id: create(:user, id: 2).id))
    expect(helper.contact_user_partial_path).to(
      eq 'posts/show/contact_user'
    )
  end

  it "returns an empty partial's path" do
    helper.stub(:user_signed_in?).and_return(true)
    assign(:post, create(:post, user_id: @current_user.id))

    expect(helper.contact_user_partial_path).to(
      eq 'shared/empty_partial'
    )
  end

  it "returns an empty partial's path" do
    helper.stub(:user_signed_in?).and_return(false)
    expect(helper.contact_user_partial_path).to(
      eq 'posts/show/login_required'
    )
  end
end
...

showディレクトリを作成して、対応するパーシャルファイルを作成します(GistGist)。

<!-- posts/show/_contact_user.html.erb -->
<div class="contact-user">
  <%= render leave_message_partial_path %>
</div><!-- contact-user -->
<-- posts/show/_login_required.html.erb -->
<div class="text-center">
  To contact the user you have to <%= link_to 'Login', login_path %>
</div>

posts_helper.rbファイルでleave_message_partial_pathヘルパーメソッドを定義します(Gist)。

# helpers/posts_helper.rb
def leave_message_partial_path
  if @message_has_been_sent
    'posts/show/contact_user/already_in_touch'
  else
    'posts/show/contact_user/message_form'
  end
end

ヘルパーメソッドのspecを作成します(Gist)。

# spec/helpers/posts_helper_spec.rb
...
context '#leave_message_partial_path' do
  it "returns an already_in_touch partial's path" do
    assign('message_has_been_sent', true)
    expect(helper.leave_message_partial_path).to(
      eq 'posts/show/contact_user/already_in_touch'
    )
  end

  it "returns an already_in_touch partial's path" do
    assign('message_has_been_sent', false)
    expect(helper.leave_message_partial_path).to(
      eq 'posts/show/contact_user/message_form'
    )
  end
end
...

今だけ、PostsController@message_has_been_sentインスタンス変数を定義することにします。この変数は、ユーザーへの最初のメッセージが送信されたかどうかを決定します。

contact_userを作成し、leave_message_partial_pathヘルパーメソッドに対応するパーシャルファイルをその下に作成します(GistGist)。

<!-- posts/show/contact_user/_already_in_touch.html.erb -->
<div class="contacted-user">
  You are already in touch with this user
</div>
<!-- posts/show/contact_user/_message_form.html.erb -->
<%= form_tag({controller: "private/conversations", action: "create"},
              method: "post",
              remote: true) do %>
  <%= hidden_field_tag(:post_id, @post.id)  %>
  <%= text_area_tag(:message_body,
                    nil,
                    rows: 3,
                    class: 'form-control',
                    placeholder: 'Send a messsage to the user') %>
  <%= submit_tag('Send a message', class: 'btn send-message-to-user') %>
<% end %>

今度はPostsControllershowアクションを設定しましょう。アクション内に以下を追加します(Gist)。

# controllers/posts_controller.rb
...
if user_signed_in?
  @message_has_been_sent = conversation_exist?
end
...

このコントローラのprivateスコープでconversation_exist?メソッドを定義します(Gist)。

# controllers/posts_controller.rb
...
def conversation_exist?
  Private::Conversation.between_users(current_user.id, @post.user.id).present?
end
...

このbetween_usersメソッドは、2人のユーザー間の非公開チャットの存在を問い合わせます。これをPrivate::Conversationモデルでスコープとして定義しましょう(Gist)。

# models/private/conversation.rb
...
scope :between_users, -> (user1_id, user2_id) do
  where(sender_id: user1_id, recipient_id: user2_id).or(
    where(sender_id: user2_id, recipient_id: user1_id)
  )
end
...

このスコープが機能しているかどうかをテストしなければなりません。テストデータベース内にサンプルが必要なので、specを書く前にprivate_conversationファクトリーを定義しておきましょう(Gist)。

# spec/factories/private_conversations.rb
FactoryBot.define do
  factory :private_conversation, class: 'Private::Conversation' do
    association :recipient, factory: :user
    association :sender, factory: :user

    factory :private_conversation_with_messages do
      transient do
        messages_count 1
      end

      after(:create) do |private_conversation, evaluator|
        create_list(:private_message, evaluator.messages_count,
                     conversation: private_conversation)
      end
    end
  end
end

ファクトリーをネストさせることで、その親の設定を使ってファクトリーを作成してからそれを変更できるようになります。また、private_conversation_with_messagesファクトリーでメッセージを作成するので、private_messageファクトリーの定義も必要です(Gist)。

# spec/factories/private_messages.rb
FactoryBot.define do
  factory :private_message, class: 'Private::Message' do
    body 'a' * 20
    association :conversation, factory: :private_conversation
    user
  end
end

準備がすべて整いましたので、between_usersスコープをspecでテストします(Gist)。

# spec/models/private/conversation_spec.rb
...
context 'Scopes' do
  it 'gets a conversation between users' do
    user1 = create(:user)
    user2 = create(:user)
    create(:private_conversation, recipient_id: user1.id, sender_id: user2.id)
    conversation = Private::Conversation.between_users(user1.id, user2.id)
    expect(conversation.count).to eq 1
  end
end
...

Private::Conversationsコントローラでcreateアクションを定義します(Gist)。

# controllers/private/conversation_controller.rb
...
def create
  recipient_id = Post.find(params[:post_id]).user.id
  conversation = Private::Conversation.new(sender_id: current_user.id,
                                           recipient_id: recipient_id)
  if conversation.save
    Private::Message.create(user_id: recipient_id,
                            conversation_id: conversation.id,
                            body: params[:message_body])
    respond_to do |format|
      format.js {render partial: 'posts/show/contact_user/message_form/success'}
    end
  else
    respond_to do |format|
      format.js {render partial: 'posts/show/contact_user/message_form/fail'}
    end
  end
end
...

ここでは、投稿の著者と現在のユーザーの間でのチャットを作成しています。問題がなければ、アプリで現在のユーザーが書いたメッセージが作成され、対応するJavaScriptパーシャルをレンダリングすることで画面に表示されるようになります。

そのためのパーシャルを作成します(GistGist)。

<!-- posts/show/contact_user/message_form/_success.js.erb -->
$('.contact-user').replaceWith('\
    <div class="contact-user">\
        <div class="contacted-user">Message has been sent</div>\
    </div>');
<!-- posts/show/contact_user/message_form/_fail.js.erb -->
$('.contact-user').replaceWith('<div>Message has not been sent</div>');

Private::ConversationsコントローラとPrivate::Messagesコントローラへのルーティングを作成します(Gist)。

# routes.rb
...
namespace :private do
  resources :conversations, only: [:create] do
    member do
      post :close
    end
  end
  resources :messages, only: [:index, :create]
end
...

今はまだアクションが少ないので、このような場合はonlyメソッドで書くのが便利です。namespaceメソッドを使うと、名前空間化されたコントローラへのルーティングを簡単に作成できます。

.contact-userフォーム全体のパフォーマンスをfeature specでテストします(Gist)。

# spec/features/posts/contact_user_spec.rb
require "rails_helper"

RSpec.feature "Contact user", :type => :feature do
  let(:user) { create(:user) }
  let(:category) { create(:category, name: 'Arts', branch: 'hobby') }
  let(:post) { create(:post, category_id: category.id) }

  context 'logged in user' do
    before(:each) do
      sign_in user
    end

    scenario "successfully sends a message to a post's author", js: true do
      visit post_path(post)
      expect(page).to have_selector('.contact-user form')

      fill_in('message_body', with: 'a' * 20)
      find('form .send-message-to-user').trigger('click')

      expect(page).not_to have_selector('.contact-user form')
      expect(page).to have_selector('.contacted-user',
                                      text: 'Message has been sent')
    end

    scenario 'sees an already contacted message' do
      create(:private_conversation_with_messages,
              recipient_id: post.user.id,
              sender_id: user.id)
      visit post_path(post)
      expect(page).to have_selector(
        '.contact-user .contacted-user',
        text: 'You are already in touch with this user')
    end
  end

  context 'non-logged in user' do
    scenario 'sees a login required message to contact a user' do
      visit post_path(post)
      expect(page).to have_selector('div', text: 'To contact the user you have to')
    end
  end
end

変更をcommitします。

git add -A
git commit -m "Inside a post add a form to contact a user

- Define a contact_user_partial_path helper method in PostsHelper.
  Add specs for the method
- Create _contact_user.html.erb and _login_required.html.erb partials
- Define a leave_message_partial_path helper method in PostsHelper.
  Add specs for the method
- Create _already_in_touch.html.erb and _message_form.html.erb
  partial files
- Define a @message_has_been_sent in PostsController's show action
- Define a between_users scope inside the Private::Conversation model
  Add specs for the scope
- Define private_conversation and private_message factories
- Define routes for Private::Conversations and Private::Messages
- Define a create action inside the Private::Conversations
- Create _success.js and _fail.js partials
- Add feature specs to test the overall .contact-user form"

branch_page.scssファイルにCSSを追加して、フォームのスタイルを少し変更します(Gist)。

// stylesheets/partials/posts/branch_page.scss
...
.send-message-to-user {
  background-color: $navbarColor;
  padding: 10px;
  color: white;
  border-radius: 10px;
  margin-top: 10px;
  &:hover {
    background-color: black;
    color: white;
  }
}

.contact-user {
  text-align: center;
}

.contacted-user {
  display: inline-block;
  border-radius: 10px;
  padding: 10px;
  background-color: $navbarColor;
  color: white;
}
...

この単一投稿ページを表示すると、以下のようにフォームが表示されるはずです。

メッセージを投稿の著者に送信すると、フォームは消えます。

投稿の著者と既にやりとりしたことがある場合は、以下のように表示されます。

変更をcommitします。

git add -A
git commit -m "Add CSS to style the .contact-user form"

チャットウィンドウを表示する

上ではメッセージを1件送信して新しいチャットを作成しました。今はこれ以外に何もできない状態なのでこの機能は何の役にも立ちません。メッセージを読み書きできるチャットウィンドウが必要です。

開いているチャットのidは「セッション」の内部に保存されます。これによって、ユーザーがチャットを終了するかセッションが死ぬまでアプリのチャットを継続できるようになります。

Private::ConversationsControllercreateアクション内で、チャットを保存できたときの処理にadd_to_conversations unless already_added?を追加してください。次にこのメソッドをprivateスコープで定義します(Gist)。

# controllers/private/conversations_controller.rb
...
private

def add_to_conversations
  session[:private_conversations] ||= []
  session[:private_conversations] << @conversation.id
end

これによってチャットのidがセッションに保存されます。チャットidがセッションに追加されていないかどうかを確認するalready_added?メソッドをprivateに配置します(Gist)。

# controllers/private/conversations_controller.rb
def already_added?
  session[:private_conversations].include?(@conversation.id)
end

最後に、ビュー内でチャットにアクセスできる必要があるので、createアクション内のconversation変数(3か所)を@conversationインスタンス変数に書き換えてください。

訳注: 原文にありませんが、変更後のPrivate::ConversationsControllerコントローラを以下に示します。

# controllers/private/conversations_controller.rb
class Private::ConversationsController < ApplicationController
  def create
    recipient_id = Post.find(params[:post_id]).user.id
    @conversation = Private::Conversation.new(sender_id: current_user.id,
                                             recipient_id: recipient_id)
    if @conversation.save
      Private::Message.create(user_id: current_user.id,
                              conversation_id: @conversation.id,
                              body: params[:message_body])

      add_to_conversations unless already_added?

      respond_to do |format|
        format.js {render partial: 'posts/show/contact_user/message_form/success'}
      end
    else
      respond_to do |format|
        format.js {render partial: 'posts/show/contact_user/message_form/fail'}
      end
    end
  end

  private

  def add_to_conversations
    session[:private_conversations] ||= []
    session[:private_conversations] << @conversation.id
  end

  def already_added?
    session[:private_conversations].include?(@conversation.id)
  end
end

チャットウィンドウのテンプレート作成の準備が整いましたので、ウィンドウのパーシャルを作成します(Gist)。

<!-- private/conversations/_conversation.html.erb -->
<% @recipient = private_conv_recipient(conversation) %>
<% @is_messenger = false %>
<li class="conversation-window"
    id="pc<%= conversation.id %>"
    data-pconversation-user-name="<%= @recipient.name %>"
    data-turbolinks-permanent>
  <div class="panel panel-default" data-pconversation-id="<%= conversation.id %>">
    <%= render 'private/conversations/conversation/heading',
                conversation: conversation %>

    <!-- Conversation window's content -->
    <div class="panel-body">
      <%= render 'private/conversations/conversation/messages_list',
                  conversation: conversation %>
      <%= render 'private/conversations/conversation/new_message_form',
                  conversation: conversation,
                  user: user %>
    </div><!-- panel-body -->
  </div>
</li><!-- conversation-window -->

private_conv_recipientを用いてチャットのrecipient(受け側)を取得しているので、Private::ConversationsHelperでヘルパーメソッドを定義します(Gist)。

# helpers/private/conversations_helper.rb
...
# チャットの相手となるユーザーを取得
def private_conv_recipient(conversation)
  conversation.opposed_user(current_user)
end
...

opposed_userメソッドが使われているので、Private::Conversationモデルでこのメソッドを定義します(Gist)。

# models/private/conversation.rb
...
def opposed_user(user)
  user == recipient ? sender : recipient
end
...

このメソッドは、非公開チャットの相手ユーザーを返します。このメソッドが正しく機能していることをspecで確認しましょう(Gist)。

# spec/models/private/conversation_spec.rb
...
context 'Methods' do
  it 'gets an opposed user of the conversation' do
    user1 = create(:user)
    user2 = create(:user)
    conversation = create(:private_conversation,
                           recipient_id: user1.id,
                           sender_id: user2.id)
    opposed_user = conversation.opposed_user(user1)
    expect(opposed_user).to eq user2
  end
end
...

次に、足りないパーシャルを_conversation.html.erbファイルで作成します(GistGist)。

<!-- private/conversations/conversation/_heading.html.erb -->
<div class="panel-heading conversation-heading">
  <span class="contact-name-notif"><%= @recipient.name %></span>
</div> <!-- conversation-heading -->

<!-- Close conversation button -->
<%= link_to "X",
            close_private_conversation_path(conversation),
            class: 'close-conversation',
            title: 'Close',
            remote: true,
            method: :post %>
<!-- private/conversations/conversation/_messages_list.html.erb -->
<div class="messages-list">
  <%= render load_private_messages(conversation), conversation: conversation %>
  <div class="loading-more-messages">
    <i class="fa fa-spinner" aria-hidden="true"></i>
  </div>
  <!-- messages -->
  <ul>
  </ul>
</div>

load_private_messagesヘルパーメソッドをPrivate::ConversationsHelperに定義します(Gist)。

# helpers/private/conversations_helper.rb
...
# if the conversation has unshown messages, show a button to get them
def load_private_messages(conversation)
  if conversation.messages.count > 0
    'private/conversations/conversation/messages_list/link_to_previous_messages'
  else
    'shared/empty_partial'
  end
end
...

上はそれまでのメッセージを読み込むリンクを追加します。これに対応するパーシャルファイルをmessages_listディレクトリの下に作成します(Gist)。

<!-- private/conversations/conversation/messages_list/_link_to_previous_messages.html.erb -->
<%= link_to "Load messages",
            private_messages_path(:conversation_id => conversation.id,
                                  :messages_to_display_offset => @messages_to_display_offset,
                                  :is_messenger => @is_messenger),
            class: 'load-more-messages',
            remote: true %>

このメソッドがすべて問題なく動くことを確認するspecを書くのもお忘れなく(Gist)。

# spec/helpers/private/conversations_helper_spec.rb
...
context '#load_private_messages' do
  let(:conversation) { create(:private_conversation) }

  it "returns load_messages partial's path" do
    create(:private_message, conversation_id: conversation.id)
    expect(helper.load_private_messages(conversation)).to eq (
      'private/conversations/conversation/messages_list/link_to_previous_messages'
    )
  end

  it "returns empty partial's path" do
    expect(helper.load_private_messages(conversation)).to eq (
      'shared/empty_partial'
    )
  end
end
...

チャットのウィンドウはアプリ全体でレンダリングすることになるので、Private::ConversationsHelperヘルパーメソッドにアクセスできる必要があります。このヘルパーメソッドにアプリのどこからでもアクセスできるようにするには、ApplicationHelperに以下を追加します(訳注: 原文から脱落していたコードを補いました)。

# helpers/private/conversations_helper.rb
...
include Private::ConversationsHelper

def private_conversations_windows
  params[:controller] != 'messengers' ? @private_conversations_windows : []
end
...

続いて、チャットの新しいメッセージフォームで使うパーシャルファイルがまだないので、作成します(Gist)。

<!-- private/conversations/conversation/_new_message_form.html.erb -->
<form class="send-private-message">
  <input name="conversation_id" type="hidden" value="<%= conversation.id %>">
  <input name="user_id" type="hidden" value="<%= user.id %>">
  <textarea name="body" rows="3" class="form-control" placeholder="Type a message..."></textarea>
  <input type="submit" class="btn btn-success send-message">
</form>

このフォームの機能は、もう少し後で動くようにする予定です。

それでは、ユーザーが個別の投稿からメッセージを1件送信したら、アプリのチャットウィンドウで表示されるようにする機能を作成しましょう。

_success.js.erbファイルを開きます。

posts/show/contact_user/message_form/_success.js.erb

末尾に以下を追加します。

<%= render 'private/conversations/open' %>

このパーシャルファイルの目的は、アプリにチャットウィンドウを追加することです。そのためのパーシャルファイルを定義します(Gist)。

// private/conversations/_open.js.erb
var conversation = $('body').find("[data-pconversation-id='" +
                                "<%= @conversation.id %>" +
                                "']");
var chat_windows_count = $('.conversation-window').length + 1;

if (conversation.length !== 1) {
  $('body').append("<%= j(render 'private/conversations/conversation',\
                                  conversation: @conversation,\
                                  user: current_user) %>");
  conversation = $('body').find("[data-conversation-id='" +
                                "<%= @conversation.id %>" +
                                "']");
}

// チャットの作成後にチャットウィンドウをトグルする
$('.conversation-window:nth-of-type(' + chat_windows_count + ')\
   .conversation-heading').click();
// mark as seen by clicking it
setTimeout(function(){
  $('.conversation-window:nth-of-type(' + chat_windows_count + ')').click();
 }, 1000);
// focus textarea
$('.conversation-window:nth-of-type(' + chat_windows_count + ')\
   form\
   textarea').focus();

// すべてのチャットウィンドウを再配置する
positionChatWindows();

このコールバック用パーシャルファイルは、今後さまざまなシナリオで再利用することになります。同じウィンドウを何度もレンダリングするのを避けるため、レンダリング前にウィンドウが既にあるかどうかをチェックし、それからウィンドウを拡大してメッセージフォームに自動的にフォーカスを移動します。ファイル末尾にあるpositionChatWindows()関数は、チャットウィンドウの位置がすべて適切な場所に配置されるようにするために呼び出されます。配置を修正しないと、複数のウィンドウが同じ場所に表示されてしまい、使い物にならなくなります。

それでは、assetsディレクトリの下に、チャットウィンドウの表示や配置を扱うファイルを作成しましょう(Gist)。

// assets/javascripts/conversations/position_and_visibility.js
$(document).on('turbolinks:load', function() {
    chat_windows_count = $('.conversation-window').length;
    // 最新のチャットウィンドウが未設定で、チャットウィンドウが既に存在する場合
    // last_visible_chat_window変数を設定
    if (gon.last_visible_chat_window == null && chat_windows_count > 0) {
        gon.last_visible_chat_window = chat_windows_count;
    }
    // igon.hidden_chatsがない場合は値を設定
    if (gon.hidden_chats == null) {
        gon.hidden_chats = 0;
    }
    window.addEventListener('resize', hideShowChatWindow);

    positionChatWindows();
    hideShowChatWindow();
});

function positionChatWindows() {
    chat_windows_count = $('.conversation-window').length;
    // 新しいチャットウィンドウが追加された場合、
    // 表示可能な最新のチャットウィンドウとして設定し、
    // viewportの幅に応じて
    // hideShowChatWindow関数で表示をオンオフできるようにする
    if (gon.hidden_chats + gon.last_visible_chat_window !== chat_windows_count) {
        if (gon.hidden_chats == 0) {
            gon.last_visible_chat_window = chat_windows_count;
        }
    }

    // 新しいチャットウィンドウが追加されたときにリストの一番左に配置する
    for (i = 0; i < chat_windows_count; i++ ) {
        var right_position = i * 410;
        var chat_window = i + 1;
        $('.conversation-window:nth-of-type(' + chat_window + ')')
            .css('right', '' + right_position + 'px');
    }
}

// viewportの右側に接近したら常に最新のチャットウィンドウを隠す
function hideShowChatWindow() {
    // チャットウィンドウが1つもない場合は関数を終了
    if ($('.conversation-window').length < 1) {
        return;
    }
    // 最も左にあるチャットウィンドウのオフセットを取得
    var offset = $('.conversation-window:nth-of-type(' + gon.last_visible_chat_window + ')').offset();
    // チャットウィンドウの左のオフセットが50より小さい場合、
    // そのチャットウィンドウを隠す
    if (offset.left < 50 && gon.last_visible_chat_window !== 1) {
        $('.conversation-window:nth-of-type(' + gon.last_visible_chat_window + ')')
            .css('display', 'none');
        gon.hidden_chats++;
        gon.last_visible_chat_window--;
    }
    // 一番左のチャットウィンドウのオフセットが550より大きく、
    // かつ非表示のチャットがある場合は、非表示チャットを表示する
    if (offset.left > 550 && gon.hidden_chats !== 0) {
        gon.hidden_chats--;
        gon.last_visible_chat_window++;
        $('.conversation-window:nth-of-type(' + gon.last_visible_chat_window + ')')
            .css('display', 'initial');
    }
}

cookieの設定や取得のために、関数を独自に作成したりJavaScript間のデータ管理を独自に行ったりする代わりに、gon gemを使う方法があります。本来このgemはサーバーサイドからJavaScriptにデータを送信するためのものですが、アプリ全体を通してJavaScriptの変数をトラッキングするのにも便利であることに気づきました。このgemの指示を読んでインストールとセットアップを行います。

訳注: Gemfileに以下を追加し、bundle installを実行します。また、application.html.erbのheadタグにも追記が必要との情報をいただきました。ありがとうございます。

gem 'gon'

https://twitter.com/eighchaaan/status/980948611553832960

viewportの幅はイベントリスナーでトラッキングします。チャットがviewportの左側に近づくと、チャットは非表示になります。非表示のチャットウィンドウを表示するのに十分な空きスペースができると、ふたたびチャットウィンドウを表示します。

ページが表示されると、再配置や表示/非表示を行う関数を呼び出して、すべてのチャットウィンドウが正しい位置に表示されるようにします。

ここでは、Bootstrapのpanelコンポーネントを用いて、チャットウィンドウの展開/折りたたみを簡単に行えるようにします。ウィンドウはデフォルトでは折りたたまれていて操作できないので、表示/非表示をトグルできるようにするために、javascriptsディレクトリの下に以下のtoggle_window.jsファイルを作成します(Gist)。

// javascripts/conversations/toggle_window.js
$(document).on('turbolinks:load', function() {

    // when conversation heading is clicked, toggle conversation
    $('body').on('click',
               '.conversation-heading, .conversation-heading-full',
               function(e) {
        e.preventDefault();
        var panel = $(this).parent();
        var panel_body = panel.find('.panel-body');
        var messages_list = panel.find('.messages-list');

        panel_body.toggle(100, function() {
        });
    });
});

conversation_window.scssファイルを作成します。

assets/stylesheets/partials/conversation_window.scss

チャットウィンドウのスタイルを設定するCSSを追加します(Gist)。

// assets/stylesheets/partials/conversation_window.scss
textarea {
  resize: none;
}

.panel {
  margin: 0;
  border: none !important;
}

.panel-heading {
  border-radius: 0;
}

.panel-body {
  position: relative;
  display: none;
  padding: 0 0 5px 0;
}

.conversation-window, .new_chat_window {
  min-width: 400px;
  max-width: 400px;
  position: fixed;
  bottom: 0;
  right: 0;
  list-style-type: none;
}

.conversation-heading, .conversation-heading-full, .new_chat_window {
  background-color: $navbarColor !important;
  color: white !important;
  height: 40px;
  border: none !important;
  a {
    color: white !important;
  }

}

.conversation-heading, .conversation-heading-full {
  padding: 0 0 0 15px;
  width: 360px;
  display: inline-block;
  vertical-align: middle;
  line-height: 40px;
}

.close-conversation, .add-people-to-chat, .add-user-to-contacts, .contact-request-sent {
  color: white;
  float: right;
  height: 40px;
  width: 40px;
  font-size: 20px;
  font-size: 2.0rem;
  border: none;
  background-color: $navbarColor;
}

.close-conversation, .add-user-to-contacts {
  text-align: center;
  vertical-align: middle;
  line-height: 40px;
  font-weight: bold;
}

.close-conversation {
  &:hover {
    border: none;
    background-color: white;
    color: $navbarColor !important;
  }
  &:visited, &:focus {
    color: white;
  }
}

.form-control[disabled] {
  background-color: $navbarColor;
}

.send-private-message, .send-group-message {
  textarea {
    border-radius: 0;
    border: none;
    border-top: 1px solid rgba(0, 0, 0, 0.2);
  }
}

.loading_svg {
  display: none;
}

.loading_svg {
  text-align: center;
}

.messages-list {
  z-index: 1;
  min-height: 300px;
  max-height: 300px;
  overflow-y: auto;
  overflow-x: hidden;
  ul {
    padding: 0;
  }
}

.message-received, .message-sent {
  max-width: 300px;
  word-wrap: break-word;
  z-index: 1;
}

.message-sent {
  position: relative;
  background-color: white;
  border: 1px solid rgba(0, 0, 0, 0.5);
  border-radius: 5px;
  margin: 5px 5px 5px 50px;
  padding: 10px;
  float: right;
}

.message-received {
  background-color: $backgroundColor;
  border-color: #EEEEEE;
  border-radius: 5px;
  margin: 5px 50px 5px 5px;
  padding: 10px;
  float: left;
}

.messages-date {
  width: 100%;
  text-align: center;
  border-bottom: 1px solid rgba(0, 0, 0, 0.2);
  line-height: 1px;
  line-height: 0.1rem;
  margin: 20px 0 20px;
  span {
    background: #fff;
    padding: 0 10px;
  }

}

.load-more-messages {
  display: none;
}

.loading-more-messages {
  font-size: 20px;
  font-size: 2.0rem;
  padding: 10px 0;
  text-align: center;
}

.send-message {
  display: none;
}

これまでどのHTMLファイルにも定義されていないクラスがこのCSSでいくつか定義されていることにお気づきでしょうか。これらは今後作成するファイルです。viewsディレクトリに作成して、CSSを既存のHTML要素で共有できるようにする予定です。CSSファイルを何度もあちこちに行ったり来たりしなくても済むように、マイナーなHTML要素については、今後のHTML要素で定義されるこれらのクラスをさしあたってここに足しておきます。特定のスタイルがどのように効いているかを知りたければ、いつでもスタイルシートを開いて調べることができます。

これまでは新しく作成されたチャットのidをセッション内に保存していましたが、この辺で、この機能を利用してユーザーがチャットを閉じたりセッションが終了するまでチャットウィンドウを開いておけるようにしましょう。ApplicationControllerでフィルタを定義します。

before_action :opened_conversations_windows

続いて、opened_conversations_windowsメソッドを定義します(Gist)。

# controllers/application_controller.rb
...
def opened_conversations_windows
  if user_signed_in?
    # opened conversations
    session[:private_conversations] ||= []
    @private_conversations_windows = Private::Conversation.includes(:recipient, :messages)
                                      .find(session[:private_conversations])
  else
    @private_conversations_windows = []
  end
end
...

このincludesメソッドは、関連付けられているデータベーステーブルからのデータをインクルードするのに使います。今後はチャットからメッセージを読み込みます。includesメソッドを使わなければ、チャットメッセージのレコードがこのクエリで読み込まれなくなり、N+1クエリが発生することがあります。このクエリでメッセージが読み込まれないと、メッセージ1件1件で追加クエリが発火するかもしれません。N+1クエリが発生するとアプリのパフォーマンスに著しい影響が生じる可能性があります。ここでは100件のメッセージで100件のクエリを発行するのではなく、最初のクエリ1つだけで任意の数のメッセージを取れるようにしています。

application.html.erbファイルで、yieldメソッドの直後に以下を追加します(Gist)。

<!-- layouts/application.html.erb -->
...
<%= render 'layouts/application/private_conversations_windows' %>
...

applicationディレクトリを作成し、その下に_private_conversations_windows.html.erbパーシャルファイルを作成します。

<!-- layouts/application/_private_conversations_windows.html.erb -->
<% private_conversations_windows.each do |conversation| %>
  <%= render partial: "private/conversations/conversation",
             locals: { conversation: conversation,
                       user: current_user } %>
<% end %>

訳注: この時点でinfinite_scroll_spec.rbの最終行のcount: 30を以下のようにcount: 15に変えないとRSpecが通りませんでした。

# spec/features/posts/infinite_scroll_spec.rb
  let(:check_posts_count) do
    expect(page).to have_selector('.single-post-list', count: 15)
    page.execute_script("$(window).scrollTop($(document).height())")
    expect(page).to have_selector('.single-post-list', count: 15)
  end

これでブラウザでアプリを操作すると、どのページでもチャットが常に表示されるようになります。

変更をcommitします。

git add -A
git commit -m "Render a private conversation window on the app

- Add opened conversations to the session
- Create a _conversation.html.erb file inside private/conversations
- Define a private_conv_recipient helper method in the
  private/conversations_helper.rb
- Define an opposed_user method in Private::Conversation model
  and add specs for it
- Create _heading.html.erb and _messages_list.html.erb files
  inside the private/conversations/conversation
- Define a load_private_messages in private/conversations_helper.rb
  and add specs for it
- Create a _new_message_form.html.erb inside the
  private/conversations/conversation
- Create a _open.js.erbinside private/conversations
- Create a  position_and_visibility.js inside the
  assets/javascripts/conversations
- Create a  conversation_window.scss inside the
  assets/stylesheets/partials
- Define an opened_conversations_windows helper method in
  ApplicationController
- Create a _private_conversations_windows.html.erb inside the
  layouts/application"

関連記事

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。