🔔

コメント通知機能 (投稿者にのみ通知)

2023/04/24に公開

作成するポートフォリオに通知機能を実装します。
通知機能とは、新規のコメント、いいね、フォローに対してすぐに気が付くことができるよう、投稿者に通知が届く機能です。

投稿者本人以外のユーザーに対し「あなたがコメント(いいね、フォロー)した投稿に〇〇さんもコメント(いいね、フォロー)しました」という通知を送ることも可能です。

今回実装する機能は、
 ・コメントに対する通知(いいね、フォローは対象外)
 ・投稿者本人にのみ通知が届く

それでは、以下よりご覧ください。


通知方法:

通知方法で考えたのは次の2通りです。
① マイページやヘッダーに新規コメントの通知を表示する(未読数やマークにより)
② メール機能を実装し、新規コメントがあるとメールが届くようにする

ここで友人に意見を聞いたところ、「不要なメルマガが多いので、メールはあまり確認していない。」という意見が多かったので、上記 ① のみ適用しました。
マイページ上に通知マークを表示し、未読コメント数が表示されるように実装しました。

目的:

新規コメントは漏れなく確認し、投稿に対しての修正点や補足情報があれば随時投稿内容を更新できるようにするため。

実装詳細:

・ 新規コメント = 未読コメントの数をマイページ上で表示する。
・ マイページから、通知確認一覧画面へ遷移できるようにする。
・ 通知確認一覧画面から詳細画面へ遷移できるようにする。
・ 通知詳細画面から、投稿編集画面へ遷移できるようにする。(すぐに投稿編集が可能)
・ 通知詳細画面を開いた時点で、未読が既読に変わる。
・ 後から再度確認したいコメントは、既読から未読に戻せるようにする。

通知機能の実装に必要なモデルとコントローラを確認

Notificationモデル
カラム
 boolean :checked, default: false (true=既読, false=未読)
 boolean :recheck, default: false (未読/既読の切り替え)
notificationsコントローラ

◆ モデル

  • Postモデル (作成済)

  • Commentモデル (作成済)

  • Notifocationモデル

    $ rails g model Notification
    
    id : integer
    integer :visitor_id コメントしたユーザー
    integer :visited_id コメントされた(投稿元)ユーザー
    integer :comment_id
    integer :post_id
    boolean :checked, default: false  #通知を確認したかどうか
    boolean :recheck, default: false  #未読/既読
    

    ユーザーは visitor_idvisited_id に分かれています。
     visitor_id:コメントしたユーザー
     visited_id:投稿したユーザー

ユーザーの特定は、 notification.comment_idnotification.post_id と紐付けることでも「この通知はどのコメントに対して?どの投稿に対して?」という点からコメントしたユーザー、投稿したユーザーを特定することができます。

アソシエーション

Member.rb
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 = 他ユーザーから自分への通知

Post.rb
has_many :notifications, dependent: :destroy
comment.rb
belongs_to :member
belongs_to :post
has_one :notification, dependent: :destroy

一つのコメントに通知は一度だけ。なので has_many ではなく has_one と記述します。

Notification.rb
default_scope->{order(created_at: :desc)}
belongs_to :post
belongs_to :comment
belongs_to :visitor, class_name: 'Member', foreign_key: 'visitor_id'
belongs_to :visited, class_name: 'Member', foreign_key: 'visited_id'

default_scope
 ⇒ デフォルトの並び順を降順(:desc)に設定しています。
optional: true
 ⇒ belongs_toでアソシエートされるモデルの外部キー “○○_id” にnilを許可する記述です。

"optional: true" て何??

optional: trueとは、外部キーのIDがnilであることを許可するものです。

上記を例に考えましょう。

Notificationモデルにアソシエーションを記述する際、belongs_to :post とすると、実は自動的にallow_nil: false という設定が付随します。
これは、通知に対してnotification.post_id という外部キー(ID)が無い場合にはデータベースに保存されないということです。言い換えると、 “通知に対して投稿のIDが必須” = “どの投稿に対する通知なのか特定される必要がある” ということです。

もし post_idが不要。post_idがなくてもデータベースに保存したい! という場合には、optional: true を追記することで可能となります。

belongs_to :post    (allow_nil: false が指定されている状態なので、post_id必須)
belongs_to :comment, optional: true   (comment_id が nil であることを許可されている状態)

しかし、外部キーが無い場合ってどんな場合でしょうか…

◆ 通知機能のメソッド

post.rb
#投稿者にのみ通知を送る
def create_notification_comment!(current_member, comment_id)
  save_notification_comment!(current_member, comment_id, member_id)
end

def save_notification_comment!(current_member, comment_id, visited_id)
  #コメントは複数回することが考えられるため、1つの投稿に複数回通知する
  notification = current_member.active_notifications.new(
    post_id: id,
    comment_id: comment_id,
    visited_id: visited_id,
  )
  #自分の投稿に対するコメントの場合は、通知済みとする
  if notification.visitor_id == notification.visited_id
    notification.checked = true
  else
    notification.checked = false
  end
  notification.save if notification.valid?
end

上記のように、Postモデルに2つのメソッドを定義しました。
コメントを通知する機能を実装するためのメソッド です。
create_notification_comment!(current_member, comment_id) 通知を作成
save_notification_comment!(current_member, comment_id, member_id) 作成された通知を保存

次に、このメソッドをコントローラで読み込む必要があります。
commentsコントローラのcreateアクションに通知を作成する記述を追加します。

comment_controller.rb
def create
  @post = Post.find(params[:post_id])
  @comment = Comment.new(comment_params)
  @comment.member_id = current_member.id
  @comment.post_id = @post.id
  @comment.save
  @comments = @post.comments.order(created_at: :desc).page(params[:page]).per(10)
  @post.create_notification_comment!(current_member, @comment.id) #追記
end

>> なぜPostモデルに??

コメントを通知する機能を実装するためのメソッドをPostモデルに定義しました。
なぜPostモデルに? なぜCommentモデルじゃないの??

⇒ 通知を作成するためには、メソッドを定義するモデルがその通知に関連する情報を全て含んでいる必要があります。コメント通知機能の場合、関連する情報とは 投稿、コメント、ユーザー(誰に通知を送るか) といった情報です。
Commentモデルに記述できないことではありませんが、関連する全ての情報を含んだモデルがPostモデルであったため、ここに定義しています。

◆ コントローラ

$ rails g controller notifications
notifications_controller.rb
def index
  if params[:filter] == "unchecked"
    @notifications = current_member.passive_notifications.where(checked: false).order(created_at: :desc).page(params[:page]).per(10)
  else
    @notifications = current_member.passive_notifications.order(created_at: :desc).page(params[:page]).per(10)
  end
end

def show
  @notification = Notification.find(params[:id])
  @comment = @notification.comment
  @post = @notification.post
  @notification.update(checked: true) #詳細画面を開くと通知が既読になる
end

◆ ルーティング

resources :notifications, only: [:index, :show]

◆ ビュー

少しコードが長いので、トグルに格納しています。

notifications/index (通知一覧画面)
notifications/index
<div class="container my-3">
  <div class="row">
    <div class="col-md-10 mx-auto">

      <div class="card">
        <div class="card-header">
          <h3 class="title m-3"><i class="far fa-comment-dots"></i> 
            "<%= current_member.nickname %>" さんへのコメント 
            <small>(未読は<%= @notifications.where(checked: false).count %>件です)</small>
          </h3>
          <div class="text-right">
            <%= link_to "全部", notifications_path, class: "btn btn-sm btn-outline-success" %>
            <%= link_to "未読", notifications_path(filter: "unchecked"), class: "btn btn-sm btn-outline-success" %>
          </div>
          <div class="text-right">
            <small><i class="fas fa-check" style="color:red"></i>は未読です)</small>
          </div>
        </div>
        <div class="card-body">
          <!--通知が存在する場合-->
          <% if @notifications.exists? %>
            <% @notifications.each do |notification| %>
              <% if notification.visited_id == current_member.id %>
                <div class="row align-bottom">
                  <div class="col-md-8 col-12">
                    <%= link_to member_path(notification.comment.member), class: 'link' do %>
                      <%= image_tag notification.comment.member.get_image, size: '30x30', style: 'border-radius: 50%' %> 
                      <%= notification.comment.member.nickname %>
                    <% end %>
                    さんから
                    <%= link_to post_path(notification.post), class: 'link' do %>
                      "<b><%= notification.post.name.truncate(15) %></b>"
                    <% end %>
                    に対してコメントがあります。
                  </div>
                  <div class="col-md-2 col-9 text-right"><small><%= l notification.created_at %></small></div>
                  <div class="col-md-1 col-1">
                    <%= link_to notification_path(notification), class: 'link' do %>
                      <i class="far fa-comment-dots"></i>
                    <% end %>
                  </div>
                  <div class="col-md-1 col-1">
                    <%= link_to recheck_notification_path(notification), method: :put, class: 'link' do %>
                      <i class="fas fa-check" style="color: <%= notification.checked? ? 'gray' : 'red' %>;"></i>
                    <% end %>
                  </div>
                </div>
                    
                <div class="row pl-5 border-bottom align-bottom">
                  『 <%= notification.comment.comment.truncate(20) %> 』
                </div>
              <% end %>
            <% end %>
            <div class="row justify-content-center my-2">
              <%= paginate @notifications %>
            </div>
          <!--通知が存在しない場合-->
          <% else %>
            <p>現在、未読のコメントはありません</p>
          <% end %>
        </div>
      </div>
    </div>
  </div>
</div>
notifications/show (通知詳細画面)
notifications/show
<div class="container my-3">
  <div class="row">
    <div class="col-md-10 mx-auto">
      <div class="row">
        <div class="col">
          <h2 class="title m-3"><i class="far fa-comment-dots"></i>コメント確認</h2>
        </div>
      </div>

      <!--投稿施設詳細-->
      <div class="row">
        <div class="col-md-10 mx-auto my-3">
          <div class="place-info border p-3">
            <div class="row border-bottom mb-3">
              <div class="col-md-3 col-4"><p>名称:</p></div>
              <div class="col-md-9 col-8"><p><%= @post.name %></p></div>
            </div>
            <div class="row my-3">
              <div class="col-md-3 col-4">施設カテゴリー:</div>
              <div class="col-md-9 col-8"><%= @post.category.name %></div>
            </div>
            <div class="row my-3">
              <div class="col-md-3 col-4">一言紹介:</div>
              <div class="col-md-9 col-8"><%= @post.introduction %></div>
            </div>
            <div class="row my-3">
              <div class="col-md-3 col-4">詳細:</div>
              <div class="col-md-9 col-8"><%= safe_join(@post.information.split("\n"),tag(:br)) %></div>
            </div>
            <div class="row my-3">
              <div class="col-md-3 col-4">タグ:</div>
              <div class="col-md-9 col-8"><%= @post.tags.pluck(:name).join(' / ') %></div>
            </div>
            <div class="row my-3">
              <div class="col-md-3 col-4">施設住所:</div>
              <div class="col-md-9 col-8"><%= @post.address %></div>
            </div>
            <div class="row mt-3">
              <div class="col-md-3 col-4">投稿日:</div>
              <div class="col-md-9 col-8"><%= l @post.created_at, format: :time %></div>
            </div>
            <div class="row mb-3">
              <div class="col-md-3 col-4">更新日:</div>
              <div class="col-md-9 col-8"><%= l @post.updated_at, format: :time %></div>
            </div>
          </div>
        </div>
      </div>
        
      <!--コメント-->
      <div class="row">
        <div class="col-md-10 mx-auto">
          <div class="card">
            <div class="card-header">
              必要あれば施設情報の更新をお願いします
              <!--編集ボタン-->
              <%= link_to edit_post_path(@post), class: ' link col-md-1' do %>
                <i class="fas fa-edit"></i>
              <% end %>
            </div>
            <div class="card-body">
              <div class="row align-items-center">
                <div class="col-md-3 col-12">
                  <%= image_tag @comment.member.get_image, size: '30x30', style: 'border-radius: 50%' %>
                  <%= @comment.member.nickname %>
                </div>
                <div class="col-md-6 col-9"><%= safe_join(@comment.comment.split("\n"),tag(:br)) %></div>
                <div class="col-md-3 col-3"><small><%= l @comment.created_at, format: :time %></small></div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div class="row"></div>
    </div>
  </div>
</div>

◆ 未読/既読を切り替えよう

通知の既読を未読に戻したいのはどんな時?
→ 投稿の修正が必要だけど、今すぐ編集する時間がない。
 通知が既読だと後でこのコメントを探すのが大変なので、
 再確認しやすいように未読に戻しておきたい。

ルーティング

recheckを追加します。

resources :notifications, only: [:index, :show] do
  put :recheck, on: :member
end

コントローラ

recheckアクションを追加します。

notifications_controller.rb
#未読/既読の切り替え
def recheck
  @notification = Notification.find(params[:id])
  @notification.update(checked: !@notification.checked)
  redirect_to request.referer
end

checked: !@notification.checkedという記述は、
!@notification.checkedの結果をcheckedに代入します。

頭についている“ ! “はなんでしょうか?

例えば、比較演算子において ” == ” は左右が等しいことを表します。
 先頭に “ ! “ を付けて ” !== “ と記述することで左右が等しくないことを表します。(等しいを否定)
 → つまり先頭に “ ! “ を付けることで、その後の値を否定する結果となります。

では!@notification.checkedは?

  • 通知が未読の場合、
     @notification.checkedfalse なので
     !@notification.checkedtrue
  • 通知が既読の場合、
     @notification.checkedtrue なので
     !@notification.checkedfalse

recheckアクションにより切り替える仕組み

  • 既読を未読にする
    既読:checked true
    !@notification.checked! が否定しているのでfalse
    recheckアクションにより@notification.update(checked: !@notification.checked)
    truefalseに更新されることで、既読が未読に切り替わります。
  • 未読を既読にする
    未読:checked false
    !@notification.checked! が否定しているのでtrue
    recheckアクションにより@notification.update(checked: !@notification.checked)
    falsetrueに更新されることで、未読が既読に切り替わります。

ビュー

notifications/index.html.erb
<div class="col-md-1">
  <%= link_to recheck_notification_path(notification), method: :put, class: 'link' do %>
    <i class="fas fa-check" style="color: <%= notification.checked? ? 'gray' : 'red' %>;"></i>
  <% end %>
</div>

style="color: <%= notification.checked? ? 'gray' : 'red' %>;
三項演算子。チェックの色を切り替える記述。
if文で書くと以下にようになります。

<% if notification.checked %>
  style="color: 'gray';"
<% else %>
  style="color: 'red';"
<% end %>

以上です。

Discussion