import {createCryptoBackend} from './crypto-backend.js' import {getServers} from './servers.js' import {createProgressRing} from './progress.js' import {t} from './i18n.js' import { newXFTPAgent, closeXFTPAgent, uploadFile, encodeDescriptionURI, type EncryptedFileMetadata } from '../src/agent.js' import {getDescriptionServers, serverOrigin} from '../src/protocol/address.js' import {XFTPPermanentError} from '../src/client.js' const MAX_SIZE = 100 * 1024 * 1024 const ENCRYPT_WEIGHT = 0.15 const ENCRYPT_MIN_FILE_SIZE = 100 * 1024 const ENCRYPT_MIN_DISPLAY_MS = 1000 export function initUpload(app: HTMLElement) { app.innerHTML = `

${t('title', 'SimpleX File Transfer')}

${t('dropZone', 'Drag & drop a file here')}

${t('dropZoneHint', 'or')}

${t('maxSizeHint', 'Max 100 MB')}

` const dropZone = document.getElementById('drop-zone')! const fileInput = document.getElementById('file-input') as HTMLInputElement const progressStage = document.getElementById('upload-progress')! const completeStage = document.getElementById('upload-complete')! const errorStage = document.getElementById('upload-error')! const progressContainer = document.getElementById('progress-container')! const statusText = document.getElementById('upload-status')! const cancelBtn = document.getElementById('cancel-btn')! const shareLink = document.getElementById('share-link') as HTMLInputElement const copyBtn = document.getElementById('copy-btn')! const errorMsg = document.getElementById('error-msg')! const retryBtn = document.getElementById('retry-btn')! const shareBtn = typeof navigator.share === 'function' ? (() => { const btn = document.createElement('button') btn.className = 'btn btn-secondary' btn.textContent = t('share', 'Share') shareLink.parentElement!.appendChild(btn) return btn })() : null let aborted = false let pendingFile: File | null = null dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over') }) dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')) dropZone.addEventListener('drop', e => { e.preventDefault() dropZone.classList.remove('drag-over') const f = e.dataTransfer?.files[0] if (f) startUpload(f) }) fileInput.addEventListener('change', () => { if (fileInput.files?.[0]) startUpload(fileInput.files[0]) }) retryBtn.addEventListener('click', () => { pendingFile = null fileInput.value = '' showStage(dropZone) }) function showStage(stage: HTMLElement) { for (const s of [dropZone, progressStage, completeStage, errorStage]) s.hidden = true stage.hidden = false } function showError(msg: string) { errorMsg.innerHTML = msg showStage(errorStage) } async function startUpload(file: File) { pendingFile = file aborted = false if (file.size > MAX_SIZE) { showError(t('fileTooLarge', 'File too large (%size%). Maximum is 100 MB. The SimpleX app supports files up to 1 GB.').replace('%size%', formatSize(file.size))) return } if (file.size === 0) { showError(t('fileEmpty', 'File is empty.')) return } showStage(progressStage) const ring = createProgressRing() progressContainer.innerHTML = '' progressContainer.appendChild(ring.canvas) const showEncrypt = file.size >= ENCRYPT_MIN_FILE_SIZE const encryptWeight = showEncrypt ? ENCRYPT_WEIGHT : 0 statusText.textContent = showEncrypt ? t('encrypting', 'Encrypting\u2026') : t('uploading', 'Uploading\u2026') const backend = createCryptoBackend() const agent = newXFTPAgent() cancelBtn.onclick = () => { aborted = true ring.destroy() backend.cleanup().catch(() => {}) closeXFTPAgent(agent) showStage(dropZone) } try { const encryptStart = performance.now() const fileData = new Uint8Array(await file.arrayBuffer()) if (aborted) return const encrypted = await backend.encrypt(fileData, file.name, (done, total) => { ring.update((done / total) * encryptWeight) }) if (aborted) return if (showEncrypt) { const elapsed = performance.now() - encryptStart if (elapsed < ENCRYPT_MIN_DISPLAY_MS) { await ring.fillTo(encryptWeight, ENCRYPT_MIN_DISPLAY_MS - elapsed) if (aborted) return } statusText.textContent = t('uploading', 'Uploading\u2026') } const metadata: EncryptedFileMetadata = { digest: encrypted.digest, key: encrypted.key, nonce: encrypted.nonce, chunkSizes: encrypted.chunkSizes } const servers = getServers() const result = await uploadFile(agent, servers, metadata, { readChunk: (off, sz) => backend.readChunk(off, sz), onProgress: (uploaded, total) => { ring.update(encryptWeight + (uploaded / total) * (1 - encryptWeight)) } }) if (aborted) return const descServers = getDescriptionServers(result.rcvDescription) const origin = descServers.length > 0 ? serverOrigin(descServers[0]) : window.location.origin const url = origin + window.location.pathname + '#' + result.uri shareLink.value = url showStage(completeStage) app.dispatchEvent(new CustomEvent('xftp:upload-complete', {detail: {url}, bubbles: true})) copyBtn.onclick = () => { navigator.clipboard.writeText(url).then(() => { copyBtn.textContent = t('copied', 'Copied!') setTimeout(() => { copyBtn.textContent = t('copy', 'Copy') }, 2000) }) } if (shareBtn) { shareBtn.onclick = () => navigator.share({url}).catch(() => {}) } } catch (err: any) { if (!aborted) { const msg = err?.message ?? String(err) showError(msg) // Hide retry button for permanent errors (no point retrying) if (err instanceof XFTPPermanentError) retryBtn.hidden = true else retryBtn.hidden = false } } finally { ring.destroy() await backend.cleanup().catch(() => {}) closeXFTPAgent(agent) } } } function formatSize(bytes: number): string { if (bytes < 1024) return bytes + ' B' if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' return (bytes / (1024 * 1024)).toFixed(1) + ' MB' }