Files
PrivyDrop/frontend/components/ClipboardApp.tsx
T
2025-05-23 22:41:56 +08:00

571 lines
24 KiB
TypeScript

"use client";
import React, { useState, useEffect , useRef, useCallback,useMemo } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import WebRTC_Initiator from '../lib/webrtc_Initiator';
import WebRTC_Recipient from '../lib/webrtc_Recipient';
import FileReceiver from '../lib/fileReceiver';
import FileSender from '../lib/fileSender';
import { debounce } from 'lodash';
import FileListDisplay from './self_define/FileListDisplay';
import {FileMeta,CustomFile,fileMetadata } from '@/lib/types/file';
import {WriteClipboardButton,ReadClipboardButton} from './self_define/clipboard_btn';
import useRichTextToPlainText from './self_define/rich-text-to-plain-text';
import QRCodeComponent from './self_define/RetrieveMethod';
import {FileUploadHandler,DownloadAs} from './self_define/file-upload-handler';
import JSZip from 'jszip';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip } from './Tooltip';
import RichTextEditor from '@/components/Editor/RichTextEditor'
import { config } from '@/app/config/environment';
import { fetchRoom, createRoom, checkRoom } from '@/app/config/api';
import {trackReferrer} from '@/components/utils/tracking'
import { postLogInDebug } from '@/app/config/api';
import AnimatedButton from './self_define/AnimatedButton';
import {format_peopleMsg } from '@/utils/formatMessage';
import { getDictionary } from '@/lib/dictionary';
import { useLocale } from '@/hooks/useLocale';
import type { Messages } from '@/types/messages';
const developmentEnv = process.env.NEXT_PUBLIC_development!;//开发环境
// 处理 beforeunload 事件的函数
const handleBeforeUnload = (event:any) => {
event.preventDefault();
event.returnValue = ''; // This is required for older browsers
};
// 当用户确实想要离开页面时(例如,在保存数据后),可以调用此函数移除事件监听器
function allowUnload() {
window.removeEventListener('beforeunload', handleBeforeUnload);
}
const AdvancedClipboardApp = () => {
const [shareRoomId, setShareRoomId] = useState('');//发送端--房间ID
const [initShareRoomId, setInitShareRoomId] = useState('');//系统随机初始化的房间ID
const [retrieveRoomId, setRetrieveRoomId] = useState('');//接收端--房间ID
const [shareMessage, setShareMessage] = useState('');//发送端--消息显示
const [retrieveMessage, setRetrieveMessage] = useState('');//接收端--消息显示
//发送端:编辑器文本、文件
const [shareContent, setShareContent] = useState('');
const [sendFiles, setSendFiles] = useState<CustomFile[]>([]);//FILE对象只会先引用文件,并不会将文件内容读取进内存。只有当分片读取时,才加载一小片到内存。理论上支持大文件。
const [sendProgress, setSendProgress] = useState<{//文件的进度--发送端--{fileId:0-1}--支持区分多个接收端
[fileId: string]: {
[peerId: string]: { progress: number; speed: number;}
}}>({});
const [receiveProgress, setReceiveProgress] = useState<{//文件的进度--接收端--{fileId:0-1}--目前只有一个发送端(为了和发送进度保持一致)
[fileId: string]: {
[peerId: string]: { progress: number; speed: number;}
}}>({});
// 取回端:编辑器文本、文件
const [retrievedContent, setRetrievedContent] = useState('');
const [retrievedFiles, setRetrievedFiles] = useState<CustomFile[]>([]);
const [retrievedFileMetas, setRetrievedFileMetas] = useState<FileMeta[]>([]);//接收到的meta信息
//初始化 p2p通信/文件传输 对象
const [sender, setSender] = useState<WebRTC_Initiator | null>(null);
const [receiver, setReceiver] = useState<WebRTC_Recipient | null>(null);
const [senderFileTransfer, setSenderFileTransfer] = useState<FileSender | null>(null);
const [receiverFileTransfer, setReceiverFileTransfer] = useState<FileReceiver | null>(null);
const [shareLink, setShareLink] = useState('');//分享链接
const retrieveJoinRoomBtnRef = useRef<HTMLButtonElement>(null);//接收方--加入房间按钮ref
const [activeTab, setActiveTab] = useState('send');//代表tab的当前激活窗口
const richTextToPlainText = useRichTextToPlainText();
// 房间状态--显示
const [shareRoomStatus, setShareRoomStatus] = useState('');
const [retrieveRoomStatus, setRetrieveRoomStatus] = useState('');
// 1. 添加一个状态来追踪连接数量
const [sharePeerCount, setSharePeerCount] = useState(0);
const [retrievePeerCount, setRetrievePeerCount] = useState(0);
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
//显示消息一段时间后清除,shareEnd是否是发送端
async function putMessageInMs(message:string,shareEnd=true,displayTime_ms:number=4000) {
if (shareEnd){
setShareMessage(message);
setTimeout(() => setShareMessage(''), displayTime_ms);
}else{
setRetrieveMessage(message);
setTimeout(() => setRetrieveMessage(''), displayTime_ms);
}
}
useEffect(() => {
getDictionary(locale)
.then(dict => setMessages(dict))
.catch(error => console.error('Failed to load messages:', error));
}, [locale]);
// 使用 useEffect 钩子来在组件加载时生成一个随机ID
useEffect(() => {
const initRoom = async () => {
try {
const roomId = await fetchRoom();
setShareRoomId(roomId);
setInitShareRoomId(roomId);
} catch (err) {
console.error('Error fetching room:', err);
putMessageInMs(messages!.text.ClipboardApp.fetchRoom_err);
}
};
initRoom();
}, [messages]);
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeUnload);
if (sendFiles.length==0 && shareContent=='' && retrievedFiles.length==0 && retrievedContent==''){//如果页面不存在任何内容,则不阻止刷新或离开
allowUnload();
}
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [sendFiles,shareContent,retrievedContent,retrievedFiles]);
useEffect(() => {
trackReferrer();
// 检查URL中是否包含roomId参数,是--直接切换到取回界面并点击“加入房间”
const urlParams = new URLSearchParams(window.location.search);
const roomIdParam = urlParams.get('roomId');
if (roomIdParam) {
setRetrieveRoomId(roomIdParam);
setActiveTab('retrieve');
// 使用 setTimeout 来确保在 DOM 更新后触发点击
setTimeout(() => {
if (retrieveJoinRoomBtnRef.current) {
retrieveJoinRoomBtnRef.current.click();
}
}, 200);
}
}, []);
const debouncedCheckRoom = useMemo(
() => debounce(async (roomId: string): Promise<boolean> => {
const available = await checkRoom(roomId);
return available;
}, 300),
[]
);
const handleShareRoomCheck = async (roomId: string) => {
if(roomId.length === 0){
putMessageInMs(messages!.text.ClipboardApp.roomCheck.empty_msg);
return;
}
const available = await debouncedCheckRoom(roomId);
if (available) {
putMessageInMs(messages!.text.ClipboardApp.roomCheck.available_msg);
setShareRoomId(roomId);
} else {
putMessageInMs(messages!.text.ClipboardApp.roomCheck.notAvailable_msg);
}
};
//useCallback 钩子来定义 函数。这确保了函数只在其依赖项(content, files, senderFileTransfer)发生变化时才会重新创建
const sendStringAndMetas = useCallback(async (peerId: string) => {
if (!senderFileTransfer) {
console.error('senderFileTransfer is not initialized, delaying send operation...');
// 重试逻辑改为异步
setTimeout(async () => {
if (senderFileTransfer) {
console.log('Retrying send operation...');
if (shareContent) await (senderFileTransfer as FileSender).sendString(shareContent, peerId);
if (sendFiles.length) (senderFileTransfer as FileSender).sendFileMeta(sendFiles, peerId);
}
}, 1000);
return;
}
if (shareContent) {
if (developmentEnv === 'true')postLogInDebug(`Sending string content:${shareContent}`);
// console.log('Sending string content:', shareContent);
await senderFileTransfer.sendString(shareContent, peerId);
}
if (sendFiles.length) {
// console.log('Sending file metadata:', sendFiles);
senderFileTransfer.sendFileMeta(sendFiles, peerId);
}
}, [shareContent, sendFiles, senderFileTransfer]);
// 使用useEffect钩子来 在组件加载时 初始化,并在组件卸载时清理连接
useEffect(() => {
const senderConnection = new WebRTC_Initiator(config.API_URL);
const receiverConnection = new WebRTC_Recipient(config.API_URL);
setSender(senderConnection);
setReceiver(receiverConnection);
const senderFT = new FileSender(senderConnection);
const receiverFT = new FileReceiver(receiverConnection);
console.log('Created file transfer instances');
setSenderFileTransfer(senderFT);
setReceiverFileTransfer(receiverFT);
return () => {
senderConnection.cleanUpBeforeExit();
receiverConnection.cleanUpBeforeExit();
};
}, []);
//定义一些文件接收处理函数
useEffect(() => {
if (sender && senderFileTransfer) {
sender.onConnectionStateChange = (state: RTCPeerConnectionState, peerId: string) => {
console.log(`connection status: ${state} with peerId: ${peerId}`);
setSharePeerCount(sender.peerConnections.size);
if(state === "connected"){//当建立连接后,设置进度回调函数
senderFileTransfer?.setProgressCallback((fileId:string, progress:number, speed:number) => {
setSendProgress(prev => ({
...prev,
[fileId]: {
...prev[fileId],
[peerId]: { progress, speed }
}
}));
}, peerId);
}
};
sender.onDataChannelOpen = sendStringAndMetas;
}
if (receiver && receiverFileTransfer) {
receiver.onConnectionStateChange = (state: string, peerId: string) => {
console.log(`connection status: ${state} with peerId: ${peerId}`);
setRetrievePeerCount(receiver.peerConnections.size);
if(state === "connected"){
receiverFileTransfer?.setProgressCallback((fileId:string, progress:number, speed:number) => {
setReceiveProgress(prev => ({
...prev,
[fileId]: {
...prev[fileId],
[peerId]: { progress, speed }
}
}));
});
}
};
// receiver.onDataChannelOpen = () => {
// putMessageInMs(messages!.text.ClipboardApp.channelOpen_msg,false);
// };
}
if (receiverFileTransfer) {
receiverFileTransfer.onStringReceived = (value: string) => {
setRetrievedContent(value);
};
receiverFileTransfer.onFileMetaReceived = (fileMeta: fileMetadata) => {
const { type, ...metaWithoutType } = fileMeta; // 剔除 type 属性
setRetrievedFileMetas(prev => [...prev, metaWithoutType]);
};
receiverFileTransfer.onFileReceived = async (file:CustomFile) => {
setRetrievedFiles(prev => {
// 检查 fullName 是否已经存在
const isDuplicate = prev.some(existingFile => existingFile.fullName === file.fullName);
if (isDuplicate) {
return prev; // 如果存在,返回原数组
}
return [...prev, file]; // 否则添加到数组中
});
};
}
}, [sender, receiver,senderFileTransfer,receiverFileTransfer, sendStringAndMetas, messages]);
//只有接收端支持下载
const handleDownload = async (meta: FileMeta) => {
if (meta.folderName !== ""){
const downloadFiles = retrievedFiles.filter(file => file.folderName === meta.folderName);
const zip = new JSZip();
for(let file of downloadFiles)
zip.file(file.fullName, file);// Add files to the zip
try {
// Generate the zip file
const content = await zip.generateAsync({ type: 'blob' });
DownloadAs(content,`downloaded_folder_${meta.folderName}.zip`);
} catch (error) {
console.error('Error creating zip file:', error);
// alert('An error occurred while creating the zip file.');
}
}else {
const downloadFiles = retrievedFiles.filter(file => file.name === meta.name);
for(let file of downloadFiles)
DownloadAs(file,file.name);
}
};
const onFilePicked = (files:CustomFile[]) => {
setSendFiles(prevFiles => [...prevFiles, ...files]);
};
//点击删除按钮之后,将对应文件删掉
const removeSenderFile = (meta: FileMeta) => {
let updatedFiles = [];
if (meta.folderName !== ""){
updatedFiles = sendFiles.filter(file => file.folderName !== meta.folderName);
}else {
updatedFiles = sendFiles.filter(file => file.name !== meta.name);
}
setSendFiles(updatedFiles);
};
// 分享内容的处理函数
const handleShare = async () => {
// console.log('handleShare',sender);
if (!sender) return;
if (sender.peerConnections.size === 0) {
setShareMessage(messages!.text.ClipboardApp.waitting_tips);
} else {
// 广播给所有连接方
const peerIds = Array.from(sender.peerConnections.keys());
// 使用 Promise.all 并行发送给所有peer
await Promise.all(peerIds.map(peerId => sendStringAndMetas(peerId)));
}
// 生成分享链接,并展示获取方法
const link = `${window.location.origin}${window.location.pathname}?roomId=${shareRoomId}`;
setShareLink(link);
};
// 加入房间,等有人进入后会自动建立连接
const handleJoinRoom = async (isSender: boolean) => {
if(!sender || !receiver)return;
// 根据 isSender 确定使用的变量
const roomId = isSender ? shareRoomId : retrieveRoomId;
const peer = isSender ? sender : receiver;
// 检查房间 ID
if (!roomId) {
putMessageInMs(messages!.text.ClipboardApp.joinRoom.EmptyMsg,isSender);
return;
}
// 只有发送方能创建房间
if (isSender && activeTab === 'send' && !peer.isInRoom) {
if (roomId !== initShareRoomId){//如果是系统初始化的RoomID,则不需要重复创建房间
const success = await createRoom(roomId);
if (!success) {
putMessageInMs(messages!.text.ClipboardApp.joinRoom.DuplicateMsg,isSender);
return;
}
}
}
try {
await peer.joinRoom(roomId, isSender);
// 成功加入房间后的逻辑
putMessageInMs(messages!.text.ClipboardApp.joinRoom.successMsg,isSender,6000);
// 生成分享链接,并展示获取方法
const link = `${window.location.origin}${window.location.pathname}?roomId=${shareRoomId}`;
setShareLink(link);
} catch (error) {
if (error instanceof Error) {
if (error.message === "Room does not exist") {
putMessageInMs(messages!.text.ClipboardApp.joinRoom.notExist,isSender);
} else {
putMessageInMs(messages!.text.ClipboardApp.joinRoom.failMsg+` ${error.message}`,isSender);
}
console.error('Failed to join room:', error.message);
} else {
console.error('Failed to join room with unknown error', error);
}
// 处理加入房间失败的逻辑
}
};
//选择保存目录
const onLocationPick = async (): Promise<boolean> => {
// 检查浏览器是否支持 showDirectoryPicker
if (!window.showDirectoryPicker) {
console.error("showDirectoryPicker is not supported in this browser.");
return false;
}
// 确认操作
const userConfirmed = window.confirm(messages!.text.ClipboardApp.pickSaveMsg);
if (!userConfirmed) {
return false;
}
try {
// 选择保存目录
const directory = await window.showDirectoryPicker();
if (receiverFileTransfer && directory) {
console.log('onLocationPick',directory);
await receiverFileTransfer.setSaveDirectory(directory);
return true;
} else {
return false;
}
} catch (err) {
console.error("Failed to set up folder receive:", err);
return false;
}
};
const handleRequest = async (meta: FileMeta) => {
if(!receiverFileTransfer)return;
if(meta.folderName){
receiverFileTransfer.requestFolder(meta.folderName);
} else {
receiverFileTransfer.requestFile(meta.fileId);
}
}
//更新房间状态
useEffect(() => {
const Peer = activeTab === 'send' ? sender : receiver;
const peerCount = activeTab === 'send' ? sharePeerCount : retrievePeerCount;
let status = '';
if (Peer && messages) {
if (!Peer.isInRoom) {
status = activeTab === 'retrieve'
? messages.text.ClipboardApp.roomStatus.receiverEmptyMsg
: messages.text.ClipboardApp.roomStatus.senderEmptyMsg;
} else if (peerCount === 0) {
status = messages.text.ClipboardApp.roomStatus.onlyOneMsg;
} else {
if (activeTab === 'send'){
status = format_peopleMsg(messages.text.ClipboardApp.roomStatus.peopleMsg_template,peerCount+1);
}
else{
status = messages.text.ClipboardApp.roomStatus.connected_dis;
}
}
}
if (activeTab === 'send') {
setShareRoomStatus(status);
} else {
setRetrieveRoomStatus(status);
}
}, [activeTab, sharePeerCount, retrievePeerCount, sender?.isInRoom, receiver?.isInRoom, sender, receiver, messages]);
if (messages === null) {
return <div>Loading...</div>;
}
return (
<div className="container mx-auto px-4 py-8 w-full md:max-w-4xl">
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
<Button
variant={activeTab === 'send' ? 'default' : 'outline'}
onClick={() => setActiveTab('send')}
className="flex-1"
>
{messages.text.ClipboardApp.html.senderTab}
</Button>
<Button
variant={activeTab === 'retrieve' ? 'default' : 'outline'}
onClick={() => setActiveTab('retrieve')}
className="flex-1"
>
{messages.text.ClipboardApp.html.retrieveTab}
</Button>
</div>
<Card className="border-8 shadow-md">
<CardHeader>
<CardTitle>{activeTab === 'send' ?messages.text.ClipboardApp.html.shareTitle_dis:messages.text.ClipboardApp.html.retrieveTitle_dis}</CardTitle>
</CardHeader>
<CardContent>
{activeTab === 'send' ? (
<>
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
{shareRoomStatus &&
<span>{`${messages.text.ClipboardApp.html.RoomStatus_dis} ${shareRoomStatus}`}</span>
}
</div>
<RichTextEditor
value={shareContent}
onChange={(value) => setShareContent(value)}
/>
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
<ReadClipboardButton title={messages.text.ClipboardApp.html.Paste_dis} onRead={(text:string) => setShareContent(text)}/>
<WriteClipboardButton title={messages.text.ClipboardApp.html.Copy_dis} textToCopy={richTextToPlainText(shareContent)}/>
</div>
<div className="mb-2">
<FileUploadHandler onFilePicked={onFilePicked} />
<FileListDisplay
mode="sender"
files={sendFiles}
fileProgresses={sendProgress}
onDelete={removeSenderFile}
/>
</div>
<div className="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-2 mb-2">
<span>{messages.text.ClipboardApp.html.inputRoomId_tips}</span>
<Input
value={shareRoomId}//展示值
onChange={(e) => handleShareRoomCheck(e.target.value)}
className="w-full md:w-36 border-2 border-gray-300 rounded-md px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all duration-200"
/>
<Button className="w-full"
onClick={() => handleJoinRoom(true)}
disabled={ sender? sender.isInRoom : false }//如果已经在房间则停用进入房间功能
>{messages.text.ClipboardApp.html.joinRoom_dis}</Button>
</div>
<div className="flex space-x-2 mb-2">
<AnimatedButton
className="w-full"
onClick={handleShare}
loadingText={messages.text.ClipboardApp.html.startSending_loadingText}
>
{messages.text.ClipboardApp.html.startSending_dis}
</AnimatedButton>
</div>
{shareMessage && <p className="mb-4">{shareMessage}</p>}
</>
):(
<>
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
{retrieveRoomStatus &&
<span>{`${messages.text.ClipboardApp.html.RoomStatus_dis} ${retrieveRoomStatus}`}</span>
}
</div>
<div className="mb-4">
<ReadClipboardButton title={messages.text.ClipboardApp.html.readClipboard_dis} onRead={(text:string) => setRetrieveRoomId(text)}/>
</div>
<div className="mb-4">
<Input
value={retrieveRoomId}
onChange={(e) => setRetrieveRoomId(e.target.value)}
placeholder={messages.text.ClipboardApp.html.retrieveRoomId_placeholder}
className="w-full md:w-36 border-2 border-gray-300 rounded-md px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all duration-200"
/>
</div>
<div className="mb-4">
<Button className="w-full"
onClick={() => handleJoinRoom(false)}
ref={retrieveJoinRoomBtnRef}
disabled={ receiver? receiver.isInRoom : false }//如果已经在房间则停用进入房间功能
>{messages.text.ClipboardApp.html.joinRoom_dis}</Button>
</div>
{retrievedContent && (
<>
<RichTextEditor value={retrievedContent} onChange={ (value) => setRetrievedContent(value)}/>
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-2">
<WriteClipboardButton title={messages.text.ClipboardApp.html.Copy_dis} textToCopy={richTextToPlainText(retrievedContent)}/>
</div>
</>
)}
<FileListDisplay
mode="receiver"
files={retrievedFileMetas}
fileProgresses={receiveProgress}
onDownload={handleDownload}
onRequest={handleRequest}
onLocationPick={onLocationPick}
saveType={receiverFileTransfer?.saveType}
/>
{retrieveMessage && <p className="mb-4">{retrieveMessage}</p>}
</>
)}
</CardContent>
</Card>
{activeTab === 'send' && shareLink !== '' &&(
<Card className="border-2 shadow-md">
<CardHeader>
<CardTitle>{messages.text.ClipboardApp.html.RetrieveMethodTitle}</CardTitle>
</CardHeader>
<CardContent>
<QRCodeComponent RoomID={shareRoomId} shareLink={shareLink} />
</CardContent>
</Card>
)
}
</div>
);
};
export default AdvancedClipboardApp;