"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.type === 'image' && (
{message.content ? (

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' ? (
/* ── 创建房间 ── */
{/* 功能标题 + 状态栏 */}
创建聊天房间
创建房间后双方可以互相发送文字和图片
) : (
/* ── 加入房间 ── */
)}
)}
{/* ── 阶段 2: 房间已创建/加入 ── */}
{!isSetup && (
{!connection.isPeerConnected ? (
/* ── 等待对方加入: loading + QR 一体卡片 ── */
{/* 标题栏 */}
{/* Loading 等待 */}
{[...Array(3)].map((_, i) => (
))}
等待对方加入中...
对方加入后即可开始聊天
{/* QR / 取件码 - 与 loading 同一卡片内 */}
{roomCode && mode === 'send' && (
)}
) : (
/* ── 已连接: 聊天窗口 ── */
{/* 功能标题和状态 */}
{/* 聊天区域 */}
{/* 消息列表 */}
{chat.messages.length === 0 ? (
连接已建立,开始发送消息吧!
双方都可以发送文字和图片
) : (
chat.messages.map((msg) => (
))
)}
{/* 打字指示器 */}
{chat.peerTyping &&
}
{/* 输入栏 */}
{/* 图片按钮 */}
{/* 文本输入 */}
{/* 发送按钮 */}
支持粘贴图片 (Ctrl+V) · 图片最大 5MB
)}
)}
{/* ── 图片预览模态框 ── */}
{previewImage && (
setPreviewImage(null)}
>
e.stopPropagation()}>
)}
{/* 隐藏的文件输入 */}
);
};