こんにちは。ナナオです。
前回の記事ではグループチャット実装のはじめの一歩を行いました。
今回も引き続き実装していこうと思います。
現在の実装の問題点
現在の実装から感じる問題点は以下の通りです。
- メンバーの退出が検出されないケースがある
- メンバーの名前が設定できない
- 退出するとき、だれが出るのか分からない
それぞれ修正していきます。
メンバーの退出が検出されないケースがある
ブラウザを閉じた際などに退出したことがハンドリングされず、ほかの参加メンバーに退出したことが伝わらないことがありました。
そこで以下のようにハンドラを追加しました。
// --- タブを閉じる時に明示的に切断するハンドラ ---
useEffect(() => {
const handleBeforeUnload = () => {
Object.values(connsRef.current).forEach(conn => {
conn.send({ type: 'leave', peerId: myId }); // 退出を通知
conn.close();
});
peerRef.current?.destroy();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
peerRef.current?.destroy();
};
}, []);
const setupConnection = (conn: DataConnection) => {
// 定期的な接続チェック
const checkInterval = setInterval(() => {
if (!conn.open) {
cleanup();
}
}, 5000);
const cleanup = () => {
clearInterval(checkInterval);
if (connsRef.current[conn.peer]) {
delete connsRef.current[conn.peer];
setConns({ ...connsRef.current });
setMessages(prev => [...prev, { system: true, text: "誰かが退室しました" }]);
}
};
// 中略...
conn.on('data', (data: { type: string; sender?: string; text?: string, ids?: string[] }) => {
// 中略...
} else if (data.type === 'leave') {
// 明示的な退出通知を受け取った場合
cleanup();
conn.close();
}
});
conn.on('close', cleanup);
conn.on('error', cleanup);
タブを閉じる時に明示的に切断するハンドラを定義したuseEffectと、setupConnection関数の最初に接続を定期的にチェックするヘルスチェック処理とcleanup関数を追加しました。
これによりメンバーの入退室管理がより厳格になりました。
メンバーの名前が設定できない・退出するとき、だれが出るのか分からない
UI上に表示するメンバーの名前がPeer.jsが自動で設定したランダムな文字列のIDなので、誰が誰なのかよく分かりません。
また、メンバーの退出時は誰が退出しても「誰かが退室しました」と出力されるため、明確に誰が退出したかがわからないです。
結構大幅な変更なので、結構書き換えてもらいました(Geminiに)
import { useState, useEffect, useRef } from 'react';
import Peer from 'peerjs';
import type { DataConnection } from 'peerjs';
import { randomName } from '@/utils/string';
const ChatApp = () => {
const [myId, setMyId] = useState('');
const [myName, setMyName] = useState(''); // 自分の表示名
const [tempName, setTempName] = useState(''); // 入力中の名前
const [isNameSet, setIsNameSet] = useState(false);
const [messages, setMessages] = useState([]);
const [messageInput, setMessageInput] = useState('');
const [peers, setPeers] = useState({}); // { peerId: { conn: DataConnection, name: string } }
const [copyStatus, setCopyStatus] = useState('URLをコピー');
const peerRef = useRef(null);
const peersRef = useRef({}); // 状態管理用
const scrollRef = useRef(null);
useEffect(() => {
if (!isNameSet) return;
const peer = new Peer();
peerRef.current = peer;
peer.on('open', (id) => {
setMyId(id);
const targetId = window.location.hash.substring(1);
if (targetId && targetId !== id) {
connectToPeer(targetId);
}
});
peer.on('connection', (conn) => {
setupConnection(conn);
});
const handleBeforeUnload = () => {
Object.values(peersRef.current).forEach(({ conn }) => {
conn.send({ type: 'leave', senderName: myName });
conn.close();
});
peerRef.current?.destroy();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
peerRef.current?.destroy();
};
}, [isNameSet]);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages]);
const setupConnection = (conn) => {
if (peersRef.current[conn.peer]) return;
conn.on('open', () => {
// 1. 接続直後に自分の名前を相手に送る
conn.send({ type: 'introduce', name: myName });
});
conn.on('data', (data) => {
if (data.type === 'introduce') {
// 名前を受け取って登録
peersRef.current[conn.peer] = { conn, name: data.name };
setPeers({ ...peersRef.current });
setMessages(prev => [...prev, { system: true, text: `${data.name}が入室しました` }]);
// 自分がリーダーなら、他の全メンバーのIDリストを共有
const otherIds = Object.keys(peersRef.current).filter(id => id !== conn.peer);
if (otherIds.length > 0) {
conn.send({ type: 'member-list', ids: otherIds });
}
}
else if (data.type === 'chat') {
setMessages(prev => [...prev, { sender: data.senderName, text: data.text }]);
}
else if (data.type === 'member-list') {
data.ids.forEach(id => connectToPeer(id));
}
else if (data.type === 'leave') {
cleanup(conn.peer, data.senderName);
}
});
const cleanup = (id, name) => {
if (peersRef.current[id]) {
const displayName = name || peersRef.current[id].name || "不明なユーザー";
delete peersRef.current[id];
setPeers({ ...peersRef.current });
setMessages(prev => [...prev, { system: true, text: `${displayName}が退室しました` }]);
}
};
conn.on('close', () => cleanup(conn.peer));
conn.on('error', () => cleanup(conn.peer));
};
const connectToPeer = (id) => {
if (!id || id === myId || peersRef.current[id]) return;
const conn = peerRef.current.connect(id);
setupConnection(conn);
};
const sendMessage = (e) => {
e.preventDefault();
if (!messageInput) return;
const payload = { type: 'chat', senderName: myName, text: messageInput };
Object.values(peersRef.current).forEach(({ conn }) => {
if (conn.open) conn.send(payload);
});
setMessages(prev => [...prev, { sender: '自分', text: messageInput }]);
setMessageInput('');
};
// 招待用URLをコピーする関数
const copyInviteLink = () => {
const inviteUrl = `${window.location.origin}${window.location.pathname}#${myId}`;
navigator.clipboard.writeText(inviteUrl).then(() => {
setCopyStatus('コピー完了!');
setTimeout(() => setCopyStatus('URLをコピー'), 2000);
});
};
const handleNameSubmit = (e) => {
e.preventDefault();
if (tempName.trim()) {
setMyName(tempName);
setIsNameSet(true);
}
};
// 名前入力画面
if (!isNameSet) {
return (
<div className="h-screen flex items-center justify-center bg-slate-900">
<form onSubmit={handleNameSubmit} className="bg-slate-800 p-8 rounded-2xl shadow-2xl w-80 text-center">
<h2 className="text-indigo-400 font-bold mb-4 text-xl">名前を設定してください</h2>
<input
className="w-full bg-slate-700 border-none rounded-lg px-4 py-2 text-white mb-4 outline-none focus:ring-2 focus:ring-indigo-500"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
placeholder="ユーザー名"
autoFocus
/>
<button className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2 rounded-lg transition-all">
チャットを開始
</button>
</form>
</div>
);
}
return (
<div className="flex flex-col h-screen bg-slate-900 p-4 text-slate-100 font-sans">
<div className="max-w-2xl mx-auto w-full flex flex-col h-full bg-slate-800 rounded-xl shadow-2xl overflow-hidden border border-slate-700">
{/* ヘッダーセクション */}
<div className="bg-slate-800 p-5 text-white">
<h1 className="text-xl font-bold mb-2 text-center text-indigo-400">P2P Chat</h1>
<h2>名前: {myName}</h2>
<p className="text-[10px] text-slate-400">参加人数: {Object.keys(peers).length + 1}名</p>
<div className="bg-slate-700 rounded-lg p-3 flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-slate-400 font-semibold">Your ID</div>
<div className="text-sm font-mono break-all text-indigo-200">{myId || '発行中...'}</div>
{myId && (
<button
onClick={copyInviteLink}
className="mt-1 w-full bg-indigo-600 hover:bg-indigo-500 text-white text-xs py-2 rounded transition-colors font-bold"
>
{copyStatus}
</button>
)}
</div>
</div>
{/* チャットログ */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-900/30">
{messages.map((msg, i) => (
<div key={i} className={`flex flex-col ${msg.sender === '自分' ? 'items-end' : 'items-start'}`}>
{msg.system ? (
<span className="text-[10px] text-slate-500 mx-auto my-1 uppercase">{msg.text}</span>
) : (
<div className={`max-w-[85%] flex flex-col ${msg.sender === '自分' ? 'items-end' : 'items-start'}`}>
<span className="text-[10px] text-slate-400 px-1 mb-1">{msg.sender}</span>
<div className={`px-4 py-2 rounded-2xl text-sm ${
msg.sender === '自分' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-slate-700 text-slate-100 rounded-tl-none border border-slate-600'
}`}>
{msg.text}
</div>
</div>
)}
</div>
))}
</div>
{/* 送信フォーム */}
<div className="p-4 bg-slate-800 border-t border-slate-700">
<form onSubmit={sendMessage} className="flex gap-2">
<input
type="text"
className="flex-1 bg-slate-900 border border-slate-600 rounded-full px-5 py-2 text-sm text-slate-100 outline-none focus:ring-2 focus:ring-indigo-500 transition-all"
placeholder="メッセージ..."
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
/>
<button type="submit" className="bg-indigo-600 text-white px-5 py-2 rounded-full text-sm font-bold shadow-md active:scale-95 transition-all disabled:opacity-50">
送信
</button>
</form>
</div>
</div>
</div>
);
};
export default ChatApp;
感想
ということでいい感じに修正できました。
もうちょっと改修するかも…?
乞うご期待!