💬

【個人開発】Chatendarのチャット機能を深掘り:Next.js(TS)とFlask(Python)での実装を解説

2024/10/23に公開

はじめに

このブログでは、IT未経験で独学の私が開発したアプリである「Chatendar」の主要な機能について解説していきます。
今回は、チャット機能に焦点を当て、どのように実装しているかをご紹介します。
アプリの全体像を知りたい方は、以下のサイトにアクセスしてみてください。

https://qiita.com/ryoma_itngineer/items/1a45121a2317b47d2003

チャット画面

cc-chat.png

紹介する機能

・チャット機能

この機能を実装するためのコードを紹介します。

コード

それぞれの機能を実装するためのコードを載せます。もし、全体のコードを見たい方がいましたら、私のGitHubから閲覧することができます。

https://github.com/R-koma/calendar-chat

app/chat/[eventId]/page.tsx
'use client';

import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SendIcon from '@mui/icons-material/Send';
import axios from 'axios';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { useRouter } from 'next/navigation';
import { useEffect, useState, FormEvent, useRef } from 'react';
import io from 'socket.io-client';
import useFetchUser from '@/hooks/useFetchUser';
import { EventDetail, Message } from '@/types/Event';
import api from '@/utils/api';

dayjs.extend(utc);
dayjs.extend(timezone);

const socket = io(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5001', {
  withCredentials: true,
});

export default function ChatPage({ params }: { params: { eventId: string } }) {
  const { eventId } = params;
  const [eventDetail, setEventDetail] = useState<EventDetail | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);
  const [message, setMessage] = useState('');
  const [error, setError] = useState<string | null>(null);
  const hasJoinedRoom = useRef(false);
  const { user, loading } = useFetchUser();
  const currentUser = user?.username;
  const router = useRouter();

  useEffect(() => {
    // 一部省略

    return () => {
      if (hasJoinedRoom.current) {
        socket.off('receive_message');
        socket.emit('leave_room', { event_id: eventId });
        hasJoinedRoom.current = false;
      }
    };
  }, [eventId, user, loading, router]);


  useEffect(() => {
    if (eventDetail && !hasJoinedRoom.current) {
      socket.off('receive_message');
      socket.emit('join_event_chat', { event_id: eventId });
      hasJoinedRoom.current = true;

      socket.on('receive_message', (data: Message) => {
        setMessages((prevMessages) => [...prevMessages, data]);
      });
    }
  }, [eventDetail]);

  
  const handleSendMessage = (e: FormEvent) => {
    e.preventDefault();
    if (message && eventId) {
      socket.emit('send_message', { event_id: eventId, message });
      setMessage('');
    }
  };

  const handleReturnToCalendar = () => {
    if (hasJoinedRoom.current) {
      socket.emit('leave_room', { event_id: eventId });
      hasJoinedRoom.current = false;
    }
    router.push('/protected/calendar');
  };

  if (!eventDetail) return <div>Loading...</div>;

  return (
    <div className="flex h-screen">
      <div className="w-2/3 bg-white p-4">
        <ArrowBackIcon
          className="cursor-pointer"
          onClick={handleReturnToCalendar}
        />
        <div className="h-5/6 overflow-y-auto mb-4">
          {messages.map((msg, index) => {
            const currentDate = new Date(msg.timestamp).toLocaleDateString();

            const showDate = lastDate !== currentDate;
            lastDate = currentDate;
            return (
              <div key={msg.id || `message-${index}`} className="mb-2">
                {showDate && (
                  <div className="text-center text-gray-500 mb-2">
                    {formatDateToJST(msg.timestamp)}
                  </div>
                )}
                {msg.user === currentUser ? (
                  <div className="flex justify-end items-center ">
                    <div className="mr-1 text-xxs text-gray-500 relative top-1">
                      {formatTimeToJST(msg.timestamp)}
                    </div>
                    <div className="bg-slate-500 text-white rounded-xl mr-2 p-2">
                      {msg.message}
                    </div>
                  </div>
                ) : (
                  <div className="flex justify-start items-center">
                    <div className="mr-2 text-gray-700 font-bold">
                      {msg.user}
                    </div>
                    <div className="bg-slate-500 text-white rounded-xl mr-1 p-2">
                      {msg.message}
                    </div>
                    <div className="text-xxs text-gray-500 relative top-1">
                      {formatTimeToJST(msg.timestamp)}
                    </div>
                  </div>
                )}
              </div>
            );
          })}
        </div>
        <form className="flex" onSubmit={handleSendMessage}>
          <input
            type="text"
            value={message}
            onChange={(e) => setMessage(e.target.value)}
            className="flex-grow border border-gray-400 rounded p-2"
            placeholder="メッセージを入力"
          />
          <button
            type="submit"
            className="ml-2 bg-blue-500 text-white rounded p-2"
            aria-label="送信"
          >
            <SendIcon />
          </button>
        </form>
      </div>
    </div>
  );
}

dayjsの設定
dayjs.extend(utc);
dayjs.extend(timezone);

dayjsutctimezoneプラグインを拡張することで、タイムゾーンを扱う操作が可能になります。

Socket.IOの初期化
const socket = io(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5001', {
  withCredentials: true,
});

このsocketでサーバーとのWebSocket接続を確立します。process.env.NEXT_PUBLIC_API_URLから環境変数に設定されたAPIのURLを取得し、設定がない場合はデフォルトのURLを使用します。。

withCredentials: true,は、クロスサイトアクセス制御(CORS)の設定で、クッキーや認証情報を含めることを許可しています。

状態と変数の定義
const { eventId } = params;
const [eventDetail, setEventDetail] = useState<EventDetail | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [message, setMessage] = useState('');
const [error, setError] = useState<string | null>(null);
const hasJoinedRoom = useRef(false);
const { user, loading } = useFetchUser();
const currentUser = user?.username;
const router = useRouter();

let lastDate: string = '';

eventDetail: 現在のイベントの詳細情報を保持します。
messages: チャットメッセージのリストを保持します。
message: ユーザーが入力中のメッセージ内容を保持します。
hasJoinedRoom: チャットルームに参加済みかどうかを追跡するためのuseRefフックです。
user, loading: ユーザー情報とその取得状態をカスタムフックuseFetchUserから取得します。
currentUser: 現在のユーザー名です。
router: ルーティングを操作するためのオブジェクトです。
lastDate: メッセージの日付を比較するための変数です。

クリーンアップ関数
useEffect(() => {
    // 一部省略

    return () => {
      if (hasJoinedRoom.current) {
        socket.off('receive_message');
        socket.emit('leave_room', { event_id: eventId });
        hasJoinedRoom.current = false;
      }
    };
  }, [eventId, user, loading, router]);

このreturn内のコードはクリーンアップ関数です。useEffect内で定義される特別な関数で、コンポーネントがアンマウントされる前や依存関係が変更される直前に実行されます。これにより、リソースの解放やイベントリスナーの解除など、不要になった副作用を適切にクリーンアップできます。

socket.emit('leave_room', { event_id: eventId });は、ユーザーが特定のイベントチャットルームから退出したことをサーバーに通知します。

socket.off('receive_message');は。receive_messageイベントのリスナーを削除し、メモリリークや不要なイベントハンドリングを防ぎます。

hasJoinedRoom.current = false;は、ユーザーがチャットルームに参加していない状態に更新します。

useEffect(() => {
    if (eventDetail && !hasJoinedRoom.current) {
      socket.off('receive_message');
      socket.emit('join_event_chat', { event_id: eventId });
      hasJoinedRoom.current = true;

      socket.on('receive_message', (data: Message) => {
        setMessages((prevMessages) => [...prevMessages, data]);
      });
    }
  }, [eventDetail]);

このコードは、ユーザーを特定のイベントチャットに参加させ、リアルタイムでメッセージを受信するための設定を行なっています。

socket.off('receive_message')で以前に設定されたreveive_messageイベントのリスナーを解除します。これにより、リスナーの重複を防ぐことができます。

socket.emit('join_event_chat', { event_id: eventId });でサーバーに対して、ユーザーが特定のイベントチャットに参加することを通知しています。

hasJoinedRoom.current = true;でユーザーがチャットルームに参加したことを記録しています。これにより、ユーザーがすでにチャットルームに参加しているかどうかを追跡し、不要な再参加やリスナーの再設定を防ぐことができます。

socket.on('receive_message', (data: Message) => {
    setMessages((prevMessages) => [...prevMessages, data]);
});

このコードでは、サーバーからreceive_messageイベントを受信した時に実行されるコールバック関数を設定しています。新しいメッセージを受信すると、messagesの状態を更新します。そして、setMessages((prevMessages) => [...prevMessages, data]);で既存のメッセージリストに新しいメッセージを追加するという実装をしています。

handleSendMessage関数
const handleSendMessage = (e: FormEvent) => {
  e.preventDefault();
  if (message && eventId) {
    socket.emit('send_message', { event_id: eventId, message });
    setMessage('');
  }
};

この関数は、フォームの送信イベントを処理します。メッセージが存在し、eventIdがある場合、サーバーに'send_message'イベントを送信します。送信後、messageの状態をリセットします。

handleReturnToCalendar関数
const handleReturnToCalendar = () => {
  if (hasJoinedRoom.current) {
    socket.emit('leave_room', { event_id: eventId });
    hasJoinedRoom.current = false;
  }
  router.push('/protected/calendar');
};

この関数では、チャットルームから退出し、/protected/calendarページに遷移する実装をしています。leave_roomイベントをサーバーに送ることでチャットルームから退出することができます。

そしてJSX内のコードでArrowBackIconアイコンをクリックするとカレンダー画面に戻る実装やメッセージを表示・送信させるための実装をしています。

カレンダー画面に遷移
<ArrowBackIcon
    className="cursor-pointer"
    onClick={handleReturnToCalendar}
/>
メッセージを表示
{/* メッセージ一覧 */}
      <div className="h-5/6 overflow-y-auto mb-4">
        {messages.map((msg, index) => {
          // 日付の表示
          const currentDate = new Date(msg.timestamp).toLocaleDateString();

          const showDate = lastDate !== currentDate;
          lastDate = currentDate;
          return (
            <div key={msg.id || `message-${index}`} className="mb-2">
              {showDate && (
                <div className="text-center text-gray-500 mb-2">
                  {formatDateToJST(msg.timestamp)}
                </div>
              )}
              {/* メッセージの表示 */}
              {msg.user === currentUser ? (
                // 自分のメッセージ
                <div className="flex justify-end items-center ">
                  <div className="mr-1 text-xxs text-gray-500 relative top-1">
                    {formatTimeToJST(msg.timestamp)}
                  </div>
                  <div className="bg-slate-500 text-white rounded-xl mr-2 p-2">
                    {msg.message}
                  </div>
                </div>
              ) : (
                // 他のユーザーのメッセージ
                <div className="flex justify-start items-center">
                  <div className="mr-2 text-gray-700 font-bold">
                    {msg.user}
                  </div>
                  <div className="bg-slate-500 text-white rounded-xl mr-1 p-2">
                    {msg.message}
                  </div>
                  <div className="text-xxs text-gray-500 relative top-1">
                    {formatTimeToJST(msg.timestamp)}
                  </div>
                </div>
              )}
            </div>
          );
        })}
    </div>
メッセージを送信
{/* メッセージ送信フォーム */}
      <form className="flex" onSubmit={handleSendMessage}>
        <input
          type="text"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          className="flex-grow border border-gray-400 rounded p-2"
          placeholder="メッセージを入力"
        />
        <button
          type="submit"
          className="ml-2 bg-blue-500 text-white rounded p-2"
          aria-label="送信"
        >
          <SendIcon />
        </button>
      </form>

このコンポーネントは、リアルタイムチャットでメッセージを送信するためのフォームを提供しています。

models/message_model.py
from app import db
from datetime import datetime


class Message(db.Model):
    id = db.Column(db.String(36), primary_key=True)
    event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    message = db.Column(db.Text, nullable=False)
    timestamp = db.Column(db.DateTime, default=datetime.now)

    event = db.relationship('Event', backref='messages')
    user = db.relationship('User', backref='messages')

id: メッセージの一意なIDです。このIDは主キー(primary_key=True)で、各メッセージを一意に識別します。

event_id: メッセージが関連するイベントのIDを格納します。ForeignKey('event.id')により、Eventテーブルのidカラムと関連付けられています。メッセージは特定のイベントに紐づいています。

user_id: メッセージを送信したユーザーのIDを格納します。ForeignKey('user.id')により、Userテーブルのidカラムと関連付けられています。

message: 実際のメッセージ内容を保存するカラムです。Text型を使用しているため、長いテキストも格納可能です。

・timestamp: メッセージが送信された日時を記録します。DateTime型で、デフォルト値としてdatetime.nowを指定しているため、メッセージが保存された瞬間の時刻が自動的に記録されます。

event: MessageモデルとEventモデルの間にリレーションシップを定義しています。これにより、メッセージがどのイベントに関連しているかを取得できます。
backref='messages'は、Eventモデルに対して逆方向のアクセス(イベントに関連するメッセージ)を可能にします。例えば、event.messagesとすることで、そのイベントに関連する全てのメッセージを取得できます。

user: MessageモデルとUserモデルの間にリレーションシップを定義しています。これにより、メッセージを送信したユーザーの情報を取得できます。backref='messages' によって、ユーザーに紐づく全てのメッセージにアクセスできます。例えば、user.messagesとすることで、そのユーザーが送信した全メッセージを取得可能です。

このMessageモデルは、ユーザーがそのイベントでチャットする際のメッセージを保存するためのものです。

controllers/event_controller.py
@socketio.on('join_event_chat')
@jwt_required()
def handle_join_event_chat(data):
    user_id = get_jwt_identity()
    event_id = data.get('event_id')

    logger.info(f"User {user_id} is attempting to join chat for event {event_id}")

    participant = EventParticipant.query.filter_by(
        event_id=event_id, user_id=user_id
    ).first()

    if participant:
        logger.info(f"User {user_id} successfully joined the chat for event {event_id}")
        join_room(str(event_id))

        logger.info(f"Emitting join message for event {event_id} and user {user_id}")
    else:
        logger.warning(
            f"Unauthorized attempt to join chat for event {event_id} by user {user_id}"
        )
        emit('error', {'message': 'Unauthorized to join this chat'}, room=request.sid)


@socketio.on('send_message')
@jwt_required()
def handle_send_message(data):
    user_id = get_jwt_identity()
    event_id = data.get('event_id')
    message_text = data.get('message')

    logger.info(f"Received message from user {user_id}: {message_text}")

    participant = EventParticipant.query.filter_by(
        event_id=event_id, user_id=user_id
    ).first()

    if participant:
        message_id = str(uuid.uuid4())
        message = Message(
            id=message_id,
            event_id=event_id,
            user_id=user_id,
            message=message_text,
            timestamp=datetime.now(ZoneInfo('UTC')),
        )
        try:
            db.session.add(message)
            db.session.commit()
            logger.info(f"Message saved by user {user_id} to event {event_id}")
        except Exception as e:
            db.session.rollback()
            logger.error(f"Failed to save message: {str(e)}")
            emit('error', {'error': 'Failed to save message'}, to=request.sid)
            return

        current_timestamp = datetime.now(ZoneInfo('UTC')).isoformat()
        emit(
            'receive_message',
            {
                'id': message.id,
                'user': participant.user.username,
                'message': message.message,
                'timestamp': current_timestamp,
            },
            room=str(event_id),
            include_self=True,
        )
    else:
        logger.warning(
            f"Unauthorized attempt to send a message in event {event_id} by user {user_id}"
        )
        emit('error', {'error': 'Unauthorized to send message'}, to=request.sid)


@socketio.on('leave_room')
@jwt_required()
def handle_leave_room(data):
    user_id = get_jwt_identity()
    event_id = data.get('event_id')

    logger.info(f"User {user_id} is leaving chat for event {event_id}")

    leave_room(str(event_id))
    logger.info(f"User {user_id} has left the chat for event {event_id}")

このコードは、Flask-SocketIOを使用してリアルタイムのチャット機能を実装するためのサーバー側のロジックです。JWTによるユーザー認証を行い、特定のイベントチャットにユーザーが参加したり、メッセージを送信したり、チャットルームから退出する機能を提供します。

handle_join_event_chat関数
@socketio.on('join_event_chat')
@jwt_required()
def handle_join_event_chat(data):

@socketio.on('join_event_chat')デコレータにより、クライアントがjoin_event_chatイベントを送信すると、この関数がトリガーされます。

user_id = get_jwt_identity()
event_id = data.get('event_id')

get_jwt_identity()でJWTトークンから認証されたユーザーのIDを取得します。
data.get('event_id')でクライアントから送信されたevent_idを取得します。

participant = EventParticipant.query.filter_by(event_id=event_id, user_id=user_id).first()

if participant:
    join_room(str(event_id))
else:
    emit('error', {'message': 'Unauthorized to join this chat'}, room=request.sid)

EventParticipantテーブル(DB)を検索し、このユーザーが特定のイベントの参加者かどうか確認します。

参加者だった場合、join_room()関数で、ユーザーがそのevent_idのイベントチャットルームに参加することができます。

参加者ではない場合、エラーメッセージを送信します。

handle_sendmessage関数
@socketio.on('send_message')
@jwt_required()
def handle_send_message(data):

ユーザーがメッセージをイベントチャットに送信する際にトリガーされます。クライアントが'send_message'イベントを送信すると、この関数が呼び出されます。

メッセージ送信の処理
user_id = get_jwt_identity()
event_id = data.get('event_id')
message_text = data.get('message')

event_idmessageの内容をクライアントから受け取り、どのイベントにメッセージを送るか、送信するメッセージの内容を取得します。

参加者確認とメッセージ保存
if participant:
    message_id = str(uuid.uuid4())
    message = Message(
        id=message_id,
        event_id=event_id,
        user_id=user_id,
        message=message_text,
        timestamp=datetime.now(ZoneInfo('UTC')),
    )
    try:
        db.session.add(message)
        db.session.commit()
    except Exception as e:
        db.session.rollback()
        emit('error', {'error': 'Failed to save message'}, to=request.sid)

イベントの参加者なのか確認する。参加者の場合は、UUIDを生成して、メッセージをデータベースに保存するという処理を行います。エラーが発生した場合はロールバックし、エラーメッセージを送信します。

メッセージのブロードキャスト
emit(
    'receive_message',
    {
        'id': message.id,
        'user': participant.user.username,
        'message': message.message,
        'timestamp': current_timestamp,
    },
    room=str(event_id),
    include_self=True,
)

メッセージが成功した場合、メッセージをそのevent_idルームにいるすべての参加者にブロードキャストします。
ブロードキャストとは、一つのデータやメッセージを複数の対象に同時に送信することを指します。

emit関数でメッセージやデータをSocket.IOを通じてクライアントに送信しています。

room=str(event_id)は、指定されたチャットルーム(event_id)にメッセージを送信することを意味しており、このルームに参加しているすべてのユーザーにメッセージが送信されます。

include_self=Trueにより、メッセージを送信した際に自分自身にもメッセージが返されるようになります。つまり、全員にブロードキャストされるということです。

handle_leave_room関数
@socketio.on('leave_room')
@jwt_required()
def handle_leave_room(data):
    user_id = get_jwt_identity()
    event_id = data.get('event_id')

    logger.info(f"User {user_id} is leaving chat for event {event_id}")

    leave_room(str(event_id))
    logger.info(f"User {user_id} has left the chat for event {event_id}")

ユーザーがイベントチャットを退出する際に呼び出されます。'leave_room'イベントを受信すると、この関数が実行されます。

leave_room()でユーザーが指定したevent_idのチャットルームから退出することができます。

このコードは、特定のイベントチャットに参加したり、メッセージを送信したり、チャットルームから退出するリアルタイムのチャット機能を提供しています。各関数でJWTによるユーザー認証を行い、適切な参加者のみがチャットにアクセスできるようにしています。メッセージはデータベースに保存され、リアルタイムで他の参加者にブロードキャストされます。

まとめ

今回のブログでは、私が開発したアプリ「Chatendar」のチャット機能について、主要な実装部分を解説しました。

チャット機能は、アプリケーションにおいてユーザーにとって視覚的にも機能的にも重要な部分です。

Discussion