【個人開発】Chatendarのチャット機能を深掘り:Next.js(TS)とFlask(Python)での実装を解説
はじめに
このブログでは、IT未経験で独学の私が開発したアプリである「Chatendar」の主要な機能について解説していきます。
今回は、チャット機能に焦点を当て、どのように実装しているかをご紹介します。
アプリの全体像を知りたい方は、以下のサイトにアクセスしてみてください。
チャット画面
紹介する機能
・チャット機能
この機能を実装するためのコードを紹介します。
コード
それぞれの機能を実装するためのコードを載せます。もし、全体のコードを見たい方がいましたら、私のGitHubから閲覧することができます。
'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.extend(utc);
dayjs.extend(timezone);
dayjs
にutc
とtimezone
プラグインを拡張することで、タイムゾーンを扱う操作が可能になります。
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]);
で既存のメッセージリストに新しいメッセージを追加するという実装をしています。
const handleSendMessage = (e: FormEvent) => {
e.preventDefault();
if (message && eventId) {
socket.emit('send_message', { event_id: eventId, message });
setMessage('');
}
};
この関数は、フォームの送信イベントを処理します。メッセージが存在し、eventId
がある場合、サーバーに'send_message'
イベントを送信します。送信後、message
の状態をリセットします。
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>
このコンポーネントは、リアルタイムチャットでメッセージを送信するためのフォームを提供しています。
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
モデルは、ユーザーがそのイベントでチャットする際のメッセージを保存するためのものです。
@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によるユーザー認証を行い、特定のイベントチャットにユーザーが参加したり、メッセージを送信したり、チャットルームから退出する機能を提供します。
@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
のイベントチャットルームに参加することができます。
参加者ではない場合、エラーメッセージを送信します。
@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_id
とmessage
の内容をクライアントから受け取り、どのイベントにメッセージを送るか、送信するメッセージの内容を取得します。
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
により、メッセージを送信した際に自分自身にもメッセージが返されるようになります。つまり、全員にブロードキャストされるということです。
@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