メッセージ機能の実装

この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。

## 記事投稿の背景
Xのクローンサイトを制作している時にメッセージ機能の実装で色々と躓いたので、知識整理も兼ねて記事として残すことにしました。

## 実装時のER図
![スクリーンショット 2024-10-26 18.20.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3508918/1445a2ae-a6b1-349f-f3dd-da9bc90e8845.png)
UserモデルとRoomモデルの間に二つの中間テーブルを作りました。
Roomモデルはチャットルーム(メッセージをやり取りする場所)となります。
※チャットルームについて以降はルームと表現します。

Entrieモデルの役割：どのユーザー(user_id)がどのルーム(room_id)に居るのかを保存する

Messageモデルの役割：どのユーザー(user_id)がどのルーム(room_id)でどんな発言(content)をしたかを保存する

## 実装したコードと解説
```ruby:routes.rb
  resources :rooms, only: %i[index show create] do
    resources :messages, only: %i[new create]
  end
```
RoomモデルとMessageモデルは関連があるので、routes.rbにもその関連性を落とし込みました。
これで`room/room_id/message`のようなURLができます。
この場合、rooms_controllerのcreateアクションではヘルパーメソッドが表示されません。
![スクリーンショット 2024-10-27 8.47.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3508918/02df51c4-9186-a918-d90f-806f97e950ac.png)
これは何故かというとindexアクションでrooms_pathというヘルパーメソッドを使用しており、これはcreateメソッドにも当てがわれるヘルパーメソッドと重複しているからです。重複しているかどうかはヘルパーメソッド横に表示されているPathを見れば分かります。createアクションとindexアクションのPathが全く同じになっています。
![スクリーンショット 2024-10-27 16.33.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3508918/4a8bb80e-9bf1-b385-8f74-d9025ea9c9fe.png)

createアクションではヘルパーメソッドが表示されていませんが、indexアクションと同じrooms_pathというヘルパーメソッドを使用することはできます。HTTPメソッドがGET(index)とPOST(create)で異なるので、同じヘルパーメソッド名を使ってもそれぞれ正しく動作させることができます。

```ruby:room.rb
class Room < ApplicationRecord
  has_many :entries, dependent: :destroy
  has_many :users, through: :entries
  has_many :messages, dependent: :destroy

  validate :room_user_limit

  def room_user_limit
    return unless entries.size >= 2

    errors.add(:room, 'このチャットルームには既に２人のユーザーが参加しています。')
  end
end
```
`has_many :users, through: :entries`という記述を入れることで`room.users`といった簡潔な書き方ができるようになります。
無い場合は`room.entries...`といった冗長な書き方になってしまいます。
ルームはログインユーザーとその他のユーザー1人の合計2人で使用する想定なので、ルームの人数が2人を超える場合はバリデーションで阻止する実装にしています。

```ruby:user.rbの抜粋
  has_many :entries, dependent: :destroy
  has_many :rooms, through: :entries
  has_many :messages, dependent: :destroy
```
こちらも`has_many :rooms, through: :entries`の記述を入れることで`user.rooms`といった簡潔な書き方を実現できるようにしています。
```ruby:entrie.rb
class Entrie < ApplicationRecord
  belongs_to :user
  belongs_to :room

  validates :user_id, uniqueness: { scope: :room_id } 
end
```
`validates :user_id, uniqueness: { scope: :room_id }`の記述を入れることで`user_id`と`room_id`の組み合わせに一意性を持たせることができます。同じルームに同じユーザーの組み合わせが重複して入ることを防ぎます。
```ruby:message.rb
class Message < ApplicationRecord
  belongs_to :user
  belongs_to :room

  validates :content, presence: true
end
```
`content`はメッセージの内容です。空のメッセージは送付できないようにしています。
```ruby:rooms_controller.rb
class RoomsController < ApplicationController
  # 誰がどのルームに居るかを管理する
  def create
    @room = Room.create

    @entrie = @room.entries.build(user_id: params[:user_id])
    @entrie_login_user = @room.entries.build(user_id: current_user.id)

    if @entrie.save && @entrie_login_user.save
      redirect_to room_path(@room), notice: 'チャットルームに入りました。'
    else
      redirect_to home_index_path, alert: 'チャットルームの作成に失敗しました。'
    end
  end

  def index
    @rooms = Room.all.includes(:users, :messages)
  end

  def show
    @room = Room.find(params[:id])
    @messages = @room.messages.includes(:user)
    @message = Message.new
  end
end
```
`create`アクションではどのユーザーがどのルームに入るのかを実装しています。
新規でチャットルームを作る場合、`@room`インスタンス変数を先に作ってルームを決めて、
そこに紐づく形でアソシエーションを使いながら、ルームに入るユーザーを結びつけます。
`show`アクションでは後述の`show.html.slim`に記述していますが、メッセージ投稿フォームを`form_with`ヘルパーを使って実装しています。`form_with`ヘルパーに空のインスタンス(`@room`)を渡すことでメッセージ投稿用の`create`アクションが存在することを認識させます。

```ruby:messages_controller.rb
class MessagesController < ApplicationController
  # 誰がどのルームでどんな発言をしたかを管理する

  def create
    @room = Room.find(params[:room_id])
    @message = @room.messages.build(user_id: current_user.id, content: params[:message][:content])

    if @message.save
      redirect_to room_path(@room), notice: 'メッセージが送信されました。'
    else
      redirect_to room_path(@room), alert: 'メッセージの送信に失敗しました。'
    end
  end
end
```
rooms_controllerのshowアクションにあった空の`@message`を認識させることで、`form_with`の機能としてmessages_controllerのcreateアクションへ繋がるようにしています。
```ruby:index.html.slim チャットルームの一覧を表示
body
  = render partial: "layouts/nav"

  .message-box
    .entrance-rooms
      h2
        | メッセージ
      br

      - @rooms.each do |room|
        - if current_user.present?
          - if room.entries.exists?(user_id: current_user.id)
            - other_user = room.users.reject { |user| user == current_user }.first
            p = link_to other_user.name, room_path(room)
            - if room.messages.present? && room.messages.first.content.present?
              p = truncate(room.messages.first.content, length: 20)
            hr
```
![スクリーンショット 2024-10-27 17.47.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3508918/7f63d681-5b36-de08-240c-437a081dae9b.png)

```ruby:show.html.slim メッセージ一覧を表示、メッセージ投稿フォームの設置
body
  = render partial: "layouts/nav"

  .message-box
    h2
      = link_to image_tag("arrow_back.png", alt: "Arrow back", size: "35x35"), rooms_path
      - opponent = @room.users.reject { |user| user == current_user }.first
      = "#{opponent.name}さんとのメッセージ"
    br

    - @messages.each do|message|
      - if message.user == current_user
        .box1
          p.login_user = message.content
      - else
        .box2
          p.the_other_user = message.content
      br


    .message-room
      = form_with model: @message, url: room_messages_path(@room), local: true, data: { turbo: false } do |f|
        ul
          li
            = f.text_field :content, placeholder: '新しいメッセージを作成', class: "message-form form-control", style: "border: none"
          li
            = f.submit '送信', class: 'btn btn-primary'
```
![スクリーンショット 2024-10-27 17.51.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3508918/55038a75-9dab-483b-e9cb-ed64369225d8.png)
CSSでメッセージの表示について相手を左側の黒地、ログインユーザーを右側の青地にしています。
```ruby:index.html.slim ホーム画面の一部を抜粋
.tab-content
  div class="#{'show active' if params[:tab] == 'recommend'} tab-pane fade " id="for-you"
    - @tweets.each do |tweet|
      = link_to tweet_path(tweet.id) do
        - if tweet.retweets.exists?(user: current_user)
          ul
            li
              = image_tag('repost.png', size: '20x20', class: 'titled_repost')
              object = link_to 'あなたがリポストしました', user_path(current_user.id)
        ul
          li
            object = link_to image_tag(tweet.user.icon, alt: "Icon image", class: "icon_image", size: '50x50'), profile_path(tweet.user.id)
          li 
            object = link_to tweet.user.name, profile_path(tweet.user.id), class: 'link'
          li 投稿日： #{tweet.created_at.strftime('%Y/%m/%d %H:%M:%S')}

          - if current_user.present? # ツイートのユーザーとログインユーザーが共に所属するチャットルームが存在するかをデータベースから検索する
            - room_with_tweet_user = check_rooms(tweet.user)

          - if current_user.present? && tweet.user.name.include?(current_user.name)
            li.dropdown
              | ... 
          - elsif current_user.present? && tweet.user.followers.pluck(:user_id).include?(current_user.id)
            li.dropdown
              | ... 
              ul.sub-list
                li.sub
                  object
                    = link_to "#{tweet.user.name}さんのフォローを解除する", unfollow_user_path(tweet.user), data: {turbo_method: :delete}
                    br
                    - if room_with_tweet_user.present? # 既に２人のチャットルームがある場合
                      = link_to "#{tweet.user.name}さんにメッセージを送る", room_path(room_with_tweet_user)
                    - else # ２人のチャットルームが無くて作成が必要な場合
                      = link_to "#{tweet.user.name}さんにメッセージを送る", rooms_path(user_id: tweet.user.id), data: {turbo_method: :post}
          - elsif current_user.present? && !tweet.user.followers.pluck(:user_id).include?(current_user.id)
            li.dropdown
              | ... 
              ul.sub-list
                li.sub
                  object
                    = link_to "#{tweet.user.name}さんをフォロー", follow_user_path(tweet.user), data: {turbo_method: :post}
                    br
                    - if room_with_tweet_user.present? # 既に２人のチャットルームがある場合
                      = link_to "#{tweet.user.name}さんにメッセージを送る", room_path(room_with_tweet_user)
                    - else # ２人のチャットルームが無くて作成が必要な場合
                      = link_to "#{tweet.user.name}さんにメッセージを送る", rooms_path(user_id: tweet.user.id), data: {turbo_method: :post}
        p = tweet.content 
        - if tweet.images.attached?
          - tweet.images.each do |tweet_image|
            = image_tag tweet_image, size: '250x200', class: "tweet_image"
      end
      .icons
        .comment
          = link_to image_tag('comment.png', size: '26x26', class: "balloon"), tweet_path(tweet.id)
          = tweet.comments.length
        .repost
          - if current_user.present? && tweet.retweets.exists?(user: current_user)
            = link_to image_tag('repost.png', size: '26x26', class: "circled_arrow"), tweet_retweet_path(tweet_id: tweet.id, id: tweet.retweets.find_by(user: current_user).id), data: {turbo_method: :delete}
            = tweet.retweets.length
          - else
            = link_to image_tag('repost.png', size: '26x26', class: "circled_arrow"), tweet_retweets_path(tweet_id: tweet.id), data: {turbo_method: :post}
            = tweet.retweets.length
        .favorite
          - if current_user.present? && tweet.favorites.exists?(user: current_user)
            = link_to image_tag('heart.png', size: '32x32'), tweet_favorite_path(tweet_id: tweet.id, id: tweet.favorites.find_by(user: current_user).id), data: {turbo_method: :delete}
            = tweet.favorites.length
          - else
            = link_to image_tag('heart_with_hole.png', size: '32x32'), tweet_favorites_path(tweet_id: tweet.id), data: {turbo_method: :post}
            = tweet.favorites.length
        .bookmark
          - if current_user.present? && tweet.bookmarks.exists?(user: current_user)
            = link_to image_tag('bookmarked.png', size: '20x20'), tweet_bookmark_path(tweet_id: tweet.id, id: tweet.bookmarks.find_by(user: current_user).id), data: {turbo_method: :delete}
          - else
            = link_to image_tag('not_bookmark.png', size: '20x20'), tweet_bookmarks_path(tweet_id: tweet.id), data: {turbo_method: :post}
      hr
    = paginate @tweets, :param_name => 'recommend', params: {tab: 'recommend'}, theme: 'twitter-bootstrap-4'
```
`room_with_tweet_user = check_rooms(tweet.user)`の所でログインユーザーと投稿者が入っているルームがあるかどうか判別しています。ロジック詳細はrooms_helperに切り出しました。当初はそれぞれのviewファイル(合計5ファイル,12箇所くらい)に書いていましたが、ちょっと変更を加えたい時も該当箇所が多くて大変でした。rooms_helperに切り出してからは変更は一箇所で済むようになりました。

```ruby:rooms_helper.rb
module RoomsHelper
  def check_rooms(tweet_user)
    Room.joins(:entries).where(entries: { user_id: [current_user.id,
                                                    tweet_user.id] }).group('rooms.id').having('COUNT(entries.id) = 2').first
  end
end
```
ここではviewから切り出したロジックを実装しています。ログインユーザーと投稿者が一緒に入っているルームがあるかどうかを判別しています。
- `Room.joins(:entries)`ではRoomモデルに対してEntrieモデルを内部結合(INNER JOIN)しています。これによりEntrieモデルを介してRoomに所属するユーザー情報を取得できるようになります。INNER JOINなので、room_id同士で結合して合致したデータのみ(ルームに入っているユーザーのみ)を取得します。
- `.where(entries: { user_id: [current_user.id, tweet_user.id] })`ではEntrieテーブルのユーザーIDがログインユーザーと投稿者の両方を持つデータを取得します。これによりログインユーザーと投稿者が同じルームに居る可能性があるルームを絞り込めます。
- `.group('rooms.id')`ではルームのidごとにグループ化します。これにより、同じルームに対してユーザーが複数居る場合にそれぞれのエントリーをまとめて扱うことができます。
- `.having('COUNT(entries.id) = 2')`ではentriesの数が２つであるルームを絞り込みます。entriesが２つということは2人という意味。これにより、同じルームに2人のユーザーが居る(ログインユーザーと投稿者)がいるという条件を満たすルームだけに絞り込むことができます。
- `.first`該当する最初の１つのルームを取得します。ルームは1対1であることが期待されるため、該当するルームがあれば１つのみ返されます。1対1はルームと特定の2人のユーザーのことを指します。１つのルームには特定のユーザー2人が対となることを表します。この時点でルームを取得出来なければ、2人の間ではまだルームが作成されていないことになりますので、新規ルームを作成する流れになります。

## 今回参考にした記事
[[Rails] DM機能を解説する](https://qiita.com/aaaasahi_17/items/9e7f344488c720aaf116)
[Rails DM機能の実装](https://qiita.com/kuuuuumiiiii/items/461119ff219e0d462d7b)
[やさしい図解で学ぶ　中間テーブル　多対多　概念編](https://qiita.com/ramuneru/items/db43589551dd0c00fef9)
[【Rails/一意性制約】モデルのバリデーションをコンソールで確認する方法/複数カラムへの一意性制約](https://qiita.com/saitok7/items/b2b84316146d23000133)
[【Rails入門】helperの使い方まとめ](https://www.sejuku.net/blog/28563)
[Rails Viewに〇〇ロジックは書かない (※後日追記)](https://qiita.com/pank24ever/items/f4982a7141c0e7fc19d7)
[Viewにビジネスロジックは書かない](https://qiita.com/sumin/items/24689d0a5622f000a71f)

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