【Rails6.1】同じUser同士じゃない場合のDM機能（課題は残る）

# はじめに
メッセージ（DM機能）を作成した際、私が作成したメッセージ機能の場合、同じユーザ同士ではなく、ShopユーザとMemberユーザという異なるユーザとのやり取りになりました。

この場合のメッセージの表示方法などに何度も混乱させられました。
正直、あまり良いやり方とは自分でも思えないのですが、頑張ったので振り返りのために記録しておこうと思います。


# 前提

もっと簡単な方法があったんじゃないかという気もしている前提条件。

![2024-06-20メッセージ機能1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3762011/bb2a2863-5ab2-9032-4c7c-7ac0dbe2f407.png)
member用のメッセージデータのmember_messagesと
shop用のメッセージデータのshop_messagesを作っています。

※Roomsにあるis_take_careは対応待ちと対応済みの判定を行うためのデータ。
（trueなら対応済みでfalseなら対応待ち）

:::note warn
この形になった流れ…
<b>messagesを作ってmember_idとshop_idを設定すればいいのでは？</b>
↓
どっちのメッセージか分からなくなりそう…
↓
<b>memberのメッセージならmember_idだけ、shopのメッセージならshop_idだけを保存すればいいのでは？</b>
↓
確実に空になるカラムが存在するってどうなんだろうか…
↓
<b>両方のidを保存して、尚且つどっちのメッセージかを記録するカラムを作る？</b>
↓
回りくどい気がする…
↓
<b>いっそmessagesをshop用とmember用に分けるか</b>
:::

という感じです。
これがベストだったのかは正直分かりません…。

ちなみにモデルの関連付けはこのようになっています。

```ruby:モデル
# member.rb
  has_many :member_messages, dependent: :destroy
  has_many :rooms, dependent: :destroy

# shop.rb
  has_many :shop_messages, dependent: :destroy
  has_many :rooms, dependent: :destroy

# member_message.rb
  belongs_to :member
  belongs_to :room

# shop_message.rb
  belongs_to :shop
  belongs_to :room

# room.rb
  has_many :member_messages, dependent: :destroy
  has_many :shop_messages, dependent: :destroy
  belongs_to :member
  belongs_to :shop
```


# 実装内容
ここからは実際の記述内容です。
ほとんど全部のせたので長くなっています。

## コントローラ

### 【Memberユーザ側】
※Memberユーザは一般ユーザになるので「public」としています。
```diff_ruby:public/messages_controller.rb
class Public::MessagesController < ApplicationController
  before_action :authenticate_member!
  before_action :block_non_related_member

  # Shop詳細ページからメッセージを送るのでparamsではshopのidが送られる
  def show
    @shop = Shop.find(params[:id])
    # shop_idとmember_idが一致するroomがないか探して、なければ新しいroomを作り、あれば既存のroomを開く
    room = Room.find_by(member_id: current_member, shop_id: @shop)
    unless room.nil?
      @room = room
    else
      @room = Room.new
      @room.member_id = current_member.id
      @room.shop_id = @shop.id
      @room.save
    end
    # メッセージのやり取りを表示するために、member_messagesとshop_messagesを足してcreate順に並べる
    @messages = (@room.member_messages + @room.shop_messages).sort_by(&:created_at)
    # 送信フォームのための新しいメッセージ作成ボックス
    @message = MemberMessage.new(room_id: @room.id)
  end
  
  # 一覧ではやり取りしているメッセージの中で一番新しいメッセージを表示したい
  def index
    @rooms = Room.where(member_id: current_member)
    @messages = []

    @rooms.each do |room|
    # roomの中からMemberMessageとShopMessageそれぞれの一番新しいメッセージを取得
      new_member_message = MemberMessage.where(room_id: room.id).order(created_at: "DESC").first
      new_shop_message = ShopMessage.where(room_id: room.id).order(created_at: "DESC").first

      # new_member_messageとnew_shop_message両方あるならcreated_atが新しい方を@messagesに入れる
      if new_member_message && new_shop_message
        new_message = [new_member_message, new_shop_message].max_by(&:created_at)
        @messages << new_message
      # new_member_messageだけなら@messagesにnew_member_messageを入れる
      elsif new_member_message
        @messages << new_member_message
      # new_shop_messageだけなら@messagesにnew_shop_messageを入れる
      elsif new_shop_message
        @messages << new_shop_message
      end
      # @messagesのcreated_atを取得し、宇宙船演算子で比較して並び替え
      @messages.sort! { |a, b| b.created_at <=> a.created_at }
    end
  end

  def create
   @message = MemberMessage.new(member_message_params)
   @message.member_id = current_member.id
    if @message.save
      # メッセージが送られたらroomのステータスを対応待ちにするための設定
+     Room.find_by(id: @message.room_id).update(is_take_care: false)
      redirect_to request.referer
    else
      flash[:alert] = "送信に失敗しました"
      redirect_to request.referer
    end
  end

  def destroy
    message = MemberMessage.find(params[:id])
    message.destroy
    redirect_to request.referer
  end

  private

  def member_message_params
    params.require(:member_message).permit(:message, :room_id)
  end

  # 自分以外のメッセージルームは開けないようにするための設定
  def block_non_related_member
    if room = Room.find_by(member_id: current_member, shop_id: @shop)
      redirect_to root_path
    end
  end
end

```

:::note info
<b>max_by</b>
 Ruby の Enumerable モジュールに含まれるメソッドで、ブロックの評価結果に基づいて、コレクションの中から最大の要素を返します。
 具体的には、各要素に対して指定された基準（ブロック内の評価結果）を<b>比較</b>し、その中で<b>最大の評価結果を持つ要素を選びます。</b>
:::

https://docs.ruby-lang.org/ja/latest/class/Enumerable.html#I_MAX_BY

:::note info
<b>sort!</b>
Ruby の配列メソッドで、配列そのものをソートして変更します（破壊的メソッド）。
sort は元の配列を変更せずにソートされた新しい配列を返しますが、sort! は<b>元の配列をソートされた状態に変更</b>します。
:::

https://docs.ruby-lang.org/ja/latest/method/Array/i/sort.html

:::note info
<b><=></b>
今回のようなb.created_at <=> a.created_at の場合、created_at が新しい順に並べ替えます。
具体的には、<b>b の作成日時が a の作成日時より新しければ b が前</b>に来ます。
逆に、<b>a の作成日時が b の作成日時より新しければ a が前</b>に来ます。
:::

### 【Shopユーザ側】
shopユーザ側もほぼ同じ…と思いきや、shopからはメッセージを送れず、メッセージのやり取りを開始できるのはmemberユーザのみという設定にしてしまったがために、showページのコントローラは少し違う記述になっています。

この設定が混乱の原因でした。

```ruby:shop/messages_controller.rb
class Shop::MessagesController < ApplicationController
  before_action :authenticate_shop!
  before_action :block_non_related_shop, only: [:show, :destroy]

  def show
    # shopからはメッセージを送れないので、単純にroomのidを取得（ここが良くない気がする）
    @room = Room.find(params[:id])
    @messages = (@room.member_messages + @room.shop_messages).sort_by(&:created_at)
    @message = ShopMessage.new(room_id: @room.id)
  end

  def index
    @rooms = Room.where(shop_id: current_shop)
    @messages = []

    @rooms.each do |room|
      new_member_message = MemberMessage.where(room_id: room.id).order(created_at: "DESC").first
      new_shop_message = ShopMessage.where(room_id: room.id).order(created_at: "DESC").first

      if new_member_message && new_shop_message
        new_message = [new_member_message, new_shop_message].max_by(&:created_at)
        @messages << new_message
      elsif new_member_message
        @messages << new_member_message
      elsif new_shop_message
        @messages << new_shop_message
      end
      @messages.sort! { |a, b| b.created_at <=> a.created_at }
    end
  end

  def create
    @message = ShopMessage.new(shop_message_params)
    @message.shop_id = current_shop.id
    if @message.save
      redirect_to request.referer
    else
      flash[:alert] = "送信に失敗しました"
      redirect_to request.referer
    end
  end

  # 対応状況（is_take_care）を更新
  def update
    @room = Room.find(params[:id])
    if @room.update(room_params)
      flash[:notice] = "更新しました"
      redirect_to request.referer
    else
    end
  end

  def destroy
    message = ShopMessage.find(params[:id])
    message.destroy
    redirect_to request.referer
  end

  private

  def shop_message_params
    params.require(:shop_message).permit(:message, :room_id)
  end

  def room_params
    params.require(:room).permit(:is_take_care)
  end

  def block_non_related_shop
    room = Room.find(params[:id])
    if (shop_signed_in? && room.shop_id != current_shop.id) || (member_signed_in? && room.member_id != current_member.id)
      redirect_to root_path
    end
  end
end

```
showアクション以外はだいたいmember側と同じ。

ここで問題と感じているのが、
<b>member側</b>のメッセージルームのURLで取得するidが<b>shop.id</b>なのに対し、
<b>shop側</b>のメッセージルームのURLで取得するidが<b>room.id</b>という点です。

希望としては両方ともroom.idとしたかったのですが、member側からは新しいメッセージルームを作る必要があるのでshop.idの取得が必要。

それならいっそ、
shop側のメッセージルームのURLで取得するidをmember.idにした方が分かりやすいかな…？
それで上手くいきそうだったら、そのうち直しておこうと思います。

## ビュー
ビューもほとんど同じなのでshopページだけのせておきます。
レイアウトにはBootstrapをメインに使ってますが、CSSで設定している部分もあるので、Bootstrapと関係ないclass名も混ざっています。

### 【メッセージルーム（showページ）】

![2024-06-20メッセージ機能2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3762011/cee77e12-0aa5-a092-17cb-618d656fe72c.png)

```html:shop/messages/show.html.erb
<div class="container d-flex flex-row-reverse flex-wrap pt-3">
  <div id="messages-top" class="main-content col-9 px-3 pt-2">
    <h2><%= @room.member.member_full_name %> 様とのメッセージ</h2>
    <hr>
      <!--対応状況の変更-->
      <%= form_with model: @room, url: shop_message_path do |f| %>
        <div class="d-flex justify-content-center bg-light p-3 mt-3 mb-3">
          <div class="d-flex flex-row flex-wrap me-3">
            <div class="pe-2">
              <%= f.radio_button :is_take_care, true, class: "form-check-input" %><%= f.label :is_take_care_true, "対応済み", class: "form-check-label text-success fw-bold ps-2" %>
            </div>
            <div class="pe-2">
              <%= f.radio_button :is_take_care, false, class: "form-check-input" %><%= f.label :is_take_care_false, "対応待ち", class: "form-check-label text-danger fw-bold ps-2" %>
            </div>
          </div>
          <div>
            <%= f.submit "変更する", class: "btn btn-info p-2" %>
          </div>
        </div>
      <% end %>

    <div class="col-12">
      <!--メッセージ-->
      <div id="messages-container" class="py-2 messages-container">
        <% @messages.each do |message| %>
          <div class="bg-light p-2">
          <!--メッセージの背景レイアウトを変えたかったのでmessageがShopMessageだったらmy-message、MemberMessageだったらother-messageのclass名を設定-->
            <div class="p-3 <%= (message.is_a?(ShopMessage) ? "my-message" : "" ) %> <%= (message.is_a?(MemberMessage) ? "other-message" : "" ) %>">
              <!--メッセージがShopMessageだった場合の設定-->
              <% unless message.is_a?(ShopMessage) %>
                <span class="px-1 py-0">
                  <%= link_to shop_member_path(message.member) do %>
                    <%= message.member.member_full_name %> 様
                  <% end %>
                </span>
              <% end %>
              <p class="pt-1">
                <%= message.message %>
              </p>
              <small class="text-body-tertiary">(<%= message.created_at.strftime("%Y年%m月%d日 %H:%M:%S") %>)</small>
              <% unless message.is_a?(MemberMessage) %>
                <%= link_to "削除", shop_message_path(message), method: :delete, "data-confirm" => "本当に削除しますか？" %>
              <% end %>
            </div>
          </div>
        <% end %>
      </div>
      <!--送信フォーム-->
      <div class= "py-2">
        <%= form_with model: @message, url: shop_messages_path do |f| %>
          <%= f.text_area :message, class: "form-control" %>
          <div class= "py-2 text-end">
            <%= f.submit "送信", class: "btn btn-info" %>
          </div>
          <%= f.hidden_field :room_id %>
        <% end %>
      </div>
    </div>

  </div>
  <!--左ナビ-->
  <div class="left-content col-3 px-3">
    <%= render 'layouts/shop_menu' %>
  </div>
</div>
```

:::note info
<b>is_a?</b>
Ruby のオブジェクト指向プログラミングにおいて、オブジェクトが特定のクラスまたはモジュールのインスタンスであるかどうかをチェックするために使用されます。

今回の場合、message.is_a?(ShopMessage)では、<b>message オブジェクトが ShopMessage クラスのインスタンスであるかどうか</b>をチェックします。
:::

https://docs.ruby-lang.org/ja/latest/method/Object/i/is_a=3f.html


### 【メッセージ一覧（indexページ）】

![2024-06-20メッセージ機能3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3762011/6aa2ba6c-42e0-62da-a600-8b538f7095fa.png)

```html:shop/messages/index.html.erb
<div class="container d-flex flex-row-reverse flex-wrap pt-3">
  <div class="main-content col-9 px-3">
    <h2>新着メッセージ</h2>
    <hr>
    <% messages.each do |message| %>
      <!--メッセージルーム詳細ページのリンク…id="messages-top"の位置を開きたかったのでanchorを指定-->
      <%= link_to shop_message_path(message.room_id, anchor: 'messages-top') do %>
        <div class="d-flex flex-row my-3">
          <div class="col-1">
            <% if message.room.is_take_care %>
              <span class="text-success">
                対応済み
              </span>
            <% else %>
              <span class="text-danger">
                対応待ち
              </span>
            <% end %>
          </div>
          <div class="col-11 d-flex flex-row flex-wrap p-3 <%= (message.is_a?(ShopMessage) ? "my-message" : "" ) %> <%= (message.is_a?(MemberMessage) ? "other-message" : "" ) %>">
            <div class="col-9">
              <% if message.is_a?(MemberMessage) %>
                <%= message.member.member_full_name %> 様 [ <%= message.member.id %> ]
              <% elsif message.is_a?(ShopMessage) %>
                <%= message.room.member.member_full_name %> 様 [ <%= message.room.member.id %> ]
              <% end %>
              <p class="pt-1"><%= message.message %></p>
            </div>
            <div class="col-3">
              <small class="text-secondary"><%= message.created_at.strftime("%Y年%m月%d %H:%M:%S") %></small>
            </div>
          </div>
        </div>
      <% end %>
    <% end %>
    <% if @messages.blank? %>
      <p>メッセージはありません</p>
    <% end %>

  </div>
  <!--左ナビ-->
  <div class="left-content col-3 px-3">
    <%= render 'layouts/shop_menu' %>
  </div>
</div>
```

:::note info
<b>anchor</b>
ページ内の特定の場所へジャンプするために使う機能です。
Ruby on Rails の link_to ヘルパーで anchor オプションを使うと、リンクをクリックしたときにページ内の<b>特定の位置に移動するリンク</b>を作ることができます。
:::



## おまけ…レイアウトとか

DM機能と言えば、画面の下に送信フォームがあり、メッセージのやり取りも下から新着順に並ぶのが一般的ではないかと思います。
ここからは、その表示のために設定した記述の内容です。

```css:
.my-message {
    background-color: #CCE4E4;
    width: 85%;
    margin-left: auto;
    border-radius: 15px 15px 2px 15px;
}

.other-message {
    background-color: #EAEAEA;
    width: 85%;
    margin-right: auto;
    border-radius: 15px 15px 15px 2px;
}

// スクロールバーを設置するため
.messages-container {
    max-height: 500px;
    overflow-y: scroll;
}
```
:::note info
<b>overflow-y: scroll;</b>
CSS のプロパティで、要素の垂直方向のコンテンツが要素の高さを超える場合に、スクロールバーを表示するように指示します。
このプロパティを使用すると、コンテンツが要素の領域内に収まらないとき、<b>垂直スクロールバー</b>が常に表示されます。
:::


## javascript
scroll_messages.jsを作成しました。

```javascript:javascript/packs/scroll_messages.js
// Turbolinksによるページロードが完了したときに特定のコードを実行
document.addEventListener('turbolinks:load', function() {
// 指定した時間（300ミリ秒）後に特定の関数を実行するためのタイマーを設定
  setTimeout(function() {
    // messages-containerのHTML要素を取得
    var messagesContainer = document.getElementById('messages-container');
    if (messagesContainer) {
    // messages-container要素のスクロール位置を、その要素内のコンテンツの高さに設定
      messagesContainer.scrollTop = messagesContainer.scrollHeight;
    }
  },300);
});
```
:::note info
<b>scrollTop = scrollHeight</b>
要素内のスクロール位置がコンテンツの最下部に移動します。
:::

# おわりに
こうして振り返ってみるとそんなに悪くないような気もしてきました。
記述している最中はなんでこうなるんだー！と頭を抱えることも多かったですが、こうして冷静に見てみると、あの時うまくいかなかったのは当然だったな…というミスが思い浮かびます。

作成中はけっこう冷静になれないことも多くなりがちなのかなと感じたので、分からなくなったときは落ち着いて内容をまとめるべきだなと感じました。焦りは禁物です。