🙆

利用者管理アプリ開発:チャット機能の実装

2025/01/15に公開

はじめに

今回は、面談記録の詳細ページに実装されているチャット機能について解説していきます!

前回の記事はこちら↓
https://zenn.dev/kenberu1200/articles/24f2dbd76d1e43

コントローラーの実装:メッセージ表示

メッセージをデータベースから取得してフロントエンドの渡す仕組みを実装していきます。

コントローラーとリクエストの作成

以下のコマンドを用いてメッセージのコントローラーを生成します。

php artisan make:controller MessageController

MeetingLogControllerのShowメソッドの修正

面談記録のコントローラーであるMeetingLogControllershowメソッドを以下のように書き加えます。

app/Http/Controllers/MeetingLogController.php
public function show(MeetingLog $meetingLog)
{
+    $messages = Message::where('meeting_logs_id', $meetingLog->id)
+        ->latest()
+        ->paginate(10);

        return inertia('MeetingLog/Show', [
        'meetingLog' => new MeetingLogResource($meetingLog),
+        'messages' => MessageResource::collection($messages),
    ]);
}

アクセスした詳細ページを同じmeeting_logs_idを持つメッセージを、最新のものから10件ずつページ分割して取得します。

そして、messagesとしてフロントエンド側にデータを渡しています。

コントローラーの実装:メッセージの保存

メッセージの保存機能を実装していきます。

MeetingLogコントローラーのstoreメソッドを以下のように書き換えます。

app/Http/Controllers/MessageController.php
public function store(StoreMessageRequest $request)
{
    $data = $request->validated();
    $data['sender_id'] = Auth::user()->id;
    $message = Message::create($data);

    SocketMessage::dispatch($message);

    return new MessageResource($message);
}

まずvalidated()で、送られてきたデータをバリデーションします。

次に、現在ログインしているユーザーのIDを送信者IDとしてデータの中に加えます。

さらに、create()メソッドを用いて、データベースに登録します。

その後、dispatch()を用いて、メッセージが送信されたことをイベント側に通知する。

最後に、メッセージの内容をフロントエンド側に返して、メッセージが作成されたことを伝えます。

コントローラーの実装:メッセージの削除

メッセージの削除機能を実装していきます。

MeetingLogコントローラーのdestroyメソッドを以下のように書き換えます。

app/Http/Controllers/MessageController.php
public function destroy(Message $message)
{
    if ($message->sender_id !== Auth::user()->id) {
        return response()->json(['message' => 'Forbidden'], 403);
    }

    $message->delete();

    return response('', 204);
}

まず、メッセージ送信者のIDがログインしているユーザーのIDと一致するか確認します。

一致しない場合、つまり送信者がログインしているアカウントではない場合、エラーコード403をフロントエンド側に返します。

一致している場合は、delete()メソッドで削除処理を行い、コード204をフロントエンド側に返します。

執筆段階では、削除機能の実装完了していませんが、今後実装予定です。

リソースの実装

続いて、リソースを実装していきます。

リソースの作成

以下のコマンドを用いて、リソースファイルを生成していきます。

php artisan make:resource MessageResource

メッセージリソースの実装

メッセージのリソースは以下のように実装します。

app\Http\Resources\MessageResource.php
class MessageResource extends JsonResource
{
    public static $wrap = false;
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'message' => $this->message,
            'sender' => new UserResource($this->sender),
            'meeting_logs_id' => $this->meeting_logs_id,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

基本的には、$thisを用いてMessageモデルの各カラムの値を参照しています。

senderは送信者のIDをもとに該当する送信者の情報をUserResourceに沿ったデータの形にして参照しています。

イベントの実装

チャットにメッセージを送信したときに、ページ更新を挟むことなく、チャットを開いているユーザー全員にメッセージを反映するために、イベントを実装していきます。

イベント

イベントの実装のためにlaravel broadcastlaravel reverbを利用していきます。
▼公式ドキュメント:ブロードキャスト
https://readouble.com/laravel/11.x/ja/broadcasting.html#authorizing-channels
▼公式ドキュメント:リバーブ
https://readouble.com/laravel/11.x/ja/reverb.html

イベントファイルの生成

以下のコマンドを用いて、イベントファイルを生成します。

php artisan make:event SocketMessage

イベントのReverbサーバーへの送信設定

メッセージが送信されたとき、すなわちstoreメソッド内でSocketMeesagedispatchしたときに、他ユーザーにもリアルタイムにメッセージが送信したいです。

そのために、ShouldBroadcastShouldBroadcastNowインターフェースを継承する必要があります。

app/Events/SocketMessage.php
class SocketMessage implements ShouldBroadcastNow
{

}

今回は、直ちにメッセージを反映したいので、ShouldBroadcastNowを継承します。

メッセージが送信されたときに実行されるイベントの実装

ShouldBroadcastNowインターフェースを継承した場合、broadcastOnメソッドを定義する必要があります。

broadcastOnメソッドでは、発生したイベントがどのチャンネルに送信するかを記述していきます。

app/Events/SocketMessage.php
public function broadcastOn(): array
{
    $m = $this->message;
    $channels = [];

    if ($m->meeting_logs_id) {
        $channels[] = new PrivateChannel('message.meetinglog.' . $m->meeting_logs_id);
    }

    return $channels;
}

今回は、各面談記録に対して、チャット機能がついているので、メッセージデータ内のmeeting_logs_idを使って、チャンネルを面談記録ごとに分けています。

また、認証されたユーザーにのみチャンネルを許可したいので、PrivateChannelクラスを使っていきます。

なお、誰でも書き込めるようなオープンチャットにしたい場合は、Channelクラスを使います。

チャンネルの許可

PrivateChannelを用いた場合、routes/channels.phpで実際に特定のチャンネルをリッスンできることを認証する必要があります。

routes/channels.php
Broadcast::channel('message.meetinglog.{meetinglogId}', function(User $user) {
    return $user ? $user : null;
});

今回は、ログインできるユーザーであれば誰でもチャットに参加できるようにしたいので、ユーザーデータが存在するかどうかの確認のみ行っています。

送信データの形を指定するときの実装

配信されるデータをより細かく制御したいときは、broadcastWithメソッド用いて、設定していきます。

app/Events/SocketMessage.php
public function broadcastWith(): array
{
    return [
        'message' => new MessageResource($this->message),
    ];
}

今回は、MessageResourceでメッセージデータの形を作っているので、それを利用していきます。

リクエストの実装

まず、以下のコマンドを用いてリクエストファイルを生成します。

php artisan make:request StoreMessageRequest

MessageControllerstoreメソッドでバリデーションを行っているので、生成したStoreMessageRequest.phpにて、バリデーションルールを設定していきます。

app/Http/Requests/StoreMessageRequest.php
class StoreMessageRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            "message" => "required|string",
            "meeting_logs_id" => "required",
        ];
    }
}

メッセージの内容は文字列型かつ必須である必要があり、メッセージと面談記録を紐付けるmeeting_logs_idは必須なのでrequiredにしています。

ルーティング

最後に、ルーティングを設定して、バックエンド周りはほぼ実装完了です。

Route::middleware(['auth', 'verified'])->group(function () {

    Route::post('/message',[MessageController::class, 'store'])
        ->name('message.store');

    Route::delete('message/{message}',[MessageController::class, 'destroy'])
        ->name('message.destroy');

    Route::get('/message/older/{message}',[MessageController::class, 'loadOlder'])
        ->name('message.loadOlder');
});

チャット機能の表示部分の実装

続いて、フロントエンド部分の実装に入っていきます。

Show.jsxの編集

チャットと入力欄が表示できるように以下のように実装します。

https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Pages/MeetingLog/Show.jsx

以下、部分ごとに解説していきます。
なおMessgeItemMessageInputコンポーネントは後ほど解説します。

ここでは、useRefを用いて、メッセージ表示部分のDOM要素を取得しています。

const messagesCtrRef = useRef(null);

そしてuseEffectを用いて、ページがレンダリングされるたびに、scrollTopの値をscrollHeightの値に書き換えて、スクロールバーが一番下に来るようにしています。

useEffect(() => {
  setTimeout(() => {
    if (messagesCtrRef.current) {
      messagesCtrRef.current.scrollTop = messagesCtrRef.current.scrollHeight;
    }
  }, 10);
  //// 省略 ////
}, [meetingLog]);

さらに、メッセージが更新されるたびに、メッセージデータを更新して最新のメッセージを反映したいので、useEffectを追加します。

resources/js/Pages/MeetingLog/Show.jsx
export default function Show({ auth, meetingLog, messages }) {
  const [localMessages, setLocalMessages] = useState([]);
  ////省略////
  useEffect(() => {
    setLocalMessages(messages ? messages.data.reverse() : []);
  }, [messages]);
}

useEffectを用いることで、messagesの値が更新されるたびに、再レンダリングをしてlocalMessagesを更新するようにしています。

再レンダリングの際、取得されるメッセージデータは最新のデータから降順で並んでします。

しかし、メッセージは、新しいものを一番下に表示して、上に遡るほどに古いものにしたいので、messages.data.reverse()することで、昇順にしています。

続いて、return部分の解説をしていきます。

 <div>
  <label className="font-bold text-lg">チャット</label>
  <div className="mt-1 whitespace-pre-wrap">
    <>
      <div
        ref={messagesCtrRef}
        className="flex-1 overflow-y-auto p-5 max-h-[400px]"
      >
        {/* {messages} */}
        {localMessages.length === 0 && (
          <div className="flex justify-center items-center h-full">
            <div className="text-lg text-gray-500">
              メッセージがありません
            </div>
          </div>
        )}
        {localMessages.length > 0 && (
          <div className="flex-1 flex flex-col">
            <div ref={loadMoreIntersect}></div>
            {localMessages.map((message) => (
              <MessageItem
                key={message.id}
                message={message}
              />
            ))}
          </div>
        )}
      </div>
      <MessageInput meetingLogId={meetingLog.id} />
    </>
  </div>
</div>


DOM要素を記録しているref、すなわちmessageCtrRefloadMoreIntersectは次回の記事で解説予定です。

チャット欄の構成は、ラベル・メッセージ表示部分・チャット入力部分に分かれています。

<div>
 <label className="font-bold text-lg">チャット</label>
  <div className="mt-1 whitespace-pre-wrap">
    <>
      <div
        ref={messagesCtrRef}
        className="flex-1 overflow-y-auto p-5 max-h-[400px]"
      >
        {/* {messages} */}
      </div>
      <MessageInput meetingLogId={meetingLog.id} />
    </>
  </div>
</div>

チャットの表示部分は、表示するメッセージがある場合は表示をし、無い場合は「メッセージがありません」と表示されるようになっています。
メッセージの表示は、MessageItemコンポーネントを作成して用いています。

 {localMessages.length === 0 && (
  <div className="flex justify-center items-center h-full">
    <div className="text-lg text-gray-500">
      メッセージがありません
    </div>
  </div>
)}
{localMessages.length > 0 && (
  <div className="flex-1 flex flex-col">
    <div ref={loadMoreIntersect}></div>
    {localMessages.map((message) => (
      <MessageItem
        key={message.id}
        message={message}
      />
    ))}
  </div>
)}

UserAvatarコンポーネントの実装

ユーザーのアイコンを表示するためのコンポーネントを作ります。

https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Components/Message/UserAvatar.jsx

user.avatarに値が存在する場合は、画像URLを参照して、アイコン画像を表示します。

ない場合は、アカウント名の頭文字を用いてアイコン画像の代わりにしています。

cssの書き方は公式ドキュメントを参考にしました。
https://daisyui.com/components/avatar/

MessageItemコンポーネントの実装

各メッセージを表示するためのコンポーネントを作ります。

https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Components/Message/MessageItem.jsx

こちらも、公式ドキュメントを参考にしています。
https://daisyui.com/components/chat/

そのメッセージの送信者が現在ページを開いているユーザーである場合は、吹き出しを右側(chat-start)に、そうでない場合は、吹き出しを左側(chat-end)に出すようにしています。

MessageInputコンポーネントの実装

メッセージ入力のコンポーネントを作ります。
https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Components/Message/MessageInput.jsx

NewMessageInputコンポーネントには、メッセージの内容であるnewMessageと送信処理をするonSendClick、入力したテキストを反映するsetNewMessageを渡しています。

送信ボタンを押すと、onSendClick関数を呼び出して、送信処理を行います。

messageSendingの値がtrueの場合、ローディングを表すアイコンを表示するようにしています。

const [newMessage, setNewMessage] = useState("");
const [inputErrorMessage, setInputErrorMessage] = useState("");
const [messageSending, setMessageSending] = useState(false);

// meetingLogIdがオブジェクト型だったので配列型に直している
const id = Object.values(meetingLogId)

const onSendClick = () => {
  if (messageSending) {
    return;
  }

  if (newMessage.trim() === "") {
    setInputErrorMessage("メッセージを入力してください");
    setTimeout(() => {
      setInputErrorMessage("");
    }, 3000);
    return;
  }
  const formData = new FormData();
  formData.append("message", newMessage);
  formData.append("meeting_logs_id", id[0]);

  setMessageSending(true);

  axios.post(route("message.store"), formData, {
    onUploadProgress: (progressEvent) => {
      const progress = Math.round(
        (progressEvent.loaded / progressEvent.total) * 100
      );
      console.log("progress", progress);
    }
  }).then((response) => {
    setNewMessage("");
    setMessageSending(false);
  }).catch((error) => {
    setMessageSending(false);
  });
}


useStateを用いて、以下の3つの変数を定義していきます。

変数名 説明
newMessage メッセージ内容を書くのする変数
inputErrorMessage 入力時のエラー内容を格納する変数
messageSending メッセージが送信中かどうかを表す変数

次に、送信ボタンが押されたときの処理であるonSendClickを実装していきます。

まず、メッセージが送信中である場合は、処理を終了します。

次に、newMessage.trim()で、入力した文字列の両端の空白文字を削除した値が何もなければ、エラーメッセージを3秒間表示します。

また、formDataインターフェースを用いて、メッセージを保存するためのフォームデータを作っていきます。

以下のサイトによるとFormDataとは、

FormData インターフェイスは、フォームフィールドおよびそれらの値から表現されるキーと値のペアのセットを簡単に構築する手段を提供します。

https://developer.mozilla.org/ja/docs/Web/API/FormData

append()メソッドを用いて、キーとデータのペアを作っていきます。

そして、axiosライブラリを用いて、バックエンドにPOSTリクエスト送信していきます。

バックエンド側から正常なレスポンスが返されたら、入力したテキストを初期化して、messageSendingfalseにします。

NewMessageInputコンポーネントの実装

メッセージ入力のテキスト入力欄のコンポーネントです。

https://github.com/kenberu-dev/laravel11-member-manager/blob/develop/resources/js/Components/Message/MessageInput.jsx

まず、表示部分はデフォルトで1行の<textarea>を使っています。

onKeyDownでなにかキーが押されたときにonInputKeyDown関数を呼び出しています。

onInputKeyDownは、エンターキーとシフトキー以外のキーが押された場合、ev.preventDefaultで他のイベントストップさせた上で、onSend、すなわちMessageInput.jsxonSendClickを実行して、メッセージを送信しています。
https://developer.mozilla.org/ja/docs/Web/API/Event/preventDefault

onChangeonChangeEvent関数を呼び出して、入力値が変わった場合、adjustHeightを呼び出しています。

adjustHeightは、textareaタグの高さの設定をautoにした上で、要素の高さscrollHeightに1足した値を返しています。

これにより、Shift+Enterを押した場合と、入力欄をはみ出した場合、改行がなされ、テキスト入力欄が1行分拡張されます。

動作確認

一通り実装できたと思うので、http://localhost:8000/meetinglogにアクセスして、適当な面談記録を開いてみます。

すると以下のような出力になりました。

どうやら、TailwindCSS内のDaisyUIが機能していないみたいです。

DaisyUIの有効化

公式ドキュメントによると、TailwindCSSの設定ファイルであるtailwind.config.jsを開いて、DaisyUIをインポートする必要があるみたいです。
▼公式ドキュメント
https://daisyui.com/docs/install/

インポートするには、以下のように記述する必要があります。

tailwind.config.js
/** @type {import('tailwindcss').Config} */
 export default {
-    plugins: [forms],
+    plugins: [forms, require('daisyui')],
 };

再度、http://localhost:8000/meetinglogにアクセスして、適当な面談記録を開いてみます。

ちゃんとメッセージが出力されています。

リアルタイムチャットの実装

リアルタイムにチャットがチャットを送信したユーザーとページ開いているユーザーに反映されるように、機能を実装していきます。

HandleInertiaRequests.phpの編集

自分の作成した面談記録のチャットチャンネルは、常にリッスンしておきたいです。

なので、どのページにいても、作成した面談記録の情報を取得できる状態にしておきたいです。

この状態を実現するために、Inertiajsのデータ共有機能を使っていきます。
https://inertiajs.com/shared-data

実装するには、app/Http/Middleware/HandleInertiaRequests.phpを以下のように書き換えます。

app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
    return [
        ...parent::share($request),
        'auth' => [
            'user' => $request->user(),
        ],
        'conversations' => Auth::id() ? MeetingLog::getMeetingLogsForUser() : [],
    ];
}

後で説明しますがgetMeetingLogsForUserメソッドは、ログインしているユーザーが作成した面談記録の一覧を配列として取得するメソッドです。

こうすることで、どのページからでもconversationsにアクセスして、自身の作成した面談記録のデータを取得することができます。

MeetingLog.phpの編集

上述した該当面談記録を取得するgetMeetingLogsForUserの実装は、MeetingLogモデルに記述していきます。

app/Models/MeetingLog.php
public static function getMeetingLogsForUser()
{
    $userId = Auth::user()->id;
    $query = MeetingLog::where("user_id", "=", $userId);

    return $query->get()->toArray();
}

ログインしているユーザーIDを取得して、idを基にユーザーが作成した面談記録を取得し、配列型で返しています。

AuthenticatedLayout.jsxにイベントリスナーを追加

上記で作成したデータの面談記録IDをフロントエンドから取得して、それを基にリッスンするチャンネルを判別していきます。

HandleInertiaRequests.phpで設定したデータにアクセスするには、usePageフック使います。

さらに、Echoライブラリのprivateメソッドを利用して、上記で設定したプライベートチャンネルにアクセスします。

resources/js/Layouts/AuthenticatedLayout.jsx
export default function AuthenticatedLayout({ header, children }) {
    const page = usePage();
    const user = page.props.auth.user;
    const conversations = page.props.conversations;

    useEffect(() => {
      conversations.forEach((conversation) => {
        let channel = [];
        if (user.id === conversation.user_id) {
          channel = `message.meetinglog.${conversation.id}`
        }
        Echo.private(channel)
            .error((error) => {
              console.error(error);
            })
            .listen("SocketMessage", (e) => {
              console.log("SocketMessage", e);
              const message = e.message;
            });
      });

      return () => {
        conversations.forEach((conversation) => {
          let channel = `message.meetinglog.${conversation.id}`;
          Echo.leave(channel);
        });
      }
    },[conversations]);
}

useEffectを用いて、conversationsが更新されるごとに再レンダリングされるようにします。

useEffect(() => {

},[conversations]);

次に、取得した配列データであるconversationsのIDとログインしているユーザーのIDが同じ場合、接続するchannelを設定して、privateメソッドでチャンネルに接続します。

conversations.forEach((conversation) => {
  let channel = [];
  if (user.id === conversation.user_id) {
    channel = `message.meetinglog.${conversation.id}`
  }
});

Echo.privateメソッドでチャンネルに接続します。

Echo.private(channel)
    .error((error) => {
      console.error(error);
    })
    .listen("SocketMessage", (e) => {
      const message = e.message;
    });

さらに、listenメソッドを用いて、SocketMessageイベントをリッスンします。

リッスンしているチャンネルにメッセージが送信されたら、messageにメッセージ内容が取得されます。

チャンネルを離れるときは、leaveメソッドを用いて、チャンネルを離れます。

return () => {
  conversations.forEach((conversation) => {
    let channel = `message.meetinglog.${conversation.id}`;
    Echo.leave(channel);
  });

なお、Echoライブラリは、resources/js/echo.jsでインポートされ、インスタンス化されています。

echo.jsのソースコード
resources/js/echo.js
import Echo from 'laravel-echo';

import Pusher from 'pusher-js';
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

Show.jsxにイベントリスナーを追加

今回のアプリでは、自身の作成した面談記録と現在開いているページの面談記録のチャンネルをリッスンできるようにしたいと考えています。

AuthenticatedLayout.jsxでは、作成した面談記録のチャンネルにアクセスできるようにしたので、各面談記録の詳細画面では、その面談記録のチャンネルにアクセスできるようにしていきたいです。

なので、AuthenticatedLayout.jsxと同様の実装をShow.jsxにも施していきます。

resources/js/Pages/MeetingLog/Show.jsx
export default function Show({ auth, meetingLog, messages }) {
  ////省略////
  const page = usePage();
  const conversations = page.props.conversations;

  useEffect(() => {
    let channel = `message.meetinglog.${meetingLog.id}`;

    conversations.forEach((conversation) => {
      if (channel === `message.meetinglog.${conversation.id}`) {
        channel = [];
        return;
      }
    });

    if (channel.length != 0) {
      Echo.private(channel)
      .error((error) => {
        console.error(error);
      })
      .listen("SocketMessage", (e) => {
        console.log("SocketMessage", e);
        const message = e.message;

        if (message.sender_id === auth.id) {
          return;
        }
      });

      return () => {
        let channel = `message.meetinglog.${meetingLog.id}`;
        Echo.leave(channel);
      }
    }
  }, [meetingLog]);
  ////省略////
}

AuthenticatedLayout.jsxの実装とほぼ同じです。

ただし、自分が作成した面談記録のページにアクセスした場合、2重にチャンネルへアクセスするのを防ぐために、conversationsのIDと比較して、同じIDが見つかった場合は、channelの値を空にします。

そして、channelに値が入っている場合のみ、チャンネルに接続するようにしています。

EventBusの実装

ここまでで、リアルタイム送受信を実装することができました。

しかし、このままではメッセージの内容をページへリアルタイムに反映することができません。

EventBus.jsxを実装することでそれを実現していきます。

EventBus.jsxの実装

メッセージをページへリアルタイムに反映するために、on/emit関数を実装します。

そして、それらの関数をAuthenticatedLayout.jsxShow.jsxの異なるレイヤーのコンポーネントに反映する必要があります。

これを実現するために、Contextを用いていきます。

Contextとは、通常Propsをバケツリレーして異なるレイヤーのコンポーネントへ渡さなければならないような情報を、任意のコンポーネントがダイレクトに情報を受け取れる仕組みです。
https://ja.react.dev/learn/passing-data-deeply-with-context

それでは、EventBus.jsxを実装していきます。

resources\js\EventBus.jsx
import React from "react";

export const EventBusContext = React.createContext();

export const EventBusProvider = ({ children }) => {
  const [events, setEvents] = React.useState({});

  const emit = (name, data) => {
    if (events[name]) {
      for (let cb of events[name]) {
        cb(data);
      }
    }
  }

  const on = (name, cb) => {
    if(!events[name]) {
      events[name] = [];
    }

    events[name].push(cb);

    return () => {
      events[name] = events[name].filter((callback) => callback !== cb);
    }
  }

  return (
    <EventBusContext.Provider value={{emit, on}}>
      { children }
    </EventBusContext.Provider>
  );
}

export const useEventBus = () => {
  return React.useContext(EventBusContext);
}


まず、Contextの利用を宣言するために、createContextメソッドを用います。
https://ja.react.dev/reference/react/createContext

後程、アプリ全体にEventBusを反映するために、app.jsxをラップするEventBusProviderを作ります。
https://ja.react.dev/reference/react/createContext#provider

このEventBusProviderの戻り値に、EventBusContext.Providerchildrenをラップしたものを設定します。

このとき、emit/on関数をpropsとして渡します。

こうすることで、ラップされたchildrenすなわちapp.jsx以降の子コンポーネントのどこからでもemit/on関数を呼び出すことができるようになりました。

emit/on関数はそれぞれ以下のような役割があります。

  • on
    • event[name].push(cb)events配列にnameをキー/cbを値として保存する。
    • cb以外のコールバックをevent[name]に代入して返す。
  • emit
    • event[name]に値が存在している場合、すべてのコールバック関数をdataを引数として実行する。

app.jsxの編集

アプリ全体にEventBusコンポーネントを適用するために、AppコンポーネントをEventBusProviderでラップします。

resources/js/app.jsx
createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) => resolvePageComponent(`./Pages/${name}.jsx`, import.meta.glob('./Pages/**/*.jsx')),
    setup({ el, App, props }) {
        const root = createRoot(el);

        root.render(
+          <EventBusProvider>
            <App {...props} />
+          </EventBusProvider>
        );
    },
    progress: {
        color: '#4B5563',
    },
});

Show.jsxの編集

Show.jsxon/emitとコールバック関数を実装していきます。

ソースコード
resources/js/Pages/MeetingLog/Show.jsx
export default function Show({ auth, meetingLog, messages }) {
  ////省略////
+  const { on, emit } = useEventBus();

+  const messageCreated = (message) => {
+    if (meetingLog && meetingLog.id == message.meeting_logs_id) {
+      setLocalMessages((prevMessages) => [...prevMessages, message]);
+    }
+  }

  useEffect(() => {
    let channel = `message.meetinglog.${meetingLog.id}`;

    conversations.forEach((conversation) => {
      if (channel === `message.meetinglog.${conversation.id}`) {
        channel = [];
        return;
      }
    });

    if (channel.length != 0) {
      Echo.private(channel)
      .error((error) => {
        console.error(error);
      })
      .listen("SocketMessage", (e) => {
        console.log("SocketMessage", e);

+        const message = e.message;
+        emit("message.created", message);
+        });
      });

      return () => {
        let channel = `message.meetinglog.${meetingLog.id}`;
        Echo.leave(channel);
      }
    }
  }, [meetingLog]);

  useEffect(() => {
    setTimeout(() => {
      if (messagesCtrRef.current) {
        messagesCtrRef.current.scrollTop = messagesCtrRef.current.scrollHeight;
      }
    }, 10);

+    const offCreated = on('message.created', messageCreated);

+    return () => {
+      offCreated();
+    }
  }, [meetingLog]);
}

まず、const { on, emit } = useEventBus();で、on/emitを宣言します。

次に、コールバック関数messageCreatedを実装します。

ここでは、setLocalMessages((prevMessages) => [...prevMessages, message]);で、チャットの内容であるlocalMessagesの値を、入力したメッセージを追加して更新します。

さらに、listenメソッド内に、emit関数を追加することで、他ユーザーからメッセージを受信した場合や、自身がメッセージを送信した場合に、コールバック関数を呼び出して、localMessagesの更新を行っています。

そして、メッセージが追加された際のスクロール処理の部分に、on関数を追加することで、ページにアクセスした際に、emitで呼び出されるコールバックをnameキーとともに設定する。

on関数の戻り値として受け取ったoffCreatedを、useEffectreturn部分で実行することで、コンポーネントのアンマウント時に、マウント時に作成した自身のコールバックを破棄しています。

AuthenticatedLayout.jsxの編集

Show.jsxでは、開いた面談記録のみemitでコールバックを作成しているので、自身の作成した面談記録のコールバックはAuthenticatedLayout.jsxで実装します。

ソースコード
resources/js/Layouts/AuthenticatedLayout.jsx
export default function AuthenticatedLayout({ header, children }) {
    ////省略////
+    const { emit } = useEventBus();

    useEffect(() => {
      conversations.forEach((conversation) => {
        let channel = [];
        if (user.id === conversation.user_id) {
          channel = `message.meetinglog.${conversation.id}`
        }
        Echo.private(channel)
            .error((error) => {
              console.error(error);
            })
            .listen("SocketMessage", (e) => {
              console.log("SocketMessage", e);

+              const message = e.message;
+              emit("message.created", message);
              });
            });
      });

      return () => {
        conversations.forEach((conversation) => {
          let channel = `message.meetinglog.${conversation.id}`;
          Echo.leave(channel);
        });
      }
    },[conversations]);
}

Show.jsxと同様の手順でemit関数を追加しています。

おわりに

今回は、チャット機能の実装を解説しました。

自分の理解を深めるために、かなり長々と解説してしまいました。
まだ理解の及んでない部分のあるかと思うので、随時更新していきます!

ではでは!

Discussion