feat: 重构WebRTC文本和图片传输组件,新增WebRTCChat组件以支持聊天功能,整合消息发送和接收逻辑

This commit is contained in:
MatrixSeven
2026-03-05 14:27:35 +08:00
parent 6d02a9898f
commit 2b5ba5fdc4
4 changed files with 1043 additions and 99 deletions

View File

@@ -1,104 +1,8 @@
"use client";
import React, { useState, useCallback } from 'react';
import { useURLHandler } from '@/hooks/ui';
import { useWebRTCStore } from '@/hooks/ui/webRTCStore';
import { WebRTCTextSender } from '@/components/webrtc/WebRTCTextSender';
import { WebRTCTextReceiver } from '@/components/webrtc/WebRTCTextReceiver';
import { Button } from '@/components/ui/button';
import { MessageSquare, Send, Download, X } from 'lucide-react';
import React from 'react';
import { WebRTCChat } from '@/components/webrtc/WebRTCChat';
export const WebRTCTextImageTransfer: React.FC = () => {
// 状态管理
const [mode, setMode] = useState<'send' | 'receive'>('send');
const [previewImage, setPreviewImage] = useState<string | null>(null);
// 使用全局WebRTC状态
const webrtcState = useWebRTCStore();
// 使用统一的URL处理器
const { updateMode, getCurrentRoomCode, clearURLParams } = useURLHandler({
featureType: 'message',
onModeChange: setMode
});
// 重新开始函数
const handleRestart = useCallback(() => {
setPreviewImage(null);
clearURLParams();
}, [clearURLParams]);
const code = getCurrentRoomCode();
// 连接状态变化处理 - 现在不需要了,因为使用全局状态
const handleConnectionChange = useCallback((connection: any) => {
// 这个函数现在可能不需要了,但为了兼容现有的子组件接口,保留它
console.log('连接状态变化:', connection);
}, []);
// 关闭图片预览
const closePreview = useCallback(() => {
setPreviewImage(null);
}, []);
return (
<div className="space-y-4 sm:space-y-6">
{/* 模式切换 */}
<div className="flex justify-center mb-6">
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
<Button
variant={mode === 'send' ? 'default' : 'ghost'}
onClick={() => updateMode('send')}
className="px-6 py-2 rounded-lg"
>
<Send className="w-4 h-4 mr-2" />
</Button>
<Button
variant={mode === 'receive' ? 'default' : 'ghost'}
onClick={() => updateMode('receive')}
className="px-6 py-2 rounded-lg"
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg p-4 sm:p-6 animate-fade-in-up">
{mode === 'send' ? (
<WebRTCTextSender
onRestart={handleRestart}
onPreviewImage={setPreviewImage}
onConnectionChange={handleConnectionChange}
/>
) : (
<WebRTCTextReceiver
initialCode={code}
onPreviewImage={setPreviewImage}
onRestart={handleRestart}
onConnectionChange={handleConnectionChange}
/>
)}
</div>
{/* 图片预览模态框 */}
{previewImage && (
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50" onClick={closePreview}>
<div className="relative max-w-4xl max-h-4xl">
<img src={previewImage} alt="预览" className="max-w-full max-h-full" />
<Button
onClick={closePreview}
className="absolute top-4 right-4 bg-white text-black hover:bg-gray-200"
size="sm"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
);
return <WebRTCChat />;
};

View File

@@ -0,0 +1,711 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSharedWebRTCManager } from '@/hooks/connection';
import { useChatBusiness, type ChatMessage } from '@/hooks/text-transfer';
import { useURLHandler } from '@/hooks/ui';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/toast-simple';
import {
MessageSquare, Image, Send, Copy, Check, Upload,
X, ImageIcon, Download
} from 'lucide-react';
import RoomInfoDisplay from '@/components/RoomInfoDisplay';
import { ConnectionStatus } from '@/components/ConnectionStatus';
import { checkRoomStatus } from '@/lib/room-utils';
// ── 单条消息气泡组件 ──
const ChatBubble: React.FC<{
message: ChatMessage;
onPreviewImage?: (url: string) => void;
}> = ({ message, onPreviewImage }) => {
const [copied, setCopied] = useState(false);
const isMine = message.sender === 'me';
const handleCopy = async () => {
if (message.type !== 'text') return;
try {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* ignore */ }
};
const timeStr = new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
return (
<div className={`flex ${isMine ? 'justify-end' : 'justify-start'} group`}>
<div
className={`relative max-w-[80%] sm:max-w-[70%] ${
isMine
? 'bg-gradient-to-br from-blue-500 to-indigo-500 text-white rounded-2xl rounded-br-md'
: 'bg-white border border-slate-200 text-slate-800 rounded-2xl rounded-bl-md'
} shadow-sm`}
>
{/* 文本消息 */}
{message.type === 'text' && (
<div className="px-4 py-2.5 min-w-[60px]">
<pre className="whitespace-pre-wrap break-words text-sm leading-relaxed font-sans m-0">
{message.content}
</pre>
</div>
)}
{/* 图片消息 */}
{message.type === 'image' && (
<div className="p-1.5">
{message.content ? (
<img
src={message.content}
alt={message.fileName || '图片'}
className="max-w-[280px] max-h-[280px] rounded-xl cursor-pointer hover:opacity-90 transition-opacity object-cover"
onClick={() => onPreviewImage?.(message.content)}
loading="lazy"
/>
) : (
<div className="w-[200px] h-[140px] rounded-xl bg-slate-100 flex items-center justify-center">
<div className="text-center text-slate-400">
<ImageIcon className="w-8 h-8 mx-auto mb-1 animate-pulse" />
<span className="text-xs">...</span>
</div>
</div>
)}
{message.status === 'sending' && (
<div className="absolute inset-0 bg-black/10 rounded-xl flex items-center justify-center">
<div className="bg-white/90 rounded-full px-3 py-1 text-xs text-slate-600 flex items-center gap-1">
<div className="w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
</div>
)}
</div>
)}
{/* 时间 + 操作 */}
<div
className={`flex items-center gap-1.5 px-3 pb-1.5 pt-0 ${
isMine ? 'justify-end' : 'justify-start'
}`}
>
<span className={`text-[10px] ${isMine ? 'text-white/60' : 'text-slate-400'}`}>
{timeStr}
</span>
{message.status === 'failed' && (
<span className="text-[10px] text-red-400"></span>
)}
{/* 文本复制按钮 */}
{message.type === 'text' && (
<button
onClick={handleCopy}
className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded ${
isMine ? 'hover:bg-white/20 text-white/70' : 'hover:bg-slate-100 text-slate-400'
}`}
title="复制"
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</button>
)}
{/* 图片保存提示 */}
{message.type === 'image' && message.content && (
<button
onClick={() => onPreviewImage?.(message.content)}
className={`opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded ${
isMine ? 'hover:bg-white/20 text-white/70' : 'hover:bg-slate-100 text-slate-400'
}`}
title="查看大图"
>
<ImageIcon className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
);
};
// ── 打字指示器 ──
const TypingIndicator: React.FC = () => (
<div className="flex justify-start">
<div className="bg-white border border-slate-200 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
<div className="flex items-center space-x-1.5">
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
<span className="text-xs text-slate-400 ml-1"></span>
</div>
</div>
</div>
);
// ── 主组件 ──
export const WebRTCChat: React.FC = () => {
const { showToast } = useToast();
// 模式状态
const [mode, setMode] = useState<'send' | 'receive'>('send');
const [roomCode, setRoomCode] = useState('');
const [inputCode, setInputCode] = useState('');
const [inputText, setInputText] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [isJoining, setIsJoining] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
// Refs
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasAutoJoinedRef = useRef(false);
// 连接 + 业务
const connection = useSharedWebRTCManager();
const chat = useChatBusiness(connection);
// URL 参数处理
const { updateMode, getCurrentRoomCode, clearURLParams } = useURLHandler({
featureType: 'message',
onModeChange: setMode,
onAutoJoinRoom: (code: string) => {
if (!hasAutoJoinedRef.current) {
hasAutoJoinedRef.current = true;
setInputCode(code);
joinRoom(code);
}
},
});
// 滚动到底部
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
});
}, []);
// 新消息时自动滚动
useEffect(() => {
scrollToBottom();
}, [chat.messages, chat.peerTyping, scrollToBottom]);
// ── 创建房间 ──
const createRoom = useCallback(async () => {
if (isCreating) return;
setIsCreating(true);
try {
const response = await fetch('/api/create-room', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '创建房间失败');
const code = data.code;
setRoomCode(code);
await connection.connect(code, 'sender');
showToast(`聊天房间已创建,取件码: ${code}`, 'success');
} catch (error) {
showToast(error instanceof Error ? error.message : '创建房间失败', 'error');
} finally {
setIsCreating(false);
}
}, [isCreating, connection, showToast]);
// ── 加入房间 ──
const joinRoom = useCallback(async (code: string) => {
const finalCode = code || inputCode;
if (!finalCode || finalCode.length !== 6 || isJoining) return;
setIsJoining(true);
try {
const result = await checkRoomStatus(finalCode);
if (!result.success) {
showToast(result.error || '加入房间失败', 'error');
return;
}
setRoomCode(finalCode);
await connection.connect(finalCode, 'receiver');
} catch (error) {
showToast(error instanceof Error ? error.message : '加入房间失败', 'error');
} finally {
setIsJoining(false);
}
}, [inputCode, isJoining, connection, showToast]);
// ── 重新开始 ──
const restart = useCallback(() => {
chat.clearMessages();
connection.disconnect();
setRoomCode('');
setInputCode('');
setInputText('');
setPreviewImage(null);
hasAutoJoinedRef.current = false;
clearURLParams();
}, [chat, connection, clearURLParams]);
// ── 发送消息 ──
const handleSend = useCallback(() => {
const text = inputText.trim();
if (!text || !connection.isPeerConnected) return;
chat.sendTextMessage(text);
setInputText('');
// 重置 textarea 高度
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [inputText, connection.isPeerConnected, chat]);
// ── 文本输入 ──
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInputText(e.target.value);
chat.sendTypingStatus();
// 自动调整高度
const ta = e.target;
ta.style.height = 'auto';
ta.style.height = `${Math.min(ta.scrollHeight, 120)}px`;
}, [chat]);
// ── 键盘快捷键 ──
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
// ── 图片处理 ──
const handleImageSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showToast('请选择图片文件', 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
showToast('图片不能超过 5MB', 'error');
return;
}
if (!connection.isPeerConnected) {
showToast('等待对方加入后才能发送图片', 'error');
return;
}
chat.sendImage(file);
e.target.value = '';
}, [connection.isPeerConnected, chat, showToast]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
if (file.size > 5 * 1024 * 1024) {
showToast('图片不能超过 5MB', 'error');
return;
}
if (!connection.isPeerConnected) {
showToast('等待对方加入后才能发送图片', 'error');
return;
}
chat.sendImage(file);
}
break;
}
}
}, [connection.isPeerConnected, chat, showToast]);
// ── 复制分享链接 ──
const copyShareLink = useCallback(() => {
const baseUrl = window.location.origin + window.location.pathname;
const link = `${baseUrl}?type=message&mode=receive&code=${roomCode}`;
navigator.clipboard.writeText(link).then(
() => showToast('分享链接已复制', 'success'),
() => showToast('复制失败', 'error'),
);
}, [roomCode, showToast]);
const copyCode = useCallback(() => {
navigator.clipboard.writeText(roomCode);
showToast('取件码已复制', 'success');
}, [roomCode, showToast]);
const pickupLink = roomCode
? `${typeof window !== 'undefined' ? window.location.origin : ''}?type=message&mode=receive&code=${roomCode}`
: '';
// 判断阶段
const isConnected = connection.isConnected || connection.isPeerConnected;
const isSetup = !roomCode;
// ─────────────────────────────────────
// 渲染
// ─────────────────────────────────────
return (
<div className="space-y-4 sm:space-y-6">
{/* 模式切换 - 与文件传输/桌面共享统一风格 */}
{isSetup && (
<div className="flex justify-center mb-6">
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-1 shadow-lg">
<Button
variant={mode === 'send' ? 'default' : 'ghost'}
onClick={() => updateMode('send' as any)}
className="px-6 py-2 rounded-lg"
>
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button
variant={mode === 'receive' ? 'default' : 'ghost'}
onClick={() => updateMode('receive' as any)}
className="px-6 py-2 rounded-lg"
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
{/* ── 阶段 1: 创建/加入房间 ── */}
{isSetup && (
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20 animate-fade-in-up">
{mode === 'send' ? (
/* ── 创建房间 ── */
<div className="space-y-6">
{/* 功能标题 + 状态栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-600"></p>
</div>
</div>
<ConnectionStatus currentRoom={null} />
</div>
<div className="text-center py-8">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
<MessageSquare className="w-10 h-10 text-blue-500" />
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2"></h3>
<p className="text-slate-500 mb-8 text-sm"></p>
<Button
onClick={createRoom}
disabled={isCreating || connection.isConnecting}
className="px-8 py-3 bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white text-base font-medium rounded-xl shadow-lg transition-all hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
>
{isCreating || connection.isConnecting ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</div>
) : (
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
</div>
)}
</Button>
</div>
</div>
) : (
/* ── 加入房间 ── */
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl flex items-center justify-center">
<Download className="w-5 h-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800"></h2>
<p className="text-sm text-slate-500"> 6 </p>
</div>
</div>
<ConnectionStatus currentRoom={null} />
</div>
<form
onSubmit={(e) => { e.preventDefault(); joinRoom(inputCode); }}
className="space-y-4"
>
<div className="relative">
<Input
value={inputCode}
onChange={(e) =>
setInputCode(
e.target.value.replace(
/[^123456789ABCDEFGHIJKLMNPQRSTUVWXYZabcdefghijklmnpqrstuvwxyz]/g,
'',
),
)
}
placeholder="请输入取件码"
className="text-center text-2xl sm:text-3xl tracking-[0.3em] sm:tracking-[0.5em] font-mono h-12 sm:h-16 border-2 border-slate-200 rounded-xl focus:border-emerald-500 focus:ring-emerald-500 bg-white/80 backdrop-blur-sm"
maxLength={6}
disabled={isJoining || connection.isConnecting}
/>
<p className="text-center text-xs text-slate-400 mt-2">
{inputCode.length}/6
</p>
</div>
<Button
type="submit"
disabled={inputCode.length !== 6 || isJoining || connection.isConnecting}
className="w-full h-11 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-base font-medium rounded-xl shadow-lg transition-all hover:shadow-xl hover:scale-105 disabled:opacity-50 disabled:scale-100"
>
{isJoining || connection.isConnecting ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</div>
) : (
<div className="flex items-center gap-2">
<Download className="w-5 h-5" />
</div>
)}
</Button>
</form>
</div>
)}
</div>
)}
{/* ── 阶段 2: 房间已创建/加入 ── */}
{!isSetup && (
<div className="animate-fade-in-up">
{!connection.isPeerConnected ? (
/* ── 等待对方加入: loading + QR 一体卡片 ── */
<div className="bg-white/80 backdrop-blur-sm rounded-xl p-4 sm:p-6 shadow-lg border border-white/20">
{/* 标题栏 */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {roomCode}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<ConnectionStatus
currentRoom={{ code: roomCode, role: mode === 'send' ? 'sender' : 'receiver' }}
/>
<Button
onClick={restart}
variant="outline"
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
>
</Button>
</div>
</div>
{/* Loading 等待 */}
<div className="flex flex-col items-center py-8 space-y-3">
<div className="w-16 h-16 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-full flex items-center justify-center">
<MessageSquare className="w-8 h-8 text-blue-400" />
</div>
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
{[...Array(3)].map((_, i) => (
<div key={i} className="w-2 h-2 bg-blue-400 rounded-full animate-bounce" style={{ animationDelay: `${i * 0.15}s` }} />
))}
</div>
<span className="text-sm font-medium text-blue-500">...</span>
</div>
<p className="text-center text-xs text-slate-400">
</p>
</div>
{/* QR / 取件码 - 与 loading 同一卡片内 */}
{roomCode && mode === 'send' && (
<div className="pt-4">
<RoomInfoDisplay
code={roomCode}
link={pickupLink}
icon={MessageSquare}
iconColor="from-blue-500 to-indigo-500"
codeColor="from-blue-600 to-indigo-600"
title="聊天房间已创建!"
subtitle="分享取件码给对方,对方加入后即可开始聊天"
codeLabel="取件码"
qrLabel="扫码加入"
copyButtonText="复制取件码"
copyButtonColor="bg-blue-500 hover:bg-blue-600"
qrButtonText="使用手机扫码快速加入"
linkButtonText="复制链接"
onCopyCode={copyCode}
onCopyLink={copyShareLink}
/>
</div>
)}
</div>
) : (
/* ── 已连接: 聊天窗口 ── */
<div className="bg-white/80 backdrop-blur-sm rounded-xl shadow-lg border border-white/20 overflow-hidden">
{/* 功能标题和状态 */}
<div className="flex items-center justify-between p-4 sm:px-6 sm:py-4 border-b border-slate-100">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-500 rounded-xl flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-800"></h3>
<p className="text-sm text-slate-600">: {roomCode}</p>
</div>
</div>
<div className="flex items-center space-x-4">
<ConnectionStatus
currentRoom={{ code: roomCode, role: mode === 'send' ? 'sender' : 'receiver' }}
/>
<Button
onClick={restart}
variant="outline"
className="text-slate-600 hover:text-slate-800 border-slate-200 hover:border-slate-300"
>
</Button>
</div>
</div>
{/* 聊天区域 */}
<div className="bg-slate-50/50">
{/* 消息列表 */}
<div
className="h-[400px] sm:h-[480px] overflow-y-auto p-4 space-y-3"
style={{ scrollbarWidth: 'thin' }}
>
{chat.messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400 space-y-4">
<MessageSquare className="w-12 h-12 text-slate-300" />
<p className="text-center text-sm">
</p>
<p className="text-center text-xs text-slate-300">
</p>
</div>
) : (
chat.messages.map((msg) => (
<ChatBubble
key={msg.id}
message={msg}
onPreviewImage={setPreviewImage}
/>
))
)}
{/* 打字指示器 */}
{chat.peerTyping && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>
{/* 输入栏 */}
<div className="border-t border-slate-200 bg-white p-3">
<div className="flex items-end gap-2">
{/* 图片按钮 */}
<Button
onClick={() => fileInputRef.current?.click()}
variant="ghost"
size="sm"
className="h-10 w-10 p-0 flex-shrink-0 text-slate-500 hover:text-blue-500 hover:bg-blue-50 rounded-xl"
title="发送图片"
>
<Image className="w-5 h-5" />
</Button>
{/* 文本输入 */}
<textarea
ref={textareaRef}
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
rows={1}
className="flex-1 resize-none rounded-xl border border-slate-200 px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
style={{ minHeight: '40px', maxHeight: '120px' }}
/>
{/* 发送按钮 */}
<Button
onClick={handleSend}
disabled={!inputText.trim()}
size="sm"
className="h-10 w-10 p-0 flex-shrink-0 bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white rounded-xl shadow-md transition-all hover:shadow-lg disabled:opacity-40 disabled:shadow-none"
>
<Send className="w-4 h-4" />
</Button>
</div>
<p className="text-[10px] text-slate-400 mt-1.5 ml-12">
(Ctrl+V) · 5MB
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* ── 图片预览模态框 ── */}
{previewImage && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
<img
src={previewImage}
alt="预览"
className="max-w-full max-h-[85vh] rounded-lg shadow-2xl object-contain"
/>
<Button
onClick={() => setPreviewImage(null)}
className="absolute -top-3 -right-3 bg-white text-slate-700 hover:bg-slate-100 rounded-full w-8 h-8 p-0 shadow-lg"
size="sm"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
</div>
);
};

View File

@@ -1,2 +1,4 @@
// 文本传输相关的hooks
export { useTextTransferBusiness } from './useTextTransferBusiness';
export { useChatBusiness } from './useChatBusiness';
export type { ChatMessage } from './useChatBusiness';

View File

@@ -0,0 +1,327 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { WebRTCConnection } from '../connection/types';
// ── 类型定义 ──
export interface ChatMessage {
id: string;
type: 'text' | 'image';
content: string; // 文本内容 或 blob URL (图片)
timestamp: number;
sender: 'me' | 'peer';
status: 'sending' | 'sent' | 'failed';
fileName?: string; // 图片文件名
}
interface ImageAssembly {
messageId: string;
fileName: string;
mimeType: string;
fileSize: number;
totalChunks: number;
receivedChunks: Map<number, string>;
timestamp: number;
}
// ── 常量 ──
const CHANNEL_NAME = 'chat';
const IMAGE_CHUNK_SIZE = 64 * 1024; // 64KB raw → ~85KB base64 per chunk
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
const TYPING_TIMEOUT_MS = 2000;
// ── 工具函数 ──
function generateId(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunks: string[] = [];
// 分批处理避免 call stack 溢出
const BATCH = 8192;
for (let i = 0; i < bytes.length; i += BATCH) {
const slice = bytes.subarray(i, i + BATCH);
chunks.push(String.fromCharCode(...slice));
}
return btoa(chunks.join(''));
}
function base64ToBlob(base64: string, mimeType: string): Blob {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: mimeType });
}
// ── Hook ──
export function useChatBusiness(connection: WebRTCConnection) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [peerTyping, setPeerTyping] = useState(false);
// 图片分块组装缓冲区
const imageAssemblyRef = useRef<Map<string, ImageAssembly>>(new Map());
// 打字状态定时器
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 对方打字状态超时清除
const peerTypingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// ── 接收消息处理 ──
const handleMessage = useCallback((message: any) => {
const { type, payload } = message;
switch (type) {
// ── 文本消息 ──
case 'chat-text': {
const newMsg: ChatMessage = {
id: payload.id,
type: 'text',
content: payload.content,
timestamp: payload.timestamp,
sender: 'peer',
status: 'sent',
};
setMessages(prev => [...prev, newMsg]);
break;
}
// ── 打字状态 ──
case 'chat-typing': {
setPeerTyping(payload.typing);
// 自动清除打字状态(防止对方异常断开后一直显示)
if (payload.typing) {
if (peerTypingTimeoutRef.current) clearTimeout(peerTypingTimeoutRef.current);
peerTypingTimeoutRef.current = setTimeout(() => setPeerTyping(false), 5000);
}
break;
}
// ── 图片传输开始 ──
case 'chat-image-start': {
const { id, fileName, fileSize, mimeType, totalChunks, timestamp } = payload;
imageAssemblyRef.current.set(id, {
messageId: id,
fileName,
mimeType,
fileSize,
totalChunks,
receivedChunks: new Map(),
timestamp,
});
// 添加占位消息(显示加载状态)
const placeholderMsg: ChatMessage = {
id,
type: 'image',
content: '', // 空 content 表示加载中
timestamp,
sender: 'peer',
status: 'sending',
fileName,
};
setMessages(prev => [...prev, placeholderMsg]);
break;
}
// ── 图片分块数据 ──
case 'chat-image-chunk': {
const { id, chunkIndex, data } = payload;
const assembly = imageAssemblyRef.current.get(id);
if (!assembly) break;
assembly.receivedChunks.set(chunkIndex, data);
// 检查是否接收完全
if (assembly.receivedChunks.size === assembly.totalChunks) {
// 按顺序拼接 base64
const sortedChunks = Array.from(assembly.receivedChunks.entries())
.sort(([a], [b]) => a - b)
.map(([, d]) => d);
const base64Full = sortedChunks.join('');
// 转换为 Blob URL
const blob = base64ToBlob(base64Full, assembly.mimeType);
const blobUrl = URL.createObjectURL(blob);
// 更新占位消息
setMessages(prev =>
prev.map(m =>
m.id === id ? { ...m, content: blobUrl, status: 'sent' as const } : m
)
);
imageAssemblyRef.current.delete(id);
}
break;
}
}
}, []);
// 注册消息处理器
useEffect(() => {
return connection.registerMessageHandler(CHANNEL_NAME, handleMessage);
}, [connection, handleMessage]);
// ── 发送文本消息 ──
const sendTextMessage = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed || !connection.isPeerConnected) return;
const id = generateId('msg');
const timestamp = Date.now();
// 本地添加
setMessages(prev => [...prev, {
id,
type: 'text',
content: trimmed,
timestamp,
sender: 'me',
status: 'sent',
}]);
// 发送到对方
connection.sendMessage({
type: 'chat-text',
payload: { id, content: trimmed, timestamp },
}, CHANNEL_NAME);
// 停止打字状态
connection.sendMessage({
type: 'chat-typing',
payload: { typing: false },
}, CHANNEL_NAME);
}, [connection]);
// ── 发送图片 ──
const sendImage = useCallback(async (file: File) => {
if (!file.type.startsWith('image/')) return;
if (file.size > MAX_IMAGE_SIZE) return;
if (!connection.isPeerConnected) return;
const id = generateId('img');
const timestamp = Date.now();
// 本地预览(立即显示)
const localUrl = URL.createObjectURL(file);
setMessages(prev => [...prev, {
id,
type: 'image',
content: localUrl,
timestamp,
sender: 'me',
status: 'sending',
fileName: file.name,
}]);
try {
// 读取文件为 base64
const arrayBuffer = await file.arrayBuffer();
const base64Full = arrayBufferToBase64(arrayBuffer);
// 分块
const CHUNK_STR_SIZE = Math.ceil(IMAGE_CHUNK_SIZE * 4 / 3); // base64 编码后的块大小
const chunks: string[] = [];
for (let i = 0; i < base64Full.length; i += CHUNK_STR_SIZE) {
chunks.push(base64Full.slice(i, i + CHUNK_STR_SIZE));
}
// 发送开始标记
connection.sendMessage({
type: 'chat-image-start',
payload: {
id,
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
totalChunks: chunks.length,
timestamp,
},
}, CHANNEL_NAME);
// 逐块发送(带流控)
for (let i = 0; i < chunks.length; i++) {
// 等待缓冲区排空
await connection.waitForBufferDrain(256 * 1024);
connection.sendMessage({
type: 'chat-image-chunk',
payload: { id, chunkIndex: i, data: chunks[i] },
}, CHANNEL_NAME);
}
// 更新本地状态
setMessages(prev =>
prev.map(m => m.id === id ? { ...m, status: 'sent' as const } : m)
);
} catch (error) {
console.error('[Chat] 图片发送失败:', error);
setMessages(prev =>
prev.map(m => m.id === id ? { ...m, status: 'failed' as const } : m)
);
}
}, [connection]);
// ── 打字状态通知 ──
const sendTypingStatus = useCallback(() => {
if (!connection.isPeerConnected) return;
connection.sendMessage({
type: 'chat-typing',
payload: { typing: true },
}, CHANNEL_NAME);
// 自动停止打字状态
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = setTimeout(() => {
if (connection.isPeerConnected) {
connection.sendMessage({
type: 'chat-typing',
payload: { typing: false },
}, CHANNEL_NAME);
}
}, TYPING_TIMEOUT_MS);
}, [connection]);
// ── 清理 ──
const clearMessages = useCallback(() => {
messages.forEach(m => {
if (m.type === 'image' && m.content.startsWith('blob:')) {
URL.revokeObjectURL(m.content);
}
});
setMessages([]);
imageAssemblyRef.current.clear();
}, [messages]);
// 组件卸载时清理定时器
useEffect(() => {
return () => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
if (peerTypingTimeoutRef.current) clearTimeout(peerTypingTimeoutRef.current);
};
}, []);
return {
messages,
peerTyping,
sendTextMessage,
sendImage,
sendTypingStatus,
clearMessages,
isConnected: connection.isConnected,
isConnecting: connection.isConnecting,
isWebSocketConnected: connection.isWebSocketConnected,
isPeerConnected: connection.isPeerConnected,
connectionError: connection.error,
};
}