import { removeChannelItem } from './channelItems'; import { queueFileCatalog, queueItemAuthor } from './channelThreads'; import { senderSnapshot } from './senderSnapshot'; import { errorMessage } from '../errors'; import { saveResource } from '../database'; import { previewKindOf } from '@/lib/fileKind'; import type { FileItem, FileUploadOptions, Item } from '../types'; import { blobs, getSession } from './sessions'; import { deviceIdentity } from './presentation'; import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer'; import { publishWorkspace } from './presentation'; export async function sendText(resourceId: string, text: string, threadId?: string): Promise<{ itemId: string; uploaded: boolean }> { if (!threadId) throw new Error('请先选择频道'); const { session, resource } = await getSession(resourceId); if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限'); const identity = await deviceIdentity(session.endpointSecret); const device = { ...identity, name: resource.deviceLabel ?? identity.name }; session.deviceName = device.name; const ts = Date.now(); const author = await senderSnapshot(); const plan = session.encryptText(text, { dev: device.name, ts, threadId }); const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) }; blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' })); publishWorkspace(resourceId, { t: 'item', item }); await persistPlan(resource, plan); if (author) await queueItemAuthor(resource, session, plan.itemId, author, threadId, ts); const uploaded = await flushOutbox(resource, session); publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } }); if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' }); return { itemId: plan.itemId, uploaded }; } export async function sendFiles(resourceId: string, files: File[], options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId?: string, folderId?: string): Promise { if (!threadId) throw new Error('请先选择频道'); const { session, resource } = await getSession(resourceId); if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限'); const identity = await deviceIdentity(session.endpointSecret); const device = { ...identity, name: resource.deviceLabel ?? identity.name }; session.deviceName = device.name; const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA; const author = await senderSnapshot(); for (const file of files) { const ts = Date.now(); let item: FileItem | undefined; try { const plan = session.encryptFile(file, {}, { ...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }), dev: device.name, ts, maxFrameData, threadId, folderId, }); item = { id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' }, ...(author ? { author } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), name: file.name, mime: file.type || 'application/octet-stream', size: file.size, remaining: plan.gated ? (options.directPreview ? resource.maxDownloads ?? 10 : options.maxDownloads) || null : null, preview: previewKindOf(file.type, file.name), gated: plan.gated, }; resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item]; await saveResource(resource); blobs.set(`${resourceId}:${item.id}`, file); publishWorkspace(resourceId, { t: 'item', item }); const updateProgress = (progress: number, phase: 'encrypting' | 'uploading', speedBytesPerSecond?: number) => { if (!item) return; item = { ...item, state: { kind: 'receiving', progress: Math.max(0, Math.min(0.99, progress)), phase, ...(speedBytesPerSecond === undefined ? {} : { speedBytesPerSecond }), }, }; publishWorkspace(resourceId, { t: 'item', item }); }; const planned = await persistPlan(resource, plan, (_frames) => { const processed = Math.min(file.size, _frames * MAX_FRAME_DATA); updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting'); }); const uploadedFrames = new Set(); await queueFileCatalog(resource, session, item); let uploadedBytes = 0; const uploadStartedAt = performance.now(); const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => { if (frame.itemId !== item?.id) return; if (complete && !uploadedFrames.has(frame.index)) { uploadedFrames.add(frame.index); uploadedBytes += totalBytes; } const currentBytes = uploadedBytes + (complete ? 0 : loadedBytes); const elapsed = Math.max(1, performance.now() - uploadStartedAt); updateProgress(0.5 + 0.5 * currentBytes / planned.bytes, 'uploading', currentBytes * 1000 / elapsed); }); item = { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } }; resource.localItems = resource.localItems.map((entry) => entry.id === item?.id ? item! : entry); await saveResource(resource); publishWorkspace(resourceId, { t: 'item', item }); if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开空间并连接网络后,可以继续发送已加密内容。' }); } catch (error) { if (item) { item = { ...item, state: { kind: 'incomplete' } }; resource.localItems = (resource.localItems ?? []).map((entry) => entry.id === item?.id ? item! : entry); await saveResource(resource); publishWorkspace(resourceId, { t: 'item', item }); } publishWorkspace(resourceId, { t: 'error', message: `文件上传失败:${errorMessage(error)}` }); throw error; } } } export async function updateSnapshot(resourceId: string, text: string): Promise { const { session, resource } = await getSession(resourceId); const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name; const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name }); await persistPlan(resource, plan); await flushOutbox(resource, session); } export async function editChannelText(resourceId: string, itemId: string, text: string, threadId?: string): Promise { const created = await sendText(resourceId, text, threadId); if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。'); await removeChannelItem(resourceId, itemId); }