import React, { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { CheckCircle, ChevronRight, Clock3, Copy, DownloadCloud, File as FileIcon, Folder, FolderPlus, Link as LinkIcon, Loader2, Monitor, Plus, Send, Shield, Smartphone, Trash2, UploadCloud, X, LogIn, } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '@/src/auth/AuthProvider'; import { Button } from '@/src/components/ui/button'; import { appendTransferRelayHint } from '@/src/lib/transfer-ice'; import { buildTransferShareUrl, getTransferRouterMode } from '@/src/lib/transfer-links'; import { createTransferFileManifest, createTransferFileManifestMessage, createTransferCompleteMessage, createTransferFileCompleteMessage, createTransferFileId, createTransferFileMetaMessage, type TransferFileDescriptor, SIGNAL_POLL_INTERVAL_MS, } from '@/src/lib/transfer-protocol'; import { shouldPublishTransferProgress, resolveTransferChunkSize, } from '@/src/lib/transfer-runtime'; import { createTransferPeer, type TransferPeerAdapter } from '@/src/lib/transfer-peer'; import { DEFAULT_TRANSFER_ICE_SERVERS, TRANSFER_HAS_RELAY_SUPPORT, createTransferSession, listMyOfflineTransferSessions, pollTransferSignals, postTransferSignal, uploadOfflineTransferFile, } from '@/src/lib/transfer'; import type { TransferMode, TransferSessionResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; // We reuse the state and sub-component import { buildQrImageUrl, canSendTransferFiles, getAvailableTransferModes, formatTransferSize, getOfflineTransferSessionLabel, getOfflineTransferSessionSize, getTransferModeSummary, resolveInitialTransferTab, } from '@/src/pages/transfer-state'; import TransferReceive from '@/src/pages/TransferReceive'; type SendPhase = 'idle' | 'creating' | 'waiting' | 'connecting' | 'uploading' | 'transferring' | 'completed' | 'error'; function parseJsonPayload(payload: string): T | null { try { return JSON.parse(payload) as T; } catch { return null; } } function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: string) { if (mode === 'OFFLINE') { switch (phase) { case 'creating': return '正在创建离线会话...'; case 'uploading': return '文件上传中,完成后可保留7天。'; case 'completed': return '离线文件已上传完成,可以多次下载。'; case 'error': return errorMessage || '初始化失败,请重试。'; default: return '离线模式会将文件存至服务端保留7天。'; } } switch (phase) { case 'creating': return '正在准备 P2P 连接...'; case 'waiting': return '已生成二维码,等待接收端扫码或访问链接。'; case 'connecting': return '接收端已进入,正在建立连接...'; case 'transferring': return 'P2P 直连已建立,文件发送中...'; case 'completed': return '本次文件发送完成。'; case 'error': return errorMessage || '快传初始化失败,请重试。'; default: return '文件不经过服务器,浏览器直连互传。'; } } export function getMobileTransferLayoutClassNames() { return { root: 'relative flex min-h-full flex-col bg-transparent', header: 'sticky top-0 z-30 px-4 py-2', headerPanel: 'glass-panel relative overflow-hidden rounded-[24px] border border-white/12 bg-[#0b1528]/82 px-3.5 py-3 shadow-[0_14px_36px_rgba(8,15,30,0.32)] backdrop-blur-2xl', titlePanel: 'relative overflow-hidden rounded-[18px] px-3.5 pt-3 pb-3', content: 'relative z-10 flex-1 flex flex-col min-w-0 px-4 pt-3 pb-6', sendFileList: 'glass-panel rounded-2xl p-2.5', }; } export default function MobileTransfer() { const navigate = useNavigate(); const { ready: authReady, session: authSession } = useAuth(); const [searchParams] = useSearchParams(); const sessionId = searchParams.get('session'); const isAuthenticated = Boolean(authSession?.token); const allowSend = canSendTransferFiles(isAuthenticated); const availableTransferModes = getAvailableTransferModes(isAuthenticated); const [activeTab, setActiveTab] = useState(() => resolveInitialTransferTab(allowSend, sessionId)); const [selectedFiles, setSelectedFiles] = useState([]); const [transferMode, setTransferMode] = useState('ONLINE'); const [session, setSession] = useState(null); const [sendPhase, setSendPhase] = useState('idle'); const [sendProgress, setSendProgress] = useState(0); const [sendError, setSendError] = useState(''); const [copied, setCopied] = useState(false); const [offlineHistory, setOfflineHistory] = useState([]); const [offlineHistoryLoading, setOfflineHistoryLoading] = useState(false); const [offlineHistoryError, setOfflineHistoryError] = useState(''); const [selectedOfflineSession, setSelectedOfflineSession] = useState(null); const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState(null); const layoutClassNames = getMobileTransferLayoutClassNames(); const fileInputRef = useRef(null); const folderInputRef = useRef(null); const copiedTimerRef = useRef(null); const historyCopiedTimerRef = useRef(null); const pollTimerRef = useRef(null); const peerRef = useRef(null); const cursorRef = useRef(0); const bootstrapIdRef = useRef(0); const totalBytesRef = useRef(0); const sentBytesRef = useRef(0); const lastSendProgressPublishAtRef = useRef(0); const lastPublishedSendProgressRef = useRef(0); const sendingStartedRef = useRef(false); const manifestRef = useRef([]); useEffect(() => { if (folderInputRef.current) { folderInputRef.current.setAttribute('webkitdirectory', ''); folderInputRef.current.setAttribute('directory', ''); } return () => { cleanupCurrentTransfer(); if (copiedTimerRef.current) window.clearTimeout(copiedTimerRef.current); if (historyCopiedTimerRef.current) window.clearTimeout(historyCopiedTimerRef.current); }; }, []); useEffect(() => { if (!allowSend || sessionId) setActiveTab('receive'); }, [allowSend, sessionId]); useEffect(() => { if (!availableTransferModes.includes(transferMode)) setTransferMode('ONLINE'); }, [availableTransferModes, transferMode]); useEffect(() => { if (selectedFiles.length > 0) void bootstrapTransfer(selectedFiles); }, [transferMode]); useEffect(() => { if (!isAuthenticated) { setOfflineHistory([]); setOfflineHistoryError(''); setSelectedOfflineSession(null); return; } void loadOfflineHistory(); }, [isAuthenticated]); const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0); const shareLink = session ? buildTransferShareUrl(window.location.origin, session.sessionId, getTransferRouterMode()) : ''; const qrImageUrl = shareLink ? buildQrImageUrl(shareLink) : ''; const transferModeSummary = getTransferModeSummary(transferMode); const selectedOfflineSessionShareLink = selectedOfflineSession ? buildTransferShareUrl(window.location.origin, selectedOfflineSession.sessionId, getTransferRouterMode()) : ''; const selectedOfflineSessionQrImageUrl = selectedOfflineSessionShareLink ? buildQrImageUrl(selectedOfflineSessionShareLink) : ''; function navigateBackToLogin() { const nextPath = `${window.location.pathname}${window.location.search}`; navigate(`/login?next=${encodeURIComponent(nextPath)}`); } function isOfflineSessionReady(sessionToCheck: TransferSessionResponse) { return sessionToCheck.files.every((f) => f.uploaded); } async function loadOfflineHistory(options?: {silent?: boolean}) { if (!isAuthenticated) return; if (!options?.silent) setOfflineHistoryLoading(true); setOfflineHistoryError(''); try { const sessions = await listMyOfflineTransferSessions(); setOfflineHistory(sessions); setSelectedOfflineSession((curr) => curr ? (sessions.find((item) => item.sessionId === curr.sessionId) ?? null) : null); } catch (e) { setOfflineHistoryError(e instanceof Error ? e.message : '离线快传记录加载失败'); } finally { if (!options?.silent) setOfflineHistoryLoading(false); } } function cleanupCurrentTransfer() { if (pollTimerRef.current) { window.clearInterval(pollTimerRef.current); pollTimerRef.current = null; } const peer = peerRef.current; peerRef.current = null; peer?.destroy(); cursorRef.current = 0; lastSendProgressPublishAtRef.current = 0; lastPublishedSendProgressRef.current = 0; sendingStartedRef.current = false; } function publishSendProgress(nextProgress: number, options?: {force?: boolean}) { const normalizedProgress = Math.max(0, Math.min(100, nextProgress)); const now = globalThis.performance?.now?.() ?? Date.now(); if (!options?.force && !shouldPublishTransferProgress({ nextProgress: normalizedProgress, previousProgress: lastPublishedSendProgressRef.current, now, lastPublishedAt: lastSendProgressPublishAtRef.current, })) return; lastSendProgressPublishAtRef.current = now; lastPublishedSendProgressRef.current = normalizedProgress; setSendProgress(normalizedProgress); } function resetSenderState() { cleanupCurrentTransfer(); setSession(null); setSelectedFiles([]); setSendPhase('idle'); publishSendProgress(0, {force: true}); setSendError(''); } async function copyToClipboard(text: string) { try { await navigator.clipboard.writeText(text); setCopied(true); if (copiedTimerRef.current) window.clearTimeout(copiedTimerRef.current); copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1800); } catch { setCopied(false); } } function ensureReadyState(nextFiles: File[]) { setSelectedFiles(nextFiles); if (nextFiles.length === 0) resetSenderState(); else void bootstrapTransfer(nextFiles); } function appendFiles(files: FileList | File[]) { ensureReadyState([...selectedFiles, ...Array.from(files)]); } function handleFileSelect(e: React.ChangeEvent) { if (e.target.files?.length) appendFiles(e.target.files); e.target.value = ''; } function removeFile(idx: number) { ensureReadyState(selectedFiles.filter((_, i) => i !== idx)); } async function bootstrapTransfer(files: File[]) { const bootstrapId = bootstrapIdRef.current + 1; bootstrapIdRef.current = bootstrapId; cleanupCurrentTransfer(); setSendError(''); setSendPhase('creating'); publishSendProgress(0, {force: true}); manifestRef.current = createTransferFileManifest(files); totalBytesRef.current = 0; sentBytesRef.current = 0; try { const createdSession = await createTransferSession(files, transferMode); if (bootstrapIdRef.current !== bootstrapId) return; setSession(createdSession); if (createdSession.mode === 'OFFLINE') { void loadOfflineHistory({silent: true}); await uploadOfflineFiles(createdSession, files, bootstrapId); return; } setSendPhase('waiting'); await setupSenderPeer(createdSession, files, bootstrapId); } catch (e) { if (bootstrapIdRef.current !== bootstrapId) return; setSendPhase('error'); setSendError(e instanceof Error ? e.message : '快传会话创建失败'); } } async function uploadOfflineFiles(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { setSendPhase('uploading'); totalBytesRef.current = files.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true}); for (const [idx, file] of files.entries()) { if (bootstrapIdRef.current !== bootstrapId) return; const sessionFile = createdSession.files[idx]; if (!sessionFile?.id) throw new Error('单次传送配置异常,请重新传输。'); let lastLoaded = 0; await uploadOfflineTransferFile(createdSession.sessionId, sessionFile.id, file, ({ loaded, total }) => { sentBytesRef.current += (loaded - lastLoaded); lastLoaded = loaded; if (loaded >= total) sentBytesRef.current = Math.min(totalBytesRef.current, sentBytesRef.current); if (totalBytesRef.current > 0) publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); }); } publishSendProgress(100, {force: true}); setSendPhase('completed'); void loadOfflineHistory({silent: true}); } async function setupSenderPeer(createdSession: TransferSessionResponse, files: File[], bootstrapId: number) { const peer = createTransferPeer({ initiator: true, peerOptions: { config: { iceServers: DEFAULT_TRANSFER_ICE_SERVERS, }, }, onSignal: (payload) => { void postTransferSignal(createdSession.sessionId, 'sender', 'signal', payload); }, onConnect: () => { if (bootstrapIdRef.current !== bootstrapId) return; setSendPhase(cur => (cur === 'transferring' || cur === 'completed' ? cur : 'connecting')); peer.send(createTransferFileManifestMessage(manifestRef.current)); }, onData: (payload) => { if (typeof payload !== 'string') return; const msg = parseJsonPayload<{type?: string; fileIds?: string[]}>(payload); if (!msg || msg.type !== 'receive-request' || !Array.isArray(msg.fileIds) || sendingStartedRef.current) return; const requestedFiles = manifestRef.current.filter((item) => msg.fileIds?.includes(item.id)); if (requestedFiles.length === 0) return; sendingStartedRef.current = true; totalBytesRef.current = requestedFiles.reduce((sum, f) => sum + f.size, 0); sentBytesRef.current = 0; publishSendProgress(0, {force: true}); void sendSelectedFiles(peer, files, requestedFiles, bootstrapId); }, onError: (error) => { if (bootstrapIdRef.current !== bootstrapId) return; setSendPhase('error'); setSendError(appendTransferRelayHint( error.message || '数据通道建立失败', TRANSFER_HAS_RELAY_SUPPORT, )); }, }); peerRef.current = peer; startSenderPolling(createdSession.sessionId, bootstrapId); } function startSenderPolling(sessionId: string, bootstrapId: number) { let polling = false; pollTimerRef.current = window.setInterval(() => { if (polling || bootstrapIdRef.current !== bootstrapId) return; polling = true; void pollTransferSignals(sessionId, 'sender', cursorRef.current) .then((res) => { if (bootstrapIdRef.current !== bootstrapId) return; cursorRef.current = res.nextCursor; for (const item of res.items) { if (item.type === 'peer-joined') { setSendPhase(cur => (cur === 'waiting' ? 'connecting' : cur)); continue; } if (item.type === 'signal') { peerRef.current?.applyRemoteSignal(item.payload); } } }) .catch(e => { if (bootstrapIdRef.current !== bootstrapId) return; setSendPhase('error'); setSendError(e instanceof Error ? e.message : '状态轮询失败'); }) .finally(() => polling = false); }, SIGNAL_POLL_INTERVAL_MS); } async function sendSelectedFiles( peer: TransferPeerAdapter, files: File[], requestedFiles: TransferFileDescriptor[], bootstrapId: number, ) { setSendPhase('transferring'); const filesById = new Map(files.map((f) => [createTransferFileId(f), f])); const chunkSize = resolveTransferChunkSize(); for (const desc of requestedFiles) { if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return; const file = filesById.get(desc.id); if (!file) continue; peer.send(createTransferFileMetaMessage(desc)); for (let offset = 0; offset < file.size; offset += chunkSize) { if (bootstrapIdRef.current !== bootstrapId || !peer.connected) return; const chunk = await file.slice(offset, offset + chunkSize).arrayBuffer(); await peer.write(chunk); sentBytesRef.current += chunk.byteLength; if (totalBytesRef.current > 0) publishSendProgress(Math.min(99, Math.round((sentBytesRef.current / totalBytesRef.current) * 100))); } peer.send(createTransferFileCompleteMessage(desc.id)); } peer.send(createTransferCompleteMessage()); publishSendProgress(100, {force: true}); setSendPhase('completed'); } async function copyOfflineSessionLink(s: TransferSessionResponse) { const sl = buildTransferShareUrl(window.location.origin, s.sessionId, getTransferRouterMode()); await navigator.clipboard.writeText(sl); setHistoryCopiedSessionId(s.sessionId); if (historyCopiedTimerRef.current) window.clearTimeout(historyCopiedTimerRef.current); historyCopiedTimerRef.current = window.setTimeout(() => { setHistoryCopiedSessionId((curr) => (curr === s.sessionId ? null : curr)); }, 1800); } return (
快传
{allowSend && (
)}
{authReady && !isAuthenticated && (

无需登录即可在线发送、在线接收和离线接收。只有发离线和把离线文件存入网盘时才需要登录。

)} {activeTab === 'send' ? ( {selectedFiles.length === 0 ? (

选择要发送的文件

选择在线模式建立一次性P2P链接
离线模式可将文件上传至服务端保存7天

{availableTransferModes.length > 1 && (
{availableTransferModes.map(mode => ( ))}
)}
) : (

取件码

{session?.pickupCode ?? '...'}
{qrImageUrl && }
{/* 状态区 */}
{sendPhase === 'completed' ? : }

{getPhaseMessage(transferMode, sendPhase, sendError)}

{sendPhase !== 'error' && sendPhase !== 'completed' &&
}
{/* 文件列表 */}

共 {selectedFiles.length} 项 / {formatTransferSize(totalSize)}

{selectedFiles.map((f, i) => (

{f.name}

{formatTransferSize(f.size)}

))}
)} {/* 离线区简略版 */} {isAuthenticated && activeTab === 'send' && selectedFiles.length === 0 && (

最近的离线快传

{offlineHistoryLoading && offlineHistory.length === 0 ?

加载中...

: offlineHistory.length === 0 ?

暂无离线快传记录

: offlineHistory.map(session => (
setSelectedOfflineSession(session)} className="glass-panel p-3 rounded-xl flex items-center justify-between hover:bg-white/5 active:bg-white/10">

{session.pickupCode} {getOfflineTransferSessionLabel(session)}

{isOfflineSessionReady(session) ? '可接收' : '处理中'} • {session.files.length}个文件 • {getOfflineTransferSessionSize(session)}

))}
)} ) : ( )}
{/* Offline History Modal Mobile */} {selectedOfflineSession && (

取件码

{selectedOfflineSession.pickupCode}
{selectedOfflineSessionQrImageUrl && }
)}
); }