Rails7のAction Cableを使ったLINE風のチャット機能の実装

### はじめに

私はプログラミングの初学者です。
自分のサービスを開発する中でチャット機能を作成いたしました。
同じようなところで困っている人や学習のアウトプットのために投稿させていただきます。

### 実現したいこと

RailsでLINEのような非同期のチャット機能ををつけたいです。
付ける場所としては投稿詳細画面のところです。
もしよろしければゲストログインして触ってみてください。
##### 以下実際のアプリURL
[自分、探してます](https://www.sagashitemasu.com/)

### 使用した技術

・Rails７系で開発
・クライアントサイドの実装としてJavaScriptを使用
・データベースはPostgresql
・Bootstrapを使用
・gem device

### 作成手順
非常に分かりやすくまとめてくれている以下の記事を参考にさせていただきました。
以下をご覧になられていることを前提に説明していきます。
https://techtechmedia.com/action-cable-rails6

### 見た目
そこから私が必要に応じて追加じた部分をまとめました。
下記がLINE風の見た目になるviewのところです。
```
<!-- Messages -->
<div class="card-body swiper scrollbar-hover overflow-hidden w-100 pb-0" data-swiper-options='{
  "direction": "vertical",
  "slidesPerView": "auto",
  "freeMode": true,
  "scrollbar": {
  "el": ".swiper-scrollbar"
  },
  "mousewheel": true
}'>
<div class="swiper-wrapper">
  <div class="swiper-slide h-auto">
    <!-- User message -->
    <div data-post-id="<%= @post.id %>" id="messages">
      <% @messages.each do |message| %>
        <% if message.user_id == current_user.id %>
          <!-- Own message -->
          <div class="message-own d-flex align-items-start justify-content-end mb-3" data-user-id="<%= message.user_id %>">
            <div class="pe-2 me-1" style="max-width: 348px;">
              <div class="bg-info bg-gradient text-light p-3 mb-1" style="border-top-left-radius: .5rem; border-bottom-right-radius: .5rem; border-bottom-left-radius: .5rem;"><%= message.content %></div>
              <div class="d-flex justify-content-end align-items-center fs-sm text-muted">
                <%= message.created_at.strftime('%Y年%m月%d日 %H:%M') %>
              </div>
            </div>
          </div>
        <% else %>
          <!-- Message from others -->
          <div class="message-others d-flex align-items-start mb-3" data-user-id="<%= message.user_id %>">
            <%= message.user.username %>
            <div class="ps-2 ms-1" style="max-width: 348px;">
              <div class="bg-secondary p-3 mb-1" style="border-top-right-radius: .5rem; border-bottom-right-radius: .5rem; border-bottom-left-radius: .5rem;"><%= message.content %></div>
              <div class="fs-sm text-muted">
                <%= message.created_at.strftime('%Y年%m月%d日 %H:%M') %>
              </div>
            </div>
          </div>
        <% end %>
      <% end %>
    </div>
  </div>
</div>
<div class="swiper-scrollbar end-0"></div>
</div>
```
自分以外の人のメッセージ
```
<% message = local_assigns[:message] %>
<div class="d-flex align-items-start mb-3">
  <%= message.user.username %>
  <div class="ps-2 ms-1" style="max-width: 348px;">
    <div class="bg-secondary p-3 mb-1" style="border-top-right-radius: .5rem; border-bottom-right-radius: .5rem; border-bottom-left-radius: .5rem;"><%= message.content %></div>
    <div class="fs-sm text-muted"><%= message.created_at.strftime('%Y年%m月%d日 %H:%M') %></div>
  </div>
</div>
```
自分のメッセージ
```
<% message = local_assigns[:message] %>
<div class="d-flex align-items-start justify-content-end mb-3" data-user-id="<%= message.user_id %>">
  <div class="pe-2 me-1" style="max-width: 348px;">
    <div class="bg-info bg-gradient text-light p-3 mb-1" style="border-top-left-radius: .5rem; border-bottom-right-radius: .5rem; border-bottom-left-radius: .5rem;"><%= message.content %></div>
    <div class="d-flex justify-content-end align-items-center fs-sm text-muted">
      <%= message.created_at.strftime('%Y年%m月%d日 %H:%M') %>
    </div>
  </div>
</div>
```

###### 解説：
bootstrapを使用しているのでcard-bodyやswiperのところは適宜自分なりに変更をしてください。
私が躓いたところとしては、Rails7系はturboを使用するのでJSのところが以下のようになります。
```
document.addEventListener("turbo:load", function() {
```

### JSのところ
以下のところは参考記事を見ながら処理の流れを確認してみてください！
おすすめとしては参考記事のチャンネルの流れを確認しながらです！！

app/javascript/application.js
```
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
import * as bootstrap from "bootstrap"
import "./channels"
//= require cable
```
app/javascript/channels/post_channel.js
```
import consumer from "./consumer"

document.addEventListener("turbo:load", () => {
  const messages = document.getElementById('messages');
  if (messages === null) {
    return;
  }
  const currentUserId = document.body.dataset.currentUserId;
  const postId = messages.dataset.postId;
  const appPost = consumer.subscriptions.create({channel: "PostChannel", post_id: postId}, {
    received(data) {
      // 送信者か受信者かを確認するロジックを追加
      let messageHtml;
      if (data.sender_id.toString() === currentUserId) {
        messageHtml = data.message;
      } else {
        messageHtml = data.second_message;
      }
      
      messages.insertAdjacentHTML('beforeend', messageHtml);
      
      setTimeout(function() {
        window.swiper.update(); // 更新
        const swiperContainer = document.querySelector('.swiper');
        if (swiperContainer !== null) {
          swiperContainer.scrollTop = swiperContainer.scrollHeight;
        } else {
          console.log('Error: No swiper container found.');
        }
      }, 100);
    },
    speak: function(message, postId) {
      return this.perform('speak', {message: message, post_id: postId});
    }
  });

  const input = document.getElementById('post_input');
  const button = document.getElementById('submit_button');

  if(input) {
    input.addEventListener("keypress", function(e) {
      if (e.keyCode === 13) {
        appPost.speak(e.target.value, postId);
        e.target.value = '';
        e.preventDefault();
      }
    });
  } else {
    console.error("Could not find element with id 'post_input'");
  }

  if(button) {
    button.addEventListener("click", function(e) {
      if(input.value !== '') {
        appPost.speak(input.value, postId);
        input.value = '';
      }
      e.preventDefault();
    });
  } else {
    console.error("Could not find element with id 'submit_button'");
  }
});

// Popstate event
window.addEventListener('popstate', function(event) {
  setTimeout(function() {
    window.swiper.update();
    const swiperContainer = document.querySelector('.swiper');
    if (swiperContainer) {
      swiperContainer.scrollTop = swiperContainer.scrollHeight;
    }
  }, 100);
});
```

###### 解説：
以下の部分はjavascriptのところでcurrent_userが使えないので独自に考えた処理です。
ここで送信者によって渡すviewを変えています。
```
const appPost = consumer.subscriptions.create({channel: "PostChannel", post_id: postId}, {
received(data) {
  // 送信者か受信者かを確認するロジックを追加
  let messageHtml;
  if (data.sender_id.toString() === currentUserId) {
    messageHtml = data.message;
  } else {
    messageHtml = data.second_message;
  }

```
### message_broadcast_job.rb

app/jobs/message_broadcast_job.rb
```
class MessageBroadcastJob < ApplicationJob
  queue_as :default

  def perform(message)
    Rails.logger.info "Broadcasting message #{message.id}"
    PostChannel.broadcast_to(message.post, message: render_message(message), second_message: render_second_message(message), sender_id: message.user_id)
  end

  private

  def render_message(message)
    ApplicationController.renderer.render(partial: 'messages/own_message', locals: { message: message })
  end

  def render_second_message(message)
    ApplicationController.renderer.render(partial: 'messages/other_message', locals: { message: message })
  end
end
```
###### 解説：
ここでは実際にユーザーに送られるviewを判別しているところになります。

### 参考にしたURL

https://techtechmedia.com/action-cable-rails6