Rails DM機能の作り方

## はじめに
この記事はプログラミング初学者が他の記事を参考にしたり、実際に実装してみたりして、アウトプットの一環としてまとめたものです。内容に不備などあればご指摘いただますと幸いです。<br>
今回Xクローン作成中DM機能を実装しました。
そのDM機能について備忘録も兼ねて記事を作成していきます。

## 実現したいこと
ユーザー同士、1対1でメッセージのやり取りできる機能を実装すること。

## DM機能の流れ
相手ユーザーのツイートorサイドバーのメニューからメッセージ画面に遷移する。
↓
roomがなければ作成する。
↓
メッセージ画面でメッセージを送信する。

このように一般的なDM機能を作成していきます。

最終的にはこのような画面になります。
<img width="700" alt="message.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3628757/cfada2f9-4ec2-4bd8-98f6-ea393724a4f9.gif">

## DM機能について
テーブル設計は以下のようになります。

<img width="700" alt="message_er.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3628757/96d79c9d-841e-4ff2-87ab-337cb9854ebe.png">

まず、usersテーブルはユーザーを管理するテーブルです。
roomsテーブルはチャットルーム(メッセージをやり取りする場所)となります。

ユーザーは複数のroomを持つ可能性があります。また、roomは複数(2人)のユーザーを持ちます。これは多対多の関係になるので、中間テーブルとしてentriesテーブルを置き情報を管理します。

ユーザーは複数のメッセージを送ることができます。また、roomでは複数のメッセージを受け取ることになります。これも多対多の関係になるので、中間テーブルとしてmessagesテーブルを置き情報を管理します。

## モデルとテーブルの作成
それでは、実装していきましょう！
以下のコマンドでモデルを作成します。
```
$ rails g model room
```
```
$ rails g model entry user_id:integer room_id:integer
```
```
$ rails g model message user_id:integer room_id:integer content:text
```
生成された`entry`モデルのマイグレーションファイルを以下のように編集します。
```ruby:db/migrate/○○○_create_entries.rb
class CreateEntries < ActiveRecord::Migration[7.0]
  def change
    create_table :entries do |t|
      t.integer :user_id
      t.integer :room_id

      t.timestamps
    end

    add_index :entries, %i[user_id room_id], unique: true # 追加
  end
end
```
同じルームに同じユーザーが入ることを避けたいので、`add_index`を使って一意制約を記述します。
これで、user_idとroom_idの組み合わせをユニーク(１通り)に設定します。<br>
以下のコマンドでマイグレーションを実行しましょう。
```
$ rails db:migrate
```

## アソシエーションの設定
それぞれのファイルに以下を追加します。
```ruby:app/models/user.rb
has_many :entries, dependent: :destroy
has_many :messages, dependent: :destroy
```
```ruby:app/models/room.rb
has_many :entries, dependent: :destroy
has_many :messages, dependent: :destroy
```
```ruby:app/models/entry.rb
belongs_to :user
belongs_to :room

# 同じルームに同じユーザーが入ることを避けたいので、一意制約を記述します。
# これで、user_idとroom_idの組み合わせをユニーク(１通り)に設定します。
validates :user_id, uniqueness: { scope: :room_id }
```
DB側と同じようにアプリケーション側にも一意制約を記述しています。
<br>
```ruby:app/models/message.rb
belongs_to :user
belongs_to :room

validates :content, presence: true
```

## ルーティングの設定
以下のように記述します。
```ruby:config/routes.rb
resources :users do
  resources :rooms, only: [:create]
end

resources :rooms, only: [:index] do
  resources :messages, only: [:create]
end
```
ネストすることで、`users/:user_id/rooms`と`rooms/:room_id/messages`のようなURLができます。
こうすることで、createアクションで`user_id`は`params[:user_id]`として、`room_id`は`params[:room_id]`として取得できます。

## Controllerの作成
以下のコマンドでコントローラを作成します。
```
$ rails g controller rooms
```
```
$ rails g controller messages
```
生成されたコントローラのファイルを以下のように編集します。
```ruby:app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  def create
    @user = User.find_by(id: params[:user_id]) # チャットする相手を取得
    my_room_ids = current_user.entries.pluck(:room_id) # ログイン中のユーザーの全てのチャットルームIDを取得　
    existing_room = @user.entries.find_by(room_id: my_room_ids) # チャットする相手とのルームがあるか確認

    if existing_room.nil # ルームがない場合
      @room = Room.create # 新しくルームを作成
      @room.entries.create(user_id: current_user.id) # 自分の中間テーブルを作成
      @room.entries.create(user_id: @user.id) # 相手の中間テーブルを作成
    end
    redirect_to rooms_path
  end

  def index
    my_room_ids = current_user.entries.pluck(:room_id) # ログイン中のユーザーの全てのチャットルームIDを取得
    # 相手のエントリー情報を取得
    @another_entries = Entry.where(room_id: my_room_ids)
                            .where.not(user_id: current_user.id)
                            .includes(user: { icon_image_attachment: :blob })

    if params[:tab] == 'room' # 右側のチャットルームでメッセージを表示できるようにする
      @room = Room.find(params[:room_id]) # ルームを取得する
      @messages = @room.messages # ルームに関連したメッセージを取得
      @message = Message.new # form_withに渡すため空のインスタンスを生成
      # 相手のエントリー情報を取得
      @another_entry = @room.entries.where.not(user_id: current_user.id).first
    end
  end
end
```
`create`アクションでは、ログイン中のユーザーと相手ユーザーのroomが存在するか調べ、なければ作成します。

`index`アクションの`if params[:tab] == 'room'`は、`link_to`のクエリパラメータで渡された値を使っています。

```ruby:app/controllers/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])
    @message.save!
    redirect_to request.referer, notice: 'メッセージが送信されました'
  end
end
```

## Viewの作成
チャットルーム一覧の表示、メッセージ一覧の表示、メッセージ投稿フォームの設置をしていきます。
```ruby:app/views/rooms/index.html.slim
div.message-contents
  = render 'layouts/sidebar'

  main.main.border-start.border-end
    div.bar.d-flex.align-items-center
      div.bar-name
        | メッセージ   

    # チャットルーム一覧の表示
    div
      - @another_entries.each do |entry|
        = link_to rooms_path(tab: 'room', room_id: "#{entry.room.id}"), class: "room-link" do # クエリパラメータを渡す
          div.d-flex class="#{'active-room' if params[:room_id] == "#{entry.room.id}"} one-room"
            div
              = image_tag entry.user.icon_image.variant(resize_to_fill: [40, 40]).processed, class: "post-user-img rounded-circle"
            div
              div
                span.room-name
                  = entry.user.name
                span.room-date
                  = entry.room.messages.last&.created_at&.to_fs(:room_datetime_jp) #「ぼっち演算子 &.」レシーバがnilであった場合でもエラーが発生しない
              div.room-content
                span
                  = entry.room.messages.last&.content

  div.message-container.border-end
    -if params[:tab] == 'room' # チャットルーム一覧をクリックでメッセージ一覧が表示される
      div.bar.d-flex.align-items-center.border-bottom
        div
          = image_tag @another_entry.user.icon_image.variant(resize_to_fill: [35, 35]).processed, class: "post-user-img rounded-circle"
        div.bar-room-name
          = @another_entry.user.name

      # メッセージ一覧の表示 
      div.exchanging-messages-container.pt-4
        div.exchanging-messages
          - @messages.each do |message|
            - if message.user == current_user # 自分のメッセージを表示
              div.d-flex.flex-column.align-items-end.mb-3
                div.my-message
                  = simple_format(message.content, class: "simple_format")
                div.message-date
                  = message.created_at.to_fs(:room_datetime_jp)
            - else # 相手のメッセージを表示
              div.d-flex.flex-column.align-items-start.mb-3
                div.another-message
                  = message.content
                div.message-date
                  = message.created_at.to_fs(:room_datetime_jp)

        # メッセージ投稿フォームの設置
        div.message-form-container
          = form_with model: [@room, @message], url: room_messages_path(@room) do |f|
            div.d-flex.align-items-center.justify-content-center
              = f.text_area :content, class: "message-form", placeholder: "新しいメッセージを作成"
              = button_tag type: "submit", class: "form-btn" do
                i.bi.bi-send
```
メッセージ一覧の部分をif文を用いて分岐させることによって、DMっぽく自分のメッセージは右側、相手のメッセージは左側のようにスタイリングすることができます。

form_withを記述する際、ルーティングでネストを定義している時は`[@room, @message]`のように、**配列で二つ ([関連元のインスタンス, 関連先のインスタンス])** 渡す必要があります。

## おわりに
最後まで読んでいただきありがとうございました。
少しでも皆さんの参考になれば幸いです。

## 参考にしたサイト
https://qiita.com/Tohru-f/items/addad421180ccce15ce0

https://qiita.com/kuuuuumiiiii/items/461119ff219e0d462d7b

https://naskanoheya.com/catch-up/sns/