はじめに
こんにちは!!今回はRailsを使用してDM、フォロー、いいね、コメント
をされた時に通知が表示されるようにしていきます。通知機能があるだけで一気にSNSっぽくなりますよね。
必ずしも全ての機能がなくてもコメントだけ、いいねだけにすることもできますのでぜひ一緒にやってみましょう!!
<追加>
非同期通信の削除機能
完成イメージ
開発環境、前提条件
- AWS Cloud9
- Ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [x86_64-linux]
- Rails 6.1.7.3
- Deviseのインストールが完了していて、ユーザーの新規登録やログインができる状態
- BootStrapがインストール済み
- FontAwesomeがインストール済み
- 投稿機能を実装済み(この記事では、Postモデル)
- コメント機能を実装済み(この記事では、Commentモデル)
- いいね機能を実装済み(この記事では、Likeモデル)
- フォロー機能を実装済み(この記事では、Relationshipモデル)
- メッセージ機能を実装ずみ(この記事では、Chat,Roomモデル)
通知モデルの概要
テーブルの情報としては、以下のようになります。
visitor_id | visited_id | post_id | comment_id | action | checked |
---|---|---|---|---|---|
1 | 2 | nil | nil | follow | false |
1 | 2 | 3 | nil | like | false |
1 | 2 | nil | 4 | comment | false |
- visitor_id : 通知を送ったユーザーのid
- visited_id : 通知を送られたユーザーのid
- post_id : いいねされた投稿のid
- comment_id : 投稿へのコメントのid
- action : 通知の種類(フォロー、いいね、コメント、DM)
- checked : 通知を送られたユーザーが通知を確認したかどうか
では、やっていきましょう!!
通知モデル作成
$ rails g model Notification
出来上がったマイグレーションファイルを以下のように編集しましょう。
class CreateNotifications < ActiveRecord::Migration[6.1]
def change
create_table :notifications do |t|
t.integer :visitor_id, null: false
t.integer :visited_id, null: false
t.integer :comment_id
t.integer :room_id
t.integer :chat_id
t.integer :post_id
t.string :action, default: '', null: false
t.boolean :checked, default: false, null: false
t.timestamps
end
#検索の高速化の為の記述(add_index)
add_index :notifications, :visitor_id
add_index :notifications, :visited_id
add_index :notifications, :post_id
add_index :notifications, :comment_id
add_index :notifications, :room_id
add_index :notifications, :chat_id
end
end
DBミスがあると修正が大変なのでよく確認しましょう。私は :comment_id にnull: falseを間違えて設定してしまい想定通り動かなく時間を消費してしまいました、、、。
では、いつも通り
$ rails g db:migrate
モデルの関連付け
では作成した通知モデルをUser
、Comment
、Post
、Like
、Chat
と紐付けの作業を行います。
User <-> Notification
ユーザーと通知モデルの紐付け
has_many :active_notifications, class_name: 'Notification', foreign_key: 'visitor_id', dependent: :destroy
has_many :passive_notifications, class_name: 'Notification', foreign_key: 'visited_id', dependent: :destroy
active_notifications:自分からの通知
passive_notifications:相手からの通知
紐付ける名前とクラス名が異なるため、明示的にクラス名とIDを指定して紐付けます。
Post<->Notifications
投稿と通知モデルの紐付けをしていきます。
has_many :notifications, dependent: :destroy
コメントと通知モデルの紐付けをしていきます。
Comment<->Notifications
has_many :notifications, dependent: :destroy
メッセージと通知モデルの紐付けをしていきます。
Chat -> Notifications
has_many :notifications, dependent: :destroy
Notifications->User,Post,Comment,Chat
ここまではDM、ユーザー、投稿、コメントから通知モデルへの関連付けを行いました。
今度は、逆方向の紐付けを行います。
class Notification < ApplicationRecord
default_scope -> { order(created_at: :desc) }
#{optional: true}はnilを許可する
belongs_to :post, optional: true
belongs_to :comment, optional: true
belongs_to :room, optional: true
belongs_to :chat, optional: true
belongs_to :visitor, class_name: 'User', foreign_key: 'visitor_id', optional: true
belongs_to :visited, class_name: 'User', foreign_key: 'visitor_id', optional: true
end
default_scope
では、デフォルトの並び順を「作成日時の降順」で指定しています。
つまり、常に新しい通知からデータを取得することができるということです。
たとえば、Notification.first
を実行すると、一番古い通知ではなく、一番新しい通知が取得できます。
optional => true
の部分は、このアソシエーションが必須ではないことを示しています。つまり、このモデルのインスタンス(レコード)をデータベースに保存するときに、それが実際に post
や comment
などに属していなくても良いということを示しています。
デフォルトでは、Railsの belongs_to
アソシエーションは必須です。
つまり、関連付けられたレコードが存在しなければ、そのレコードは有効とは認められません。これは、データの整合性を保つための重要な特徴です。しかし、全てのシチュエーションでそのような制約が必要なわけではありません。なので、optional: true
を使ってその制約を外すことができます。
この設定がないと、データベースにこのモデルのレコードを保存する前に、それが post
、comment
、room
、chat
のいずれかに属していることを確認するバリデーションが自動的に適用されます。これは、関連するレコードがまだ存在しない場合や、その関連が必須ではない場合には不便です。そのため、そのようなシチュエーションでこの optional: true
を設定すると便利です。
通知作成メソッドを作る
いいね通知の作成メソッド
「いいね」が押されたタイミングで、以下のようなデータを作成します。
visitor_id | visited_id | post_id | comment_id | action | checked |
---|---|---|---|---|---|
いいねした人のid | いいねされた人のid | いいねされた投稿のid | nil | like | false |
visitor_id
については、先ほどユーザーモデルで関連付けをしたため、ここでの紐付けは不要です。
したがって、post_id
、visited_id
、action
の3つを設定してあげましょう。
#いいねの通知メソッド
def create_notification_like!(current_user)
#いいね済みか検証
temp = Notification.where(["visitor_id = ? and visited_id = ? and post_id = ? and action = ?",
current_user.id, user_id, id, 'like'])
#いいねされていない場合に通知レコードを作成
if temp.blank?
notification = current_user.active_notifications.new(
post_id: id,
visited_id: user_id,
action: 'like'
)
#自分の投稿に対するいいねの場合は通知済みとする
if notification.visitor_id == notification.visited_id
notification.checked = true
end
if notification.valid?
notification.save
puts "Notification has been created." # ログ出力
else
puts "Notification is invalid. Error messages: #{notification.errors.full_messages.join(', ')}" # ログ出力
end
else
puts "Notification already exists." # ログ出力
end
end
ここでポイントとなるのが、「いいねされていない場合のみレコードを作成する」という分岐処理が入っているところです。
例えば、いいねボタンを連打した場合、押した数だけ相手に通知がいってしまいます。
これでは、悪意を持ったユーザーがひたすらいいねを連打して迷惑行為をするかもしれません。
そこで、以下の検索処理を事前に行い、レコード存在チェックをします。
# すでに「いいね」されているか検索
temp = Notification.where(["visitor_id = ? and visited_id = ? and post_id = ? and action = ? ", current_user.id, user_id, id, 'like'])
これで、いいねを連続でした場合でも、1度しか相手に通知がいかないようになります。
ちなみに、「?」は「プレースホルダ」と言って、「?」を指定した値で置き換えることができるものです。
SQLインジェクションを防ぐためにも、必須のセキュリティ対策になります。
# 自分の投稿に対するいいねの場合は、通知済みとする
if notification.visitor_id == notification.visited_id
notification.checked = true
end
コメント通知の作成メソッド
同様に、コメント投稿時にも処理を追加していきます。
#コメントの通知メソッド
def create_notification_comment!(current_user, comment_id)
# 自分以外にコメントしている人をすべて取得し、全員に通知を送る
temp_ids = Comment.select(:user_id).where(post_id: id).where.not(user_id: current_user.id).distinct
temp_ids.each do |temp_id|
save_notification_comment!(current_user, comment_id, temp_id['user_id'])
end
# まだ誰もコメントしていない場合は、投稿者に通知を送る
save_notification_comment!(current_user, comment_id, user_id) if temp_ids.blank?
end
def save_notification_comment!(current_user, comment_id, visited_id)
# コメントは複数回することが考えられるため、1つの投稿に複数回通知する
notification = current_user.active_notifications.new(
post_id: id,
comment_id: comment_id,
visited_id: visited_id,
action: 'comment'
)
# 自分の投稿に対するコメントの場合は、通知済みとする
if notification.visitor_id == notification.visited_id
notification.checked = true
end
notification.save if notification.valid?
end
コメントは、1つの投稿に対して複数回コメントを残す場合があるため、通知レコードの存在チェックはしません。
なので、コメントの度に相手に通知がいくようになります。
また、投稿者に対してだけではなく、他にコメントをしている人がいれば、その人にも通知を届けないといけないため、最初に以下の処理を追加しています。
temp_ids = Comment.select(:user_id).where(post_id: id).where.not(user_id: current_user.id).distinct
この処理を箇条書きで説明すると、以下のようになります。
- 投稿にコメントしているユーザーIDのリストを取得する
- 自分のコメントは除外する
- 重複した場合は削除する
なぜ重複した場合に削除するのかというと、取得したユーザーIDの分だけ通知を作成するとき、同じ通知が複数回登録されてしまうことを防ぐためです。
IDが複数取得されるケースというのは、同じ人が同じ投稿に複数回コメントした場合ですね。
また、なぜ自分のコメントを除外するのかというと、「コメントを登録→通知を登録」という順番のため、たった今登録したコメントの通知レコードが、自分に対して作成されてしまうからです。
フォロー通知の作成メソッド
次に、フォロー時の処理を書いていきます。
まずは、通知レコード作成メソッドを作ります。
#フォロー通知
def create_notification_follow!(current_user)
temp = Notification.where(["visitor_id = ? and visited_id = ? and action = ? ",current_user.id, id, 'follow'])
if temp.blank?
notification = current_user.active_notifications.new(
visited_id: id,
action: 'follow'
)
notification.save if notification.valid?
end
end
フォローの場合もいいねのときと同じで、「連続でフォローボタンを押す」ことに備えて、
同じ通知レコードが存在しないときだけ、レコードを作成するようにします。
また、フォローの場合は以下の処理を入れていません。
# 自分の投稿に対するコメントの場合は、通知済みとする
if notification.visitor_id == notification.visited_id
notification.checked = true
end
これは、「自分が自分をフォローすること」はありえないためです。
メッセージ通知の作成メソッド
def create
@chat = current_user.chats.new(chat_params)
@room = @chat.room
@chats = @room.chats
render :validater unless @chat.save
if @chat.save
another_entry = UserRoom.where(room_id: @room.id).where.not(user_id: current_user.id).first
visited_id = another_entry.user_id
notification = current_user.active_notifications.new(
room_id: @room.id,
chat_id: @chat.id,
visited_id: visited_id,
visitor_id: current_user.id,
action: 'dm'
)
if notification.visitor_id == notification.visited_id
notification.checked = true
end
notification.save if notification.valid?
# redirect_to room_path(@chat.room)
else
redirect_back(fallback_location: root_path)
end
end
以下はコードの解説です、
@chat = current_user.chats.new(chat_params): current_user(現在ログインしているユーザー)のチャットメッセージの新しいインスタンスを作成します。chat_paramsは、通常、メッセージの本文やその他のチャットに関するパラメータを含むプライベートメソッドです。
@room = @chat.room
: チャットメッセージが属するルーム(チャットルーム)を取得します。
@chats = @room.chats
: そのルームに存在するすべてのチャットメッセージを取得します。
render :validater unless @chat.save
: チャットメッセージをデータベースに保存しようとします。保存できない場合(例えば、バリデーションエラーが発生した場合)、validaterというビューが表示されます。
if @chat.save
: 既に試みた保存が成功した場合、次のブロックのコードが実行されます。
another_entry = UserRoom.where(room_id: @room.id).where.not(user_id: current_user.id).first
: 現在のユーザー以外の、そのルームに参加しているユーザーを取得します。これは、通知を送るべきユーザーを見つけるためです。
visited_id = another_entry.user_id
: 通知を受け取るべきユーザー(通知の訪問者)のIDを取得します。
notification = current_user.active_notifications.new
( ... )からnotification.save if notification.valid?
: 新しい通知を作成し、現在のユーザーから別のユーザーへDM(ダイレクトメッセージ)として送ります。通知が自分自身へのものである場合、checked = true
に設定します。これは、自分自身に送られる通知は自動的に「既読」状態にするためです。そして、作成した通知が有効であれば(バリデーションをパスすれば)それを保存します。
通知画面一覧の作成
通知コントローラを作っていきましょう
$ rails g controller notifications idnex update
ルーティング設定
resources :notifications, only:[:index, :update, :destroy]
index
とupdate
destroy
アクションを記述しましょう
class NotificationsController < ApplicationController
before_action :authenticate_user!
def index
@notifications = current_user.passive_notifications.includes(:visitor, :comment, :post).page(params[:page]).per(20)
@notification = Notification.find_by(id: params[:id])
@notification = @notification.decorate if @notification.present?
@notifications.update(checked: true)
end
def update
notification = Notification.find(params[:id])
if notification.update(checked: true)
redirect_to notifications_path
end
end
def destroy
@notification = Notification.find(paras[:id])
@notification.destroy
respond_to do |format|
format.js
end
end
end
ややこしいと思うので以下にコードの内容を記載します。
@notifications = current_user.passive_notifications.includes(:visitor, :comment, :post).page(params[:page]).per(20)
: 現在のユーザーが受け取ったすべての通知を取得します。includes(:visitor, :comment, :post)
は、Eager Loadingを用いて、visitor
、comment
、post
の情報も同時に読み込みます。これにより、N+1問題を解消します。最後の.page(params[:page]).per(20)
は、kaminari gemによるページネーションの設定で、一ページに20件の通知を表示します。
@notification = Notification.find_by(id: params[:id])
と@notification = @notification.decorate if @notification.present?
: params[:id]
に基づいて特定の通知を見つけ、存在する場合にはデコレート(表示用にデータを整形)します。
@notifications.update(checked: true)
: 現在のユーザーが受け取ったすべての通知を「既読」(checked: true)
に更新します。
update
アクションを定義します。このアクションは、通常、リソースの更新を行うために使われます。
notification = Notification.find(params[:id])
: params[:id]
に基づいて特定の通知を見つけます。
if notification.update(checked: true)
とredirect_to notifications_path
: その通知を「既読」(checked: true)
に更新し、成功した場合には通知一覧ページにリダイレクトします。
このコントローラーは、ユーザーの通知の一覧表示と更新(既読状態への変更)を行う機能を提供します。indexアクションは、ログインユーザーの通知を一覧表示し、それら全てを既読状態に更新します。updateアクションは、特定の通知を既読状態に更新し、その後通知一覧ページにリダイレクトします。
以上の動作により、ユーザーは通知一覧ページを訪れるときに、新たに受け取った通知をすぐに確認でき、また、特定の通知を既読にすることができます。これにより、ユーザーは自分が受け取った通知を効率的に管理することができます。
通知一覧画面を作ろう
ビューを記述していきましょう。
#自分の投稿に対するいいね、コメントは通知に表示しない
<% notifications = @notifications.where.not(visitor_id: current_user.id) %>
<% if notifications.exists? %>
<%= render notifications %>
<%= paginate notifications.all %>
<% else %>
<p>通知はありません</p>
<% end %>
レンダリングする部分テンプレートも作っていきましょう。
<div class="col-md-6 mx-auto">
<% visitor = notification.visitor %>
<% visited = notification.visited %>
<div class="card mb-1" id="notification_<%= notification.id%>" >
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<%= link_to user_path(visitor) do %>
<% if visitor.profile_picture.attached? %>
<%= image_tag url_for(visitor.profile_picture), size: "30x30", class: 'rounded-circle mr-2' %>
<% else %>
<%= image_tag 'default_profile_picture.png', size: "30x30", class: 'img-fluid rounded-circle mr-2' %>
<% end %>
<strong class="text-dark"><%= visitor.username %></strong>
<% end %>
<span class="ml-2">さんが</span>
</div>
<div class="text-muted small">
<%= notification.decorate.how_long_ago %>
</div>
</div>
<div class="mt-2">
<% case notification.action %>
<% when 'follow' %>
<i class="fas fa-user-plus mr-1"></i>あなたをフォローしました
<% when 'dm' %>
<i class="fas fa-envelope mr-1"></i>あなたに
<%= link_to 'メッセージ', chat_path(notification.visitor_id), class: 'text-dark' %>
を送りました
<% when 'like' %>
<i class="fas fa-heart mr-1"></i>
<%= link_to 'あなたの投稿', notification.post, class: "font-weight-bold text-dark" %>
にいいねしました
<% when 'comment' %>
<i class="fas fa-comments mr-1"></i>
<% if notification.post.user_id == visited.id %>
<%= link_to "あなたの投稿", notification.post, class: "font-weight-bold text-dark" %>
<% else %>
<%= link_to post_path(notification.post), class: 'text-dark' do %>
<%#= image_tag avatar_url(notification.post.user).to_s, class: "icon_mini" %>
<strong><%= notification.post.user.username + 'さんの投稿' %></strong>
<% end %>
<% end %>
にコメントしました
<p class="text-muted mb-0">
<%#= Comment.find_by(id: notification.comment_id)&.comment %>
</p>
<% end %>
</div>
<small class="mt-1">
<%= form_with url: notification_path(notification), method: :delete, local: false do |f| %>
<%= f.submit "削除" %>
<% end %>
</small>
</div>
</div>
</div>
以下に簡単にコードの説明をします。
<% visitor = notification.visitor %>
と<% visited = notification.visited %>
: 通知の発行者(visitor)と受信者(visited)をそれぞれローカル変数に格納します。
<% case notification.action %>から<% end %>
: notification.action
の値によって、表示するメッセージを変更します。follow
なら「あなたをフォローしました」、dm
なら「あなたにメッセージを送りました」、like
なら「あなたの投稿にいいねしました」、comment
なら「あなたの投稿にコメントしました」または「他のユーザーの投稿にコメントしました」を表示します。
<div class="small text-muted text-right">
から
<%= notification.decorate.how_long_ago %>
: 通知が作成されてからどのくらいの時間が経過したかを表示します。
destroy.js.erbを作成
app/views/notificationsにdestroy.js.erb
を新規作成
$("#notification_<%= @notification.id %>").remove();
以上で実装の完了です。これからは実際にアプリ上で試してみましょう!!
長丁場お付き合い頂きありがとうございます!
コメント、いいねお待ちしてます!!