diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0def138e..d445392c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -45,6 +45,7 @@ import { parseRecipientList, formatRecipientList, splitPastedRecipients, + waitForPendingUploads, type Recipient, } from "@/lib/email-composer-utils"; import { RichTextEditor } from "@/components/email/rich-text-editor"; @@ -1413,6 +1414,7 @@ export function EmailComposer({ const canSend = toAddresses.length > 0 && !!subject && hasContent; const getSendTooltip = (): string | undefined => { + if (isWaitingForUploads) return t('validation.attachments_uploading'); if (canSend) return undefined; if (toAddresses.length === 0) return t('validation.recipient_required'); if (!subject) return t('validation.subject_required'); @@ -1498,8 +1500,43 @@ export function EmailComposer({ const [isSending, setIsSending] = useState(false); const isSendingRef = useRef(false); + // Attachments still uploading when Send is clicked used to be silently + // dropped from the outgoing message (the filters below exclude anything + // with uploading:true). attachmentsRef gives handleSend a way to read the + // freshest attachment state after waiting on in-flight uploads, since the + // `attachments` closure captured at click time won't reflect uploads that + // finish during that wait. + const attachmentsRef = useRef(attachments); + useEffect(() => { + attachmentsRef.current = attachments; + }, [attachments]); + const [isWaitingForUploads, setIsWaitingForUploads] = useState(false); + const sendCancelledRef = useRef(false); + const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => { if (isSendingRef.current) return; + + if (attachmentsRef.current.some(att => att.uploading)) { + isSendingRef.current = true; + setIsSending(true); + setIsWaitingForUploads(true); + const uploadResult = await waitForPendingUploads( + () => attachmentsRef.current, + () => sendCancelledRef.current + ); + setIsWaitingForUploads(false); + isSendingRef.current = false; + setIsSending(false); + if (uploadResult === 'cancelled') return; + if (uploadResult === 'failed') { + // An upload broke while we were waiting - the user may not be + // looking at the composer, so auto-sending would silently drop + // the failed attachment. Abort and let them decide. + toast.error(t('validation.attachment_upload_failed')); + return; + } + } + const ccAddresses = withInput(cc, ccInput); const bccAddresses = withInput(bcc, bccInput); @@ -1520,7 +1557,7 @@ export function EmailComposer({ // Attachment reminder check if (!skipAttachmentCheck && attachmentReminderEnabled) { - const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error); + const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error); if (!hasAttachments) { const bodyText = htmlToPlainText(body); const searchText = `${subject} ${bodyText}`.toLowerCase(); @@ -1636,7 +1673,7 @@ export function EmailComposer({ textBody: finalBody, identityId: currentIdentity?.id || '', fromEmail, - attachments: attachments + attachments: attachmentsRef.current .filter(att => att.blobId && !att.uploading && !att.error) .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), inReplyTo: threadingHeaders?.inReplyTo?.[0], @@ -1663,7 +1700,7 @@ export function EmailComposer({ references: threadingHeaders?.references, delayedUntil: effectiveDelayedUntil, attachments: [ - ...attachments + ...attachmentsRef.current .filter(att => att.blobId && !att.uploading && !att.error) .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })), ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), @@ -1680,7 +1717,7 @@ export function EmailComposer({ } else { // Standard JMAP send path // Collect uploaded attachment blobIds for the send request - const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments + const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current .filter(att => att.blobId && !att.uploading && !att.error) .map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size })); uploadedAttachments.push(...inlineAttachments); @@ -1807,6 +1844,7 @@ export function EmailComposer({ }, []); const cleanClose = () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); @@ -1816,6 +1854,7 @@ export function EmailComposer({ }; const handleSaveDraftAndClose = async () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; setShowCloseDialog(false); if (saveTimeoutRef.current) { @@ -1827,6 +1866,7 @@ export function EmailComposer({ }; const handleDiscardAndClose = () => { + sendCancelledRef.current = true; explicitCloseRef.current = true; setShowCloseDialog(false); if (saveTimeoutRef.current) { diff --git a/lib/__tests__/email-composer-utils.test.ts b/lib/__tests__/email-composer-utils.test.ts index 3c69aa5c..f2d742a8 100644 --- a/lib/__tests__/email-composer-utils.test.ts +++ b/lib/__tests__/email-composer-utils.test.ts @@ -10,6 +10,7 @@ import { parseRecipientList, formatRecipientList, splitPastedRecipients, + waitForPendingUploads, } from "../email-composer-utils"; describe("plainTextToComposerBody", () => { @@ -274,3 +275,77 @@ describe("splitPastedRecipients", () => { expect(splitPastedRecipients(" ")).toEqual({ valid: [], invalid: [] }); }); }); + +describe("waitForPendingUploads", () => { + const att = (over: Partial<{ uploading: boolean; error: boolean }> = {}) => ({ + name: "file.pdf", + type: "application/pdf", + size: 100, + ...over, + }); + + it("resolves 'completed' immediately when nothing is uploading", async () => { + const result = await waitForPendingUploads( + () => [att({}), att({})], + () => false, + 1 + ); + expect(result).toBe("completed"); + }); + + it("polls until in-flight uploads finish, then resolves 'completed'", async () => { + let list = [att({ uploading: true }), att({})]; + setTimeout(() => { + list = [att({}), att({})]; + }, 10); + const result = await waitForPendingUploads(() => list, () => false, 1); + expect(result).toBe("completed"); + }); + + it("resolves 'failed' when an upload finishes with an error during the wait", async () => { + let list = [att({ uploading: true })]; + setTimeout(() => { + list = [att({ error: true })]; + }, 10); + const result = await waitForPendingUploads(() => list, () => false, 1); + expect(result).toBe("failed"); + }); + + it("resolves 'failed' when another attachment is already errored once uploads finish", async () => { + let list = [att({ uploading: true }), att({ error: true })]; + setTimeout(() => { + list = [att({}), att({ error: true })]; + }, 10); + const result = await waitForPendingUploads(() => list, () => false, 1); + expect(result).toBe("failed"); + }); + + it("resolves 'cancelled' when cancellation is signalled mid-wait", async () => { + let cancelled = false; + const list = [att({ uploading: true })]; + setTimeout(() => { + cancelled = true; + }, 10); + const result = await waitForPendingUploads( + () => list, + () => cancelled, + 1 + ); + expect(result).toBe("cancelled"); + }); + + it("prefers 'cancelled' over 'failed' when the draft is closed while an errored upload is pending", async () => { + let cancelled = false; + let list = [att({ uploading: true })]; + setTimeout(() => { + list = [att({ error: true, uploading: true })]; + cancelled = true; + }, 10); + const result = await waitForPendingUploads( + () => list, + () => cancelled, + 1 + ); + expect(result).toBe("cancelled"); + }); +}); diff --git a/lib/email-composer-utils.ts b/lib/email-composer-utils.ts index e9309082..f927ddd2 100644 --- a/lib/email-composer-utils.ts +++ b/lib/email-composer-utils.ts @@ -235,3 +235,34 @@ export function replaceInlineImagePlaceholders( }); return changed ? doc.body.innerHTML : html; } + +export type PendingUploadLike = { + uploading?: boolean; + error?: boolean; +}; + +export type PendingUploadWaitResult = "completed" | "cancelled" | "failed"; + +/** + * Wait for in-flight attachment uploads to settle before sending. + * + * Polls `getAttachments` until nothing is `uploading`, checking + * `isCancelled` between polls (composer closed / draft discarded). + * Resolves: + * - "cancelled" - cancellation was signalled while waiting + * - "failed" - uploads settled but at least one attachment errored; + * the caller must NOT auto-send (the user may not be + * looking at the composer to notice the failed chip) + * - "completed" - all uploads finished cleanly, safe to proceed + */ +export async function waitForPendingUploads( + getAttachments: () => readonly PendingUploadLike[], + isCancelled: () => boolean, + pollMs = 150 +): Promise { + while (getAttachments().some((att) => att.uploading)) { + if (isCancelled()) return "cancelled"; + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + return getAttachments().some((att) => att.error) ? "failed" : "completed"; +} diff --git a/locales/cs/common.json b/locales/cs/common.json index ba7df2c6..b5fc1070 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Chcete-li zprávu odeslat, přidejte příjemce", "subject_required": "Přidejte předmět", - "body_required": "Napište zprávu nebo připojte soubor" + "body_required": "Napište zprávu nebo připojte soubor", + "attachments_uploading": "Přílohy se stále nahrávají – odeslání proběhne po dokončení", + "attachment_upload_failed": "Neodesláno - přílohu se nepodařilo nahrát. Odeberte ji a zkuste to znovu." }, "upload_progress": "Nahrávání {uploaded} / {total}", "upload_cancel": "Zrušit nahrávání", diff --git a/locales/da/common.json b/locales/da/common.json index b6750727..75d5ff6f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -634,7 +634,9 @@ "validation": { "recipient_required": "Tilføj en modtager for at sende", "subject_required": "Tilføj et emne", - "body_required": "Skriv en besked eller vedhæft en fil" + "body_required": "Skriv en besked eller vedhæft en fil", + "attachments_uploading": "Vedhæftninger uploades stadig — sendes, når de er færdige", + "attachment_upload_failed": "Ikke sendt - en vedhæftet fil kunne ikke uploades. Fjern den, og prøv igen." }, "upload_progress": "Uploader {uploaded} / {total}", "upload_cancel": "Annuller upload", diff --git a/locales/de/common.json b/locales/de/common.json index 5f1470d8..04dcfb71 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Empfänger hinzufügen zum Senden", "subject_required": "Betreff hinzufügen", - "body_required": "Nachricht schreiben oder Datei anhängen" + "body_required": "Nachricht schreiben oder Datei anhängen", + "attachments_uploading": "Anhänge werden noch hochgeladen – wird gesendet, sobald sie fertig sind", + "attachment_upload_failed": "Nicht gesendet - ein Anhang konnte nicht hochgeladen werden. Entfernen Sie ihn und versuchen Sie es erneut." }, "upload_progress": "Hochladen {uploaded} / {total}", "upload_cancel": "Hochladen abbrechen", diff --git a/locales/en/common.json b/locales/en/common.json index 45e09962..d312fde4 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -635,7 +635,9 @@ "validation": { "recipient_required": "Add a recipient to send", "subject_required": "Add a subject", - "body_required": "Write a message or attach a file" + "body_required": "Write a message or attach a file", + "attachments_uploading": "Attachments are still uploading — sending as soon as they finish", + "attachment_upload_failed": "Not sent — an attachment failed to upload. Remove it and try again." }, "upload_progress": "Uploading {uploaded} / {total}", "upload_cancel": "Cancel upload", diff --git a/locales/es/common.json b/locales/es/common.json index b74f4a24..b70ba78b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Agregue un destinatario para enviar", "subject_required": "Agregue un asunto", - "body_required": "Escriba un mensaje o adjunte un archivo" + "body_required": "Escriba un mensaje o adjunte un archivo", + "attachments_uploading": "Los archivos adjuntos aún se están subiendo; se enviará en cuanto terminen", + "attachment_upload_failed": "No enviado: no se pudo subir un adjunto. Elimínalo e inténtalo de nuevo." }, "upload_progress": "Subiendo {uploaded} / {total}", "upload_cancel": "Cancelar subida", diff --git a/locales/fa/common.json b/locales/fa/common.json index 387c9429..733991bb 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -635,7 +635,9 @@ "validation": { "recipient_required": "یک گیرنده اضافه کنید", "subject_required": "یک موضوع اضافه کنید", - "body_required": "پیامی بنویسید یا فایلی پیوست کنید" + "body_required": "پیامی بنویسید یا فایلی پیوست کنید", + "attachments_uploading": "پیوست‌ها هنوز در حال بارگذاری هستند — پس از اتمام ارسال می‌شود", + "attachment_upload_failed": "ارسال نشد - بارگذاری یک پیوست ناموفق بود. آن را حذف کنید و دوباره تلاش کنید." }, "upload_progress": "در حال بارگذاری {uploaded} / {total}", "upload_cancel": "لغو بارگذاری", diff --git a/locales/fr/common.json b/locales/fr/common.json index 809152e8..63627b8e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Ajoutez un destinataire pour envoyer", "subject_required": "Ajoutez un objet", - "body_required": "Rédigez un message ou joignez un fichier" + "body_required": "Rédigez un message ou joignez un fichier", + "attachments_uploading": "Les pièces jointes sont en cours d'envoi — le message partira une fois terminé", + "attachment_upload_failed": "Non envoyé - le téléversement d'une pièce jointe a échoué. Supprimez-la et réessayez." }, "upload_progress": "Envoi {uploaded} / {total}", "upload_cancel": "Annuler l'envoi", diff --git a/locales/hu/common.json b/locales/hu/common.json index 3d8027eb..76d5063e 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -635,7 +635,9 @@ "validation": { "recipient_required": "Adj meg egy címzettet a küldéshez", "subject_required": "Adj meg egy tárgyat", - "body_required": "Írj üzenetet vagy csatolj fájlt" + "body_required": "Írj üzenetet vagy csatolj fájlt", + "attachments_uploading": "A mellékletek még feltöltés alatt vannak – küldés a feltöltés befejezése után", + "attachment_upload_failed": "Nincs elküldve - egy melléklet feltöltése nem sikerült. Távolítsa el, és próbálja újra." }, "upload_progress": "Feltöltés {uploaded} / {total}", "upload_cancel": "Feltöltés megszakítása", diff --git a/locales/it/common.json b/locales/it/common.json index b94c98f6..6516a576 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Aggiungi un destinatario per inviare", "subject_required": "Aggiungi un oggetto", - "body_required": "Scrivi un messaggio o allega un file" + "body_required": "Scrivi un messaggio o allega un file", + "attachments_uploading": "Gli allegati sono ancora in fase di caricamento: verrà inviato al termine", + "attachment_upload_failed": "Non inviato: caricamento di un allegato non riuscito. Rimuovilo e riprova." }, "upload_progress": "Caricamento {uploaded} / {total}", "upload_cancel": "Annulla caricamento", diff --git a/locales/ja/common.json b/locales/ja/common.json index f7519260..ea456771 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "送信するには宛先を追加してください", "subject_required": "件名を追加してください", - "body_required": "メッセージを入力するかファイルを添付してください" + "body_required": "メッセージを入力するかファイルを添付してください", + "attachments_uploading": "添付ファイルをアップロード中です。完了次第送信します", + "attachment_upload_failed": "送信されませんでした。添付ファイルのアップロードに失敗しました。削除してもう一度お試しください。" }, "upload_progress": "アップロード中 {uploaded} / {total}", "upload_cancel": "アップロードをキャンセル", diff --git a/locales/ko/common.json b/locales/ko/common.json index 1ccd7828..1bdf06be 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "보내려면 받는 사람을 추가해 주세요", "subject_required": "제목을 입력해 주세요", - "body_required": "메시지를 작성하거나 파일을 첨부해 주세요" + "body_required": "메시지를 작성하거나 파일을 첨부해 주세요", + "attachments_uploading": "첨부 파일을 업로드하는 중입니다. 완료되면 자동으로 전송됩니다", + "attachment_upload_failed": "전송되지 않았습니다. 첨부 파일 업로드에 실패했습니다. 해당 파일을 제거하고 다시 시도하세요." }, "upload_progress": "{uploaded} / {total} 업로드 중", "upload_cancel": "업로드 취소", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3c7e5a13..11be5d36 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Norādiet vismaz vienu saņēmēju", "subject_required": "Norādiet tematu", - "body_required": "Ierakstiet ziņojumu vai pievienojiet failu" + "body_required": "Ierakstiet ziņojumu vai pievienojiet failu", + "attachments_uploading": "Pielikumi joprojām tiek augšupielādēti — nosūtīs, tiklīdz tas pabeigts", + "attachment_upload_failed": "Nav nosūtīts - pielikumu neizdevās augšupielādēt. Noņemiet to un mēģiniet vēlreiz." }, "upload_progress": "Augšupielāde {uploaded} / {total}", "upload_cancel": "Atcelt augšupielādi", diff --git a/locales/nl/common.json b/locales/nl/common.json index 9caa0800..0bd16159 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Voeg een ontvanger toe om te verzenden", "subject_required": "Voeg een onderwerp toe", - "body_required": "Schrijf een bericht of voeg een bestand toe" + "body_required": "Schrijf een bericht of voeg een bestand toe", + "attachments_uploading": "Bijlagen worden nog geüpload — wordt verzonden zodra dit klaar is", + "attachment_upload_failed": "Niet verzonden - een bijlage kon niet worden geüpload. Verwijder deze en probeer het opnieuw." }, "upload_progress": "Uploaden {uploaded} / {total}", "upload_cancel": "Upload annuleren", diff --git a/locales/pl/common.json b/locales/pl/common.json index 65e3ab6e..7d0cbd6e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Dodaj odbiorcę, aby wysłać", "subject_required": "Dodaj temat", - "body_required": "Napisz wiadomość lub załącz plik" + "body_required": "Napisz wiadomość lub załącz plik", + "attachments_uploading": "Załączniki wciąż się przesyłają — wiadomość zostanie wysłana po zakończeniu", + "attachment_upload_failed": "Nie wysłano - nie udało się przesłać załącznika. Usuń go i spróbuj ponownie." }, "upload_progress": "Przesyłanie {uploaded} / {total}", "upload_cancel": "Anuluj przesyłanie", diff --git a/locales/pt/common.json b/locales/pt/common.json index 90eaeb78..403cda31 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Adicione um destinatário para enviar", "subject_required": "Adicione um assunto", - "body_required": "Escreva uma mensagem ou anexe um arquivo" + "body_required": "Escreva uma mensagem ou anexe um arquivo", + "attachments_uploading": "Os anexos ainda estão sendo enviados — será enviado assim que terminar", + "attachment_upload_failed": "Não enviado - falha ao carregar um anexo. Remova-o e tente novamente." }, "upload_progress": "Enviando {uploaded} / {total}", "upload_cancel": "Cancelar envio", diff --git a/locales/ro/common.json b/locales/ro/common.json index 96db6f97..7b7ea7f1 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -635,7 +635,9 @@ "validation": { "recipient_required": "Adăugați un destinatar căruia să îi trimiteți mesajul", "subject_required": "Adăugați un subiect", - "body_required": "Scrieți un mesaj sau atașați un fișier" + "body_required": "Scrieți un mesaj sau atașați un fișier", + "attachments_uploading": "Atașamentele încă se încarcă — se va trimite imediat ce se termină", + "attachment_upload_failed": "Netrimis - încărcarea unui atașament a eșuat. Eliminați-l și încercați din nou." }, "upload_progress": "Încărcare {uploaded} / {total}", "upload_cancel": "Anulează încărcarea", diff --git a/locales/ru/common.json b/locales/ru/common.json index 6b4bc91f..a04adaed 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Укажите получателя для отправки", "subject_required": "Укажите тему", - "body_required": "Напишите сообщение или прикрепите файл" + "body_required": "Напишите сообщение или прикрепите файл", + "attachments_uploading": "Вложения ещё загружаются — отправка начнётся, как только загрузка завершится", + "attachment_upload_failed": "Не отправлено — не удалось загрузить вложение. Удалите его и попробуйте снова." }, "upload_progress": "Загрузка {uploaded} / {total}", "upload_cancel": "Отменить загрузку", diff --git a/locales/tr/common.json b/locales/tr/common.json index ce579181..452aab29 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -635,7 +635,9 @@ "validation": { "recipient_required": "Göndermek için bir alıcı ekleyin", "subject_required": "Bir konu ekleyin", - "body_required": "Bir ileti yazın veya dosya ekleyin" + "body_required": "Bir ileti yazın veya dosya ekleyin", + "attachments_uploading": "Ekler hâlâ yükleniyor — yükleme bitince gönderilecek", + "attachment_upload_failed": "Gönderilmedi - bir ek yüklenemedi. Eki kaldırıp tekrar deneyin." }, "upload_progress": "{uploaded} / {total} yükleniyor", "upload_cancel": "Yüklemeyi iptal et", diff --git a/locales/uk/common.json b/locales/uk/common.json index 3c991ae9..5967a6b5 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "Додайте одержувача для надсилання", "subject_required": "Додайте тему", - "body_required": "Напишіть повідомлення або прикріпіть файл" + "body_required": "Напишіть повідомлення або прикріпіть файл", + "attachments_uploading": "Вкладення ще завантажуються — надсилання почнеться після завершення", + "attachment_upload_failed": "Не надіслано — не вдалося завантажити вкладення. Видаліть його та спробуйте ще раз." }, "upload_progress": "Завантаження {uploaded} / {total}", "upload_cancel": "Скасувати завантаження", diff --git a/locales/zh/common.json b/locales/zh/common.json index d9296798..cfd9cfec 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -632,7 +632,9 @@ "validation": { "recipient_required": "请添加收件人", "subject_required": "请添加主题", - "body_required": "请输入邮件内容或添加附件" + "body_required": "请输入邮件内容或添加附件", + "attachments_uploading": "附件仍在上传中,上传完成后将自动发送", + "attachment_upload_failed": "未发送 - 附件上传失败。请移除该附件后重试。" }, "upload_progress": "正在上传 {uploaded} / {total}", "upload_cancel": "取消上传",