mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-04-08 13:27:33 +08:00
feat: 重构WebRTC文本和图片传输组件,新增WebRTCChat组件以支持聊天功能,整合消息发送和接收逻辑
This commit is contained in:
@@ -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 />;
|
||||
};
|
||||
|
||||
711
chuan-next/src/components/webrtc/WebRTCChat.tsx
Normal file
711
chuan-next/src/components/webrtc/WebRTCChat.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,4 @@
|
||||
// 文本传输相关的hooks
|
||||
export { useTextTransferBusiness } from './useTextTransferBusiness';
|
||||
export { useChatBusiness } from './useChatBusiness';
|
||||
export type { ChatMessage } from './useChatBusiness';
|
||||
|
||||
327
chuan-next/src/hooks/text-transfer/useChatBusiness.ts
Normal file
327
chuan-next/src/hooks/text-transfer/useChatBusiness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user