"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 (
{/* 文本消息 */} {message.type === 'text' && (
              {message.content}
            
)} {/* 图片消息 */} {message.type === 'image' && (
{message.content ? ( {message.fileName onPreviewImage?.(message.content)} loading="lazy" /> ) : (
接收中...
)} {message.status === 'sending' && (
发送中
)}
)} {/* 时间 + 操作 */}
{timeStr} {message.status === 'failed' && ( 发送失败 )} {/* 文本复制按钮 */} {message.type === 'text' && ( )} {/* 图片保存提示 */} {message.type === 'image' && message.content && ( )}
); }; // ── 打字指示器 ── const TypingIndicator: React.FC = () => (
对方正在输入
); // ── 主组件 ── 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(null); // Refs const fileInputRef = useRef(null); const messagesEndRef = useRef(null); const textareaRef = useRef(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) => { 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) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend]); // ── 图片处理 ── const handleImageSelect = useCallback((e: React.ChangeEvent) => { 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 (
{/* 模式切换 - 与文件传输/桌面共享统一风格 */} {isSetup && (
)} {/* ── 阶段 1: 创建/加入房间 ── */} {isSetup && (
{mode === 'send' ? ( /* ── 创建房间 ── */
{/* 功能标题 + 状态栏 */}

双向消息

创建房间后双方可以互相发送文字和图片

创建聊天房间

创建房间后双方可以互相发送文字和图片

) : ( /* ── 加入房间 ── */

加入聊天房间

输入 6 位取件码加入房间

{ e.preventDefault(); joinRoom(inputCode); }} className="space-y-4" >
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} />

{inputCode.length}/6 位

)}
)} {/* ── 阶段 2: 房间已创建/加入 ── */} {!isSetup && (
{!connection.isPeerConnected ? ( /* ── 等待对方加入: loading + QR 一体卡片 ── */
{/* 标题栏 */}

双向消息

房间代码: {roomCode}

{/* Loading 等待 */}
{[...Array(3)].map((_, i) => (
))}
等待对方加入中...

对方加入后即可开始聊天

{/* QR / 取件码 - 与 loading 同一卡片内 */} {roomCode && mode === 'send' && (
)}
) : ( /* ── 已连接: 聊天窗口 ── */
{/* 功能标题和状态 */}

双向消息

房间代码: {roomCode}

{/* 聊天区域 */}
{/* 消息列表 */}
{chat.messages.length === 0 ? (

连接已建立,开始发送消息吧!

双方都可以发送文字和图片

) : ( chat.messages.map((msg) => ( )) )} {/* 打字指示器 */} {chat.peerTyping && }
{/* 输入栏 */}
{/* 图片按钮 */} {/* 文本输入 */}