概要
- 前回: Rails5「中級」チュートリアル(3-8)投稿機能: 新しい投稿を作成する(翻訳)
- 次回: Rails5「中級」チュートリアル(4-1-2)インスタントメッセージ: 非公開チャット - 中編(翻訳)
概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: The Ultimate Intermediate Ruby on Rails Tutorial: Let’s Create an Entire App!
- 原文公開日: 2017/12/17
- 著者: Domantas G
- 原著者によるリポジトリ: domagude/collabfield
Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。
注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。
目次
- 1. 序章とセットアップ
- 2. レイアウト
- 3. 投稿
- 4. インスタントメッセージ
- 4-1 非公開チャット
- 4-1-1 前編: チャット機能の作成(本セクション)
- 4-1-2 中編: Action Cableのブロードキャスト
- 4-1-3 後編: ナビゲーションバー
- 4-2 連絡先
- 4-3 グループチャット
- 4-4 メッセンジャー
- 4-1 非公開チャット
- 5. 通知
- 5-1 つながりリクエスト
- 5-2 チャット
訳注: この少し前あたりの手順から、visit_single_post_spec.rbが失敗しています。翻訳中の検証では
scenario
をxscenario
に変えてひとまずペンディングしています。お気づきの点がありましたら@hachi8833までお知らせください。
Rails5「中級」チュートリアル(4-1-1)インスタントメッセージ: 非公開チャット - 前編: チャット機能の作成(翻訳)
訳注: conversationは原則「チャット」と訳しています。
このセクションの目標は、2人のユーザーが非公開で会話できるチャット機能の作成です。
新しいブランチを切ります。
git checkout -B private_conversation
モデルを名前空間化する
まず、必要なモデルをいくつか定義しましょう。さしあたって2つの異なるモデルが必要です。1つは非公開チャット用、もう1つはプライベートメッセージ用です。モデル名をPrivateConversation
やPrivateMessage
とすることも一応可能ですが、すぐに小さな問題に突き当たるでしょう。すべてがうまく動いていても、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 =
でテーブル名を文字列で指定しなくてはなりません。私と同じようにデータベースのテーブル名をこの方法で指定すると、このモデルは次のようになります(Gist、Gist)。
# 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人のユーザーは非公開チャットを複数行え、チャットには多数のメッセージが含まれます。この関連付けをモデル内で定義しましょう(Gist、Gist、Gist)。
# 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人のユーザーをそれぞれsender
とrecipient
とします。user1
やuser2
のような名前にしようと思えばできますが、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
ディレクトリを作成して、対応するパーシャルファイルを作成します(Gist、Gist)。
<!-- 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
ヘルパーメソッドに対応するパーシャルファイルをその下に作成します(Gist、Gist)。
<!-- 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 %>
今度はPostsController
のshow
アクションを設定しましょう。アクション内に以下を追加します(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パーシャルをレンダリングすることで画面に表示されるようになります。
<!-- 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::ConversationsController
のcreate
アクション内で、チャットを保存できたときの処理に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
ファイルで作成します(Gist、Gist)。
<!-- 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"
- 前回: Rails5「中級」チュートリアル(3-8)投稿機能: 新しい投稿を作成する(翻訳)
- 次回: Rails5「中級」チュートリアル(4-1-2)インスタントメッセージ: 非公開チャット - 中編(翻訳)