From 396f5f7d609e26b4559f170abc24bc246b53a566 Mon Sep 17 00:00:00 2001 From: KazNIISA IT Date: Fri, 3 Jul 2026 12:07:02 +0500 Subject: [PATCH] fix(compose): wait for in-flight attachment uploads before sending Clicking Send while attachments were still uploading silently dropped them from the outgoing message: every place that builds the outgoing attachment list filters on att.blobId && !att.uploading, and the Send button never accounted for uploads still in flight. Attach a few files, hit Send right away, and the email could go out missing some of them with no warning. handleSend now detects pending uploads before validating: - Send is disabled with an explanatory tooltip (validation.attachments_uploading) while it waits. - Once uploads finish cleanly, the send proceeds automatically - no second click needed. - If an upload FAILS while waiting, the send is aborted with an error toast (validation.attachment_upload_failed) instead of silently shipping the email without the failed attachment - the user may not be looking at the composer to notice the red error chip. - If the draft is closed or discarded while waiting, the pending send is cancelled cleanly. The wait/decision logic lives in waitForPendingUploads() in lib/email-composer-utils.ts (returns completed | cancelled | failed) with unit tests covering all three outcomes. Outgoing-attachment call sites read the freshest state via attachmentsRef since the render closure captured at click time won't reflect uploads that finished during the wait. Both new i18n keys added to all 20 locales under email_composer.validation. --- components/email/email-composer.tsx | 48 ++++++++++++-- lib/__tests__/email-composer-utils.test.ts | 75 ++++++++++++++++++++++ lib/email-composer-utils.ts | 31 +++++++++ locales/cs/common.json | 4 +- locales/da/common.json | 4 +- locales/de/common.json | 4 +- locales/en/common.json | 4 +- locales/es/common.json | 4 +- locales/fa/common.json | 4 +- locales/fr/common.json | 4 +- locales/hu/common.json | 4 +- locales/it/common.json | 4 +- locales/ja/common.json | 4 +- locales/ko/common.json | 4 +- locales/lv/common.json | 4 +- locales/nl/common.json | 4 +- locales/pl/common.json | 4 +- locales/pt/common.json | 4 +- locales/ro/common.json | 4 +- locales/ru/common.json | 4 +- locales/tr/common.json | 4 +- locales/uk/common.json | 4 +- locales/zh/common.json | 4 +- 23 files changed, 210 insertions(+), 24 deletions(-) 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": "取消上传",