mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: attach files from Files and save attachments to Files
This commit is contained in:
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "هل نسيت إرفاق ملف؟",
|
||||
"attachments_too_large_total": "ستتجاوز المرفقات الحد {max} الذي يسمح به هذا الخادم لكل رسالة.",
|
||||
"editor_unavailable": "تعذّرت قراءة محتوى الرسالة. انسخ النص، ثم أغلق نافذة الإنشاء وأعد فتحها.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {إرفاق} one {إرفاق ملف واحد} other {إرفاق # ملفات}}",
|
||||
"empty_folder": "هذا المجلد فارغ",
|
||||
"load_failed": "تعذّر تحميل ملفاتك"
|
||||
},
|
||||
"from_options": "خيارات المرسل",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "الهوية غير متاحة",
|
||||
@@ -450,6 +455,14 @@
|
||||
"remove_color": "إزالة الوسم",
|
||||
"replied": "تم الرد عليها",
|
||||
"reschedule_recipients_refused": "لم يتغيّر شيء: رفض الخادم {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "حفظ في الملفات",
|
||||
"save_here": "حفظ هنا",
|
||||
"saved": "حُفظ في الملفات",
|
||||
"saved_to": "في {folder}",
|
||||
"failed": "تعذّر الحفظ في الملفات",
|
||||
"load_failed": "تعذّر تحميل مجلداتك. حاول مرة أخرى."
|
||||
},
|
||||
"sender_actions": "المرسل…",
|
||||
"set_color": "تعيين وسم",
|
||||
"show_all_count": "عرض الكل ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Us heu oblidat d'un fitxer adjunt?",
|
||||
"attachments_too_large_total": "Els fitxers adjunts superarien els {max} que aquest servidor permet per missatge.",
|
||||
"editor_unavailable": "No s'ha pogut llegir el contingut del missatge. Copieu el text i, després, tanqueu i torneu a obrir el redactor.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Adjunta} one {Adjunta 1 fitxer} other {Adjunta # fitxers}}",
|
||||
"empty_folder": "Aquesta carpeta és buida",
|
||||
"load_failed": "No s'han pogut carregar els teus fitxers"
|
||||
},
|
||||
"from_options": "Opcions del remitent",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identitat no disponible",
|
||||
@@ -455,6 +460,14 @@
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
"replied": "Respost",
|
||||
"reschedule_recipients_refused": "No s'ha canviat res: el servidor ha rebutjat {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Desa a Fitxers",
|
||||
"save_here": "Desa aquí",
|
||||
"saved": "S'ha desat a Fitxers",
|
||||
"saved_to": "A {folder}",
|
||||
"failed": "No s'ha pogut desar a Fitxers",
|
||||
"load_failed": "No s'han pogut carregar les carpetes. Torna-ho a provar."
|
||||
},
|
||||
"sender_actions": "Remitent…",
|
||||
"set_color": "Estableix l'etiqueta",
|
||||
"show_all_count": "Mostra-ho tot ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Nezapomněli jste přílohu?",
|
||||
"attachments_too_large_total": "Přílohy by překročily limit {max}, který tento server povoluje na jednu zprávu.",
|
||||
"editor_unavailable": "Obsah zprávy se nepodařilo načíst. Zkopírujte svůj text a poté editor zavřete a znovu otevřete.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Připojit} one {Připojit 1 soubor} few {Připojit # soubory} other {Připojit # souborů}}",
|
||||
"empty_folder": "Tato složka je prázdná",
|
||||
"load_failed": "Vaše soubory se nepodařilo načíst"
|
||||
},
|
||||
"from_options": "Možnosti odesílatele",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identita není k dispozici",
|
||||
@@ -455,6 +460,14 @@
|
||||
"remove_color": "Odebrat štítek",
|
||||
"replied": "Zodpovězeno",
|
||||
"reschedule_recipients_refused": "Nic se nezměnilo: server odmítl {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Uložit do Souborů",
|
||||
"save_here": "Uložit sem",
|
||||
"saved": "Uloženo do Souborů",
|
||||
"saved_to": "Do složky {folder}",
|
||||
"failed": "Nepodařilo se uložit do Souborů",
|
||||
"load_failed": "Složky se nepodařilo načíst. Zkuste to znovu."
|
||||
},
|
||||
"sender_actions": "Odesílatel…",
|
||||
"set_color": "Nastavit štítek",
|
||||
"show_all_count": "Zobrazit vše ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Glemt en vedhæftning?",
|
||||
"attachments_too_large_total": "Vedhæftningerne ville overskride de {max}, som denne server tillader pr. besked.",
|
||||
"editor_unavailable": "Beskedens indhold kunne ikke læses. Kopiér din tekst, og luk og genåbn derefter editoren.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Vedhæft} one {Vedhæft 1 fil} other {Vedhæft # filer}}",
|
||||
"empty_folder": "Denne mappe er tom",
|
||||
"load_failed": "Dine filer kunne ikke indlæses"
|
||||
},
|
||||
"from_options": "Afsenderindstillinger",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identitet ikke tilgængelig",
|
||||
@@ -452,6 +457,14 @@
|
||||
"remove_color": "Fjern tag",
|
||||
"replied": "Besvaret",
|
||||
"reschedule_recipients_refused": "Intet blev ændret: serveren afviste {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Gem i Filer",
|
||||
"save_here": "Gem her",
|
||||
"saved": "Gemt i Filer",
|
||||
"saved_to": "I {folder}",
|
||||
"failed": "Kunne ikke gemme i Filer",
|
||||
"load_failed": "Mapperne kunne ikke indlæses. Prøv igen."
|
||||
},
|
||||
"sender_actions": "Afsender…",
|
||||
"set_color": "Sæt tag",
|
||||
"show_all_count": "Vis alle ({count})",
|
||||
|
||||
@@ -286,6 +286,11 @@
|
||||
"attachment_reminder_title": "Anhang vergessen?",
|
||||
"attachments_too_large_total": "Die Anhänge würden die {max} überschreiten, die dieser Server pro Nachricht erlaubt.",
|
||||
"editor_unavailable": "Der Nachrichteninhalt konnte nicht gelesen werden. Kopieren Sie Ihren Text, schließen Sie dann den Editor und öffnen Sie ihn erneut.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Anhängen} one {1 Datei anhängen} other {# Dateien anhängen}}",
|
||||
"empty_folder": "Dieser Ordner ist leer",
|
||||
"load_failed": "Ihre Dateien konnten nicht geladen werden"
|
||||
},
|
||||
"from_options": "Absenderoptionen",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identität nicht verfügbar",
|
||||
@@ -462,6 +467,14 @@
|
||||
"remove_color": "Label entfernen",
|
||||
"replied": "Beantwortet",
|
||||
"reschedule_recipients_refused": "Nichts wurde geändert: Der Server hat {recipients} abgelehnt.",
|
||||
"save_to_files": {
|
||||
"title": "In Dateien speichern",
|
||||
"save_here": "Hier speichern",
|
||||
"saved": "In Dateien gespeichert",
|
||||
"saved_to": "In {folder}",
|
||||
"failed": "Konnte nicht in Dateien gespeichert werden",
|
||||
"load_failed": "Ihre Ordner konnten nicht geladen werden. Versuchen Sie es erneut."
|
||||
},
|
||||
"sender_actions": "Absender…",
|
||||
"set_color": "Label setzen",
|
||||
"show_all_count": "Alle anzeigen ({count})",
|
||||
|
||||
@@ -484,6 +484,13 @@
|
||||
"remove_color": "Remove tag",
|
||||
"replied": "Replied",
|
||||
"reschedule_recipients_refused": "Nothing was changed: the server refused {recipients}.",
|
||||
"save_to_files": {
|
||||
"failed": "Couldn't save to Files",
|
||||
"save_here": "Save here",
|
||||
"saved": "Saved to Files",
|
||||
"saved_to": "In {folder}",
|
||||
"title": "Save to Files"
|
||||
},
|
||||
"sender_actions": "Sender…",
|
||||
"set_color": "Set tag",
|
||||
"show_all_count": "Show all ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "¿Olvidaste un adjunto?",
|
||||
"attachments_too_large_total": "Los adjuntos superarían los {max} que este servidor permite por mensaje.",
|
||||
"editor_unavailable": "No se pudo leer el contenido del mensaje. Copia tu texto y luego cierra y vuelve a abrir el editor.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Adjuntar} one {Adjuntar 1 archivo} other {Adjuntar # archivos}}",
|
||||
"empty_folder": "Esta carpeta está vacía",
|
||||
"load_failed": "No se pudieron cargar tus archivos"
|
||||
},
|
||||
"from_options": "Opciones de remitente",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identidad no disponible",
|
||||
@@ -461,6 +466,14 @@
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
"replied": "Respondido",
|
||||
"reschedule_recipients_refused": "No se cambió nada: el servidor rechazó {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Guardar en Archivos",
|
||||
"save_here": "Guardar aquí",
|
||||
"saved": "Guardado en Archivos",
|
||||
"saved_to": "En {folder}",
|
||||
"failed": "No se pudo guardar en Archivos",
|
||||
"load_failed": "No se pudieron cargar tus carpetas. Vuelve a intentarlo."
|
||||
},
|
||||
"sender_actions": "Remitente…",
|
||||
"set_color": "Establecer etiqueta",
|
||||
"show_all_count": "Mostrar todo ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "پیوست را فراموش کردهاید؟",
|
||||
"attachments_too_large_total": "پیوستها از سقف {max} که این سرور برای هر پیام مجاز میداند بیشتر میشوند.",
|
||||
"editor_unavailable": "خواندن محتوای پیام ممکن نشد. متن خود را کپی کنید، سپس ویرایشگر را ببندید و دوباره باز کنید.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {پیوست} one {پیوست ۱ فایل} other {پیوست # فایل}}",
|
||||
"empty_folder": "این پوشه خالی است",
|
||||
"load_failed": "فایلهای شما بارگیری نشد"
|
||||
},
|
||||
"from_options": "گزینههای فرستنده",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "هویت در دسترس نیست",
|
||||
@@ -449,6 +454,14 @@
|
||||
"remove_color": "حذف برچسب",
|
||||
"replied": "پاسخ داده شده",
|
||||
"reschedule_recipients_refused": "چیزی تغییر نکرد: سرور {recipients} را رد کرد.",
|
||||
"save_to_files": {
|
||||
"title": "ذخیره در فایلها",
|
||||
"save_here": "ذخیره در اینجا",
|
||||
"saved": "در فایلها ذخیره شد",
|
||||
"saved_to": "در {folder}",
|
||||
"failed": "ذخیره در فایلها ممکن نشد",
|
||||
"load_failed": "پوشهها بارگذاری نشدند. دوباره امتحان کنید."
|
||||
},
|
||||
"sender_actions": "فرستنده…",
|
||||
"set_color": "تنظیم برچسب",
|
||||
"show_all_count": "نمایش همه ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Pièce jointe oubliée ?",
|
||||
"attachments_too_large_total": "Les pièces jointes dépasseraient les {max} autorisés par ce serveur pour un message.",
|
||||
"editor_unavailable": "Impossible de lire le contenu du message. Copiez votre texte, puis fermez et rouvrez l'éditeur.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Joindre} one {Joindre 1 fichier} other {Joindre # fichiers}}",
|
||||
"empty_folder": "Ce dossier est vide",
|
||||
"load_failed": "Impossible de charger vos fichiers"
|
||||
},
|
||||
"from_options": "Options d'expéditeur",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identité indisponible",
|
||||
@@ -459,6 +464,14 @@
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
"replied": "Répondu",
|
||||
"reschedule_recipients_refused": "Rien n’a été modifié : le serveur a refusé {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Enregistrer dans Fichiers",
|
||||
"save_here": "Enregistrer ici",
|
||||
"saved": "Enregistré dans Fichiers",
|
||||
"saved_to": "Dans {folder}",
|
||||
"failed": "Impossible d'enregistrer dans Fichiers",
|
||||
"load_failed": "Impossible de charger vos dossiers. Réessayez."
|
||||
},
|
||||
"sender_actions": "Expéditeur…",
|
||||
"set_color": "Définir l'étiquette",
|
||||
"show_all_count": "Tout afficher ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "שכחת קובץ מצורף?",
|
||||
"attachments_too_large_total": "הקבצים המצורפים יחרגו מהמגבלה של {max} להודעה שהשרת הזה מאפשר.",
|
||||
"editor_unavailable": "לא ניתן היה לקרוא את תוכן ההודעה. העתק את הטקסט, ואז סגור ופתח מחדש את חלון הכתיבה.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {צירוף} one {צירוף קובץ 1} other {צירוף # קבצים}}",
|
||||
"empty_folder": "התיקייה הזו ריקה",
|
||||
"load_failed": "לא ניתן היה לטעון את הקבצים שלך"
|
||||
},
|
||||
"from_options": "אפשרויות שולח",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "הזהות אינה זמינה",
|
||||
@@ -453,6 +458,14 @@
|
||||
"remove_color": "הסר תג",
|
||||
"replied": "נענה",
|
||||
"reschedule_recipients_refused": "דבר לא השתנה: השרת דחה את {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "שמירה בקבצים",
|
||||
"save_here": "שמירה כאן",
|
||||
"saved": "נשמר בקבצים",
|
||||
"saved_to": "ב{folder}",
|
||||
"failed": "לא ניתן היה לשמור בקבצים",
|
||||
"load_failed": "לא ניתן היה לטעון את התיקיות. נסו שוב."
|
||||
},
|
||||
"sender_actions": "שולח…",
|
||||
"set_color": "הגדר תג",
|
||||
"show_all_count": "הצג הכול ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Elfelejtetted a csatolmányt?",
|
||||
"attachments_too_large_total": "A csatolmányok túllépnék a szerver által üzenetenként engedélyezett méretet ({max}).",
|
||||
"editor_unavailable": "Nem sikerült beolvasni az üzenet tartalmát. Másold ki a szöveget, majd zárd be és nyisd meg újra a szerkesztőt.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Csatolás} one {1 fájl csatolása} other {# fájl csatolása}}",
|
||||
"empty_folder": "Ez a mappa üres",
|
||||
"load_failed": "A fájlokat nem sikerült betölteni"
|
||||
},
|
||||
"from_options": "Feladó beállításai",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Az azonosság nem érhető el",
|
||||
@@ -451,6 +456,14 @@
|
||||
"remove_color": "Címke eltávolítása",
|
||||
"replied": "Megválaszolva",
|
||||
"reschedule_recipients_refused": "Semmi sem változott: a kiszolgáló elutasította a következőt: {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Mentés a Fájlokba",
|
||||
"save_here": "Mentés ide",
|
||||
"saved": "Mentve a Fájlokba",
|
||||
"saved_to": "Ide: {folder}",
|
||||
"failed": "Nem sikerült menteni a Fájlokba",
|
||||
"load_failed": "Nem sikerült betölteni a mappákat. Próbálja újra."
|
||||
},
|
||||
"sender_actions": "Feladó…",
|
||||
"set_color": "Címke beállítása",
|
||||
"show_all_count": "Összes megjelenítése ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Hai dimenticato un allegato?",
|
||||
"attachments_too_large_total": "Gli allegati supererebbero i {max} consentiti da questo server per messaggio.",
|
||||
"editor_unavailable": "Impossibile leggere il contenuto del messaggio. Copia il testo, poi chiudi e riapri l'editor.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Allega} one {Allega 1 file} other {Allega # file}}",
|
||||
"empty_folder": "Questa cartella è vuota",
|
||||
"load_failed": "Impossibile caricare i tuoi file"
|
||||
},
|
||||
"from_options": "Opzioni mittente",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identità non disponibile",
|
||||
@@ -457,6 +462,14 @@
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
"replied": "Risposto",
|
||||
"reschedule_recipients_refused": "Non è stato cambiato nulla: il server ha rifiutato {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Salva in File",
|
||||
"save_here": "Salva qui",
|
||||
"saved": "Salvato in File",
|
||||
"saved_to": "In {folder}",
|
||||
"failed": "Impossibile salvare in File",
|
||||
"load_failed": "Impossibile caricare le cartelle. Riprova."
|
||||
},
|
||||
"sender_actions": "Mittente…",
|
||||
"set_color": "Imposta etichetta",
|
||||
"show_all_count": "Mostra tutto ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "添付ファイルを忘れていませんか?",
|
||||
"attachments_too_large_total": "添付ファイルがサーバーの上限(1 通あたり {max})を超えます。",
|
||||
"editor_unavailable": "メッセージの内容を読み取れませんでした。テキストをコピーしてから、作成画面を閉じて開き直してください。",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {添付} other {# 件のファイルを添付}}",
|
||||
"empty_folder": "このフォルダーは空です",
|
||||
"load_failed": "ファイルを読み込めませんでした"
|
||||
},
|
||||
"from_options": "送信者のオプション",
|
||||
"group_chip": "{name}({count})",
|
||||
"identity_unavailable": "送信者情報を利用できません",
|
||||
@@ -455,6 +460,14 @@
|
||||
"remove_color": "ラベルを削除",
|
||||
"replied": "返信済み",
|
||||
"reschedule_recipients_refused": "何も変更されていません: サーバーが {recipients} を拒否しました。",
|
||||
"save_to_files": {
|
||||
"title": "ファイルに保存",
|
||||
"save_here": "ここに保存",
|
||||
"saved": "ファイルに保存しました",
|
||||
"saved_to": "{folder} に保存",
|
||||
"failed": "ファイルに保存できませんでした",
|
||||
"load_failed": "フォルダを読み込めませんでした。もう一度お試しください。"
|
||||
},
|
||||
"sender_actions": "差出人…",
|
||||
"set_color": "ラベルを設定",
|
||||
"show_all_count": "すべて表示({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "첨부 파일을 잊으셨나요?",
|
||||
"attachments_too_large_total": "첨부 파일이 이 서버의 메시지당 한도({max})를 넘어요.",
|
||||
"editor_unavailable": "메시지 내용을 읽을 수 없어요. 텍스트를 복사한 다음 작성 화면을 닫았다가 다시 열어 주세요.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {첨부} other {파일 #개 첨부}}",
|
||||
"empty_folder": "이 폴더는 비어 있습니다",
|
||||
"load_failed": "파일을 불러올 수 없습니다"
|
||||
},
|
||||
"from_options": "보내는 사람 옵션",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "보내는 사람을 사용할 수 없어요",
|
||||
@@ -453,6 +458,14 @@
|
||||
"remove_color": "태그 제거",
|
||||
"replied": "답장함",
|
||||
"reschedule_recipients_refused": "변경된 내용이 없습니다: 서버가 {recipients}을(를) 거부했습니다.",
|
||||
"save_to_files": {
|
||||
"title": "파일에 저장",
|
||||
"save_here": "여기에 저장",
|
||||
"saved": "파일에 저장했어요",
|
||||
"saved_to": "{folder}에 저장",
|
||||
"failed": "파일에 저장하지 못했어요",
|
||||
"load_failed": "폴더를 불러오지 못했어요. 다시 시도하세요."
|
||||
},
|
||||
"sender_actions": "보낸 사람…",
|
||||
"set_color": "태그 설정",
|
||||
"show_all_count": "전체 보기({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Aizmirsāt pielikumu?",
|
||||
"attachments_too_large_total": "Pielikumi pārsniegtu {max}, ko šis serveris atļauj vienam ziņojumam.",
|
||||
"editor_unavailable": "Neizdevās nolasīt ziņojuma saturu. Nokopējiet savu tekstu, pēc tam aizveriet un atkal atveriet redaktoru.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Pievienot} one {Pievienot # failu} other {Pievienot # failus}}",
|
||||
"empty_folder": "Šī mape ir tukša",
|
||||
"load_failed": "Jūsu failus neizdevās ielādēt"
|
||||
},
|
||||
"from_options": "Sūtītāja opcijas",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identitāte nav pieejama",
|
||||
@@ -454,6 +459,14 @@
|
||||
"remove_color": "Noņemt tagu",
|
||||
"replied": "Atbildēts",
|
||||
"reschedule_recipients_refused": "Nekas netika mainīts: serveris noraidīja {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Saglabāt failos",
|
||||
"save_here": "Saglabāt šeit",
|
||||
"saved": "Saglabāts failos",
|
||||
"saved_to": "Mapē {folder}",
|
||||
"failed": "Neizdevās saglabāt failos",
|
||||
"load_failed": "Neizdevās ielādēt mapes. Mēģiniet vēlreiz."
|
||||
},
|
||||
"sender_actions": "Sūtītājs…",
|
||||
"set_color": "Iestatīt tagu",
|
||||
"show_all_count": "Rādīt visus ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Хавсралтаа мартсан уу?",
|
||||
"attachments_too_large_total": "Хавсралтууд энэ серверийн нэг имэйлд зөвшөөрөх {max} хэмжээнээс хэтэрнэ.",
|
||||
"editor_unavailable": "Имэйлийн агуулгыг уншиж чадсангүй. Текстээ хуулаад засварлагчийг хааж, дахин нээнэ үү.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Хавсаргах} one {1 файл хавсаргах} other {# файл хавсаргах}}",
|
||||
"empty_folder": "Энэ фолдер хоосон байна",
|
||||
"load_failed": "Таны файлуудыг ачаалж чадсангүй"
|
||||
},
|
||||
"from_options": "Илгээгчийн сонголтууд",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Таниулбар боломжгүй",
|
||||
@@ -450,6 +455,14 @@
|
||||
"remove_color": "Тагийг хасах",
|
||||
"replied": "Хариулсан",
|
||||
"reschedule_recipients_refused": "Юу ч өөрчлөгдсөнгүй: сервер дараах хүлээн авагчдыг татгалзлаа: {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Файлд хадгалах",
|
||||
"save_here": "Энд хадгалах",
|
||||
"saved": "Файлд хадгаллаа",
|
||||
"saved_to": "{folder}-д",
|
||||
"failed": "Файлд хадгалж чадсангүй",
|
||||
"load_failed": "Хавтсуудыг ачаалж чадсангүй. Дахин оролдоно уу."
|
||||
},
|
||||
"sender_actions": "Илгээгч…",
|
||||
"set_color": "Таг оноох",
|
||||
"show_all_count": "Бүгдийг харуулах ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Glemt et vedlegg?",
|
||||
"attachments_too_large_total": "Vedleggene ville overskride grensen på {max} per melding som denne serveren tillater.",
|
||||
"editor_unavailable": "Kunne ikke lese innholdet i meldingen. Kopier teksten din, og lukk og åpne skrivevinduet på nytt.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Legg ved} one {Legg ved 1 fil} other {Legg ved # filer}}",
|
||||
"empty_folder": "Denne mappen er tom",
|
||||
"load_failed": "Filene dine kunne ikke lastes inn"
|
||||
},
|
||||
"from_options": "Avsenderalternativer",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identiteten er ikke tilgjengelig",
|
||||
@@ -452,6 +457,14 @@
|
||||
"remove_color": "Fjern etiketten",
|
||||
"replied": "Besvart",
|
||||
"reschedule_recipients_refused": "Ingenting ble endret: serveren avviste {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Lagre i Filer",
|
||||
"save_here": "Lagre her",
|
||||
"saved": "Lagret i Filer",
|
||||
"saved_to": "I {folder}",
|
||||
"failed": "Kunne ikke lagre i Filer",
|
||||
"load_failed": "Kunne ikke laste inn mappene. Prøv igjen."
|
||||
},
|
||||
"sender_actions": "Avsender…",
|
||||
"set_color": "Legg til etikett",
|
||||
"show_all_count": "Vis alle ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Bijlage vergeten?",
|
||||
"attachments_too_large_total": "De bijlagen zouden de {max} overschrijden die deze server per bericht toestaat.",
|
||||
"editor_unavailable": "Kon de inhoud van het bericht niet lezen. Kopieer uw tekst en sluit en open daarna het opstelvenster opnieuw.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Bijvoegen} one {1 bestand bijvoegen} other {# bestanden bijvoegen}}",
|
||||
"empty_folder": "Deze map is leeg",
|
||||
"load_failed": "Je bestanden konden niet worden geladen"
|
||||
},
|
||||
"from_options": "Afzenderopties",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identiteit niet beschikbaar",
|
||||
@@ -455,6 +460,14 @@
|
||||
"remove_color": "Label verwijderen",
|
||||
"replied": "Beantwoord",
|
||||
"reschedule_recipients_refused": "Er is niets gewijzigd: de server heeft {recipients} geweigerd.",
|
||||
"save_to_files": {
|
||||
"title": "Opslaan in Bestanden",
|
||||
"save_here": "Hier opslaan",
|
||||
"saved": "Opgeslagen in Bestanden",
|
||||
"saved_to": "In {folder}",
|
||||
"failed": "Kon niet opslaan in Bestanden",
|
||||
"load_failed": "Kon je mappen niet laden. Probeer het opnieuw."
|
||||
},
|
||||
"sender_actions": "Afzender…",
|
||||
"set_color": "Label instellen",
|
||||
"show_all_count": "Alles tonen ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Zapomniano o załączniku?",
|
||||
"attachments_too_large_total": "Załączniki przekroczyłyby limit {max} na wiadomość obowiązujący na tym serwerze.",
|
||||
"editor_unavailable": "Nie można odczytać treści wiadomości. Skopiuj tekst, a następnie zamknij i ponownie otwórz edytor.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Dołącz} one {Dołącz 1 plik} few {Dołącz # pliki} many {Dołącz # plików} other {Dołącz # pliku}}",
|
||||
"empty_folder": "Ten folder jest pusty",
|
||||
"load_failed": "Nie udało się wczytać plików"
|
||||
},
|
||||
"from_options": "Opcje nadawcy",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Tożsamość niedostępna",
|
||||
@@ -455,6 +460,14 @@
|
||||
"remove_color": "Usuń etykietę",
|
||||
"replied": "Odpowiedziano",
|
||||
"reschedule_recipients_refused": "Nic nie zostało zmienione: serwer odrzucił {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Zapisz w Plikach",
|
||||
"save_here": "Zapisz tutaj",
|
||||
"saved": "Zapisano w Plikach",
|
||||
"saved_to": "W folderze {folder}",
|
||||
"failed": "Nie udało się zapisać w Plikach",
|
||||
"load_failed": "Nie udało się wczytać folderów. Spróbuj ponownie."
|
||||
},
|
||||
"sender_actions": "Nadawca…",
|
||||
"set_color": "Ustaw etykietę",
|
||||
"show_all_count": "Pokaż wszystkie ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Esqueceu um anexo?",
|
||||
"attachments_too_large_total": "Os anexos excederiam o limite de {max} por mensagem permitido por este servidor.",
|
||||
"editor_unavailable": "Não foi possível ler o conteúdo da mensagem. Copie seu texto e depois feche e reabra o editor.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Anexar} one {Anexar 1 arquivo} other {Anexar # arquivos}}",
|
||||
"empty_folder": "Esta pasta está vazia",
|
||||
"load_failed": "Não foi possível carregar seus arquivos"
|
||||
},
|
||||
"from_options": "Opções de remetente",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identidade indisponível",
|
||||
@@ -458,6 +463,14 @@
|
||||
"remove_color": "Remover etiqueta",
|
||||
"replied": "Respondido",
|
||||
"reschedule_recipients_refused": "Nada foi alterado: o servidor recusou {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Guardar em Ficheiros",
|
||||
"save_here": "Guardar aqui",
|
||||
"saved": "Guardado em Ficheiros",
|
||||
"saved_to": "Em {folder}",
|
||||
"failed": "Não foi possível guardar em Ficheiros",
|
||||
"load_failed": "Não foi possível carregar as pastas. Tente novamente."
|
||||
},
|
||||
"sender_actions": "Remetente…",
|
||||
"set_color": "Definir etiqueta",
|
||||
"show_all_count": "Mostrar tudo ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Ați uitat un atașament?",
|
||||
"attachments_too_large_total": "Atașamentele ar depăși limita de {max} per mesaj permisă de acest server.",
|
||||
"editor_unavailable": "Conținutul mesajului nu a putut fi citit. Copiați textul, apoi închideți și redeschideți editorul.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Atașează} one {Atașează 1 fișier} few {Atașează # fișiere} other {Atașează # de fișiere}}",
|
||||
"empty_folder": "Acest dosar este gol",
|
||||
"load_failed": "Fișierele tale nu au putut fi încărcate"
|
||||
},
|
||||
"from_options": "Opțiuni expeditor",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identitate indisponibilă",
|
||||
@@ -455,6 +460,14 @@
|
||||
"remove_color": "Eliminați eticheta",
|
||||
"replied": "Răspuns",
|
||||
"reschedule_recipients_refused": "Nu s-a schimbat nimic: serverul a respins {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Salvează în Fișiere",
|
||||
"save_here": "Salvează aici",
|
||||
"saved": "Salvat în Fișiere",
|
||||
"saved_to": "În {folder}",
|
||||
"failed": "Nu s-a putut salva în Fișiere",
|
||||
"load_failed": "Dosarele nu au putut fi încărcate. Încearcă din nou."
|
||||
},
|
||||
"sender_actions": "Expeditor…",
|
||||
"set_color": "Setați eticheta",
|
||||
"show_all_count": "Afișează tot ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Забыли вложение?",
|
||||
"attachments_too_large_total": "Вложения превысят лимит {max} на одно письмо, установленный этим сервером.",
|
||||
"editor_unavailable": "Не удалось прочитать содержимое письма. Скопируйте текст, затем закройте и снова откройте редактор.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Прикрепить} one {Прикрепить # файл} few {Прикрепить # файла} many {Прикрепить # файлов} other {Прикрепить # файла}}",
|
||||
"empty_folder": "Эта папка пуста",
|
||||
"load_failed": "Не удалось загрузить ваши файлы"
|
||||
},
|
||||
"from_options": "Параметры отправителя",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Удостоверение недоступно",
|
||||
@@ -458,6 +463,14 @@
|
||||
"remove_color": "Убрать тег",
|
||||
"replied": "Отвечено",
|
||||
"reschedule_recipients_refused": "Ничего не изменилось: сервер отклонил {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Сохранить в «Файлы»",
|
||||
"save_here": "Сохранить сюда",
|
||||
"saved": "Сохранено в «Файлы»",
|
||||
"saved_to": "В папку {folder}",
|
||||
"failed": "Не удалось сохранить в «Файлы»",
|
||||
"load_failed": "Не удалось загрузить папки. Повторите попытку."
|
||||
},
|
||||
"sender_actions": "Отправитель…",
|
||||
"set_color": "Назначить тег",
|
||||
"show_all_count": "Показать все ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Nezabudli ste prílohu?",
|
||||
"attachments_too_large_total": "Prílohy by prekročili limit {max}, ktorý tento server povoľuje na jednu správu.",
|
||||
"editor_unavailable": "Obsah správy sa nepodarilo načítať. Skopírujte si text a potom editor zatvorte a znova otvorte.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Priložiť} one {Priložiť 1 súbor} few {Priložiť # súbory} other {Priložiť # súborov}}",
|
||||
"empty_folder": "Tento priečinok je prázdny",
|
||||
"load_failed": "Vaše súbory sa nepodarilo načítať"
|
||||
},
|
||||
"from_options": "Možnosti odosielateľa",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Identita nie je k dispozícii",
|
||||
@@ -454,6 +459,14 @@
|
||||
"remove_color": "Odstrániť štítok",
|
||||
"replied": "Zodpovedané",
|
||||
"reschedule_recipients_refused": "Nič sa nezmenilo: server odmietol {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Uložiť do Súborov",
|
||||
"save_here": "Uložiť sem",
|
||||
"saved": "Uložené do Súborov",
|
||||
"saved_to": "Do priečinka {folder}",
|
||||
"failed": "Nepodarilo sa uložiť do Súborov",
|
||||
"load_failed": "Priečinky sa nepodarilo načítať. Skúste to znova."
|
||||
},
|
||||
"sender_actions": "Odosielateľ…",
|
||||
"set_color": "Nastaviť štítok",
|
||||
"show_all_count": "Zobraziť všetko ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Ek eklemeyi mi unuttunuz?",
|
||||
"attachments_too_large_total": "Ekler, bu sunucunun ileti başına izin verdiği {max} sınırını aşacak.",
|
||||
"editor_unavailable": "İleti içeriği okunamadı. Metninizi kopyalayın, ardından düzenleyiciyi kapatıp yeniden açın.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Ekle} one {1 dosya ekle} other {# dosya ekle}}",
|
||||
"empty_folder": "Bu klasör boş",
|
||||
"load_failed": "Dosyalarınız yüklenemedi"
|
||||
},
|
||||
"from_options": "Gönderen seçenekleri",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Kimlik kullanılamıyor",
|
||||
@@ -456,6 +461,14 @@
|
||||
"remove_color": "Etiketi kaldır",
|
||||
"replied": "Yanıtlandı",
|
||||
"reschedule_recipients_refused": "Hiçbir şey değiştirilmedi: sunucu şu alıcıları reddetti: {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Dosyalar'a kaydet",
|
||||
"save_here": "Buraya kaydet",
|
||||
"saved": "Dosyalar'a kaydedildi",
|
||||
"saved_to": "{folder} içine",
|
||||
"failed": "Dosyalar'a kaydedilemedi",
|
||||
"load_failed": "Klasörler yüklenemedi. Yeniden deneyin."
|
||||
},
|
||||
"sender_actions": "Gönderen…",
|
||||
"set_color": "Etiket ayarla",
|
||||
"show_all_count": "Tümünü göster ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "Забули вкладення?",
|
||||
"attachments_too_large_total": "Вкладення перевищать ліміт {max} на один лист, встановлений цим сервером.",
|
||||
"editor_unavailable": "Не вдалося прочитати вміст листа. Скопіюйте текст, потім закрийте й знову відкрийте редактор.",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {Прикріпити} one {Прикріпити # файл} few {Прикріпити # файли} many {Прикріпити # файлів} other {Прикріпити # файлу}}",
|
||||
"empty_folder": "Ця папка порожня",
|
||||
"load_failed": "Не вдалося завантажити ваші файли"
|
||||
},
|
||||
"from_options": "Параметри відправника",
|
||||
"group_chip": "{name} ({count})",
|
||||
"identity_unavailable": "Ідентичність недоступна",
|
||||
@@ -457,6 +462,14 @@
|
||||
"remove_color": "Прибрати тег",
|
||||
"replied": "Відповіли",
|
||||
"reschedule_recipients_refused": "Нічого не змінено: сервер відхилив {recipients}.",
|
||||
"save_to_files": {
|
||||
"title": "Зберегти у «Файли»",
|
||||
"save_here": "Зберегти сюди",
|
||||
"saved": "Збережено у «Файли»",
|
||||
"saved_to": "У теку {folder}",
|
||||
"failed": "Не вдалося зберегти у «Файли»",
|
||||
"load_failed": "Не вдалося завантажити теки. Спробуйте ще раз."
|
||||
},
|
||||
"sender_actions": "Відправник…",
|
||||
"set_color": "Призначити тег",
|
||||
"show_all_count": "Показати всі ({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "忘記新增附件了嗎?",
|
||||
"attachments_too_large_total": "附件將超過此伺服器每封郵件 {max} 的上限。",
|
||||
"editor_unavailable": "無法讀取郵件內容。請先複製您的文字,再關閉並重新開啟撰寫畫面。",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {附加} other {附加 # 個檔案}}",
|
||||
"empty_folder": "此資料夾是空的",
|
||||
"load_failed": "無法載入你的檔案"
|
||||
},
|
||||
"from_options": "寄件者選項",
|
||||
"group_chip": "{name}({count})",
|
||||
"identity_unavailable": "寄件身分無法使用",
|
||||
@@ -454,6 +459,14 @@
|
||||
"remove_color": "移除標籤",
|
||||
"replied": "已回覆",
|
||||
"reschedule_recipients_refused": "未作任何變更:伺服器拒絕了 {recipients}。",
|
||||
"save_to_files": {
|
||||
"title": "儲存到檔案",
|
||||
"save_here": "儲存在這裡",
|
||||
"saved": "已儲存到檔案",
|
||||
"saved_to": "在 {folder} 中",
|
||||
"failed": "無法儲存到檔案",
|
||||
"load_failed": "無法載入資料夾。請再試一次。"
|
||||
},
|
||||
"sender_actions": "寄件者…",
|
||||
"set_color": "設定標籤",
|
||||
"show_all_count": "顯示全部({count})",
|
||||
|
||||
@@ -284,6 +284,11 @@
|
||||
"attachment_reminder_title": "忘记添加附件了吗?",
|
||||
"attachments_too_large_total": "附件将超过此服务器每封邮件 {max} 的上限。",
|
||||
"editor_unavailable": "无法读取邮件内容。请复制您的文本,然后关闭并重新打开撰写窗口。",
|
||||
"files_picker": {
|
||||
"attach": "{count, plural, =0 {添加附件} other {添加 # 个文件}}",
|
||||
"empty_folder": "此文件夹为空",
|
||||
"load_failed": "无法加载你的文件"
|
||||
},
|
||||
"from_options": "发件人选项",
|
||||
"group_chip": "{name}({count})",
|
||||
"identity_unavailable": "身份不可用",
|
||||
@@ -454,6 +459,14 @@
|
||||
"remove_color": "删除标签",
|
||||
"replied": "已回复",
|
||||
"reschedule_recipients_refused": "未作任何更改:服务器拒绝了 {recipients}。",
|
||||
"save_to_files": {
|
||||
"title": "保存到文件",
|
||||
"save_here": "保存到此处",
|
||||
"saved": "已保存到文件",
|
||||
"saved_to": "在 {folder} 中",
|
||||
"failed": "无法保存到文件",
|
||||
"load_failed": "无法加载文件夹。请重试。"
|
||||
},
|
||||
"sender_actions": "发件人…",
|
||||
"set_color": "设置颜色标签",
|
||||
"show_all_count": "显示全部({count})",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: { accountId: 'me', request: vi.fn() },
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { copyBlobs } from '../email';
|
||||
|
||||
const request = vi.mocked(jmapClient.request);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('copyBlobs', () => {
|
||||
// Stalwart 0.16.27 and 1.0.0 answer unknownMethod without it (seen live).
|
||||
it('names the blob capability, without which Stalwart refuses Blob/copy', async () => {
|
||||
request.mockResolvedValueOnce({ methodResponses: [['Blob/copy', { copied: { b1: 'b9' } }, '0']] });
|
||||
|
||||
await expect(copyBlobs('team', ['b1'], 'me')).resolves.toEqual({ b1: 'b9' });
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
[['Blob/copy', { fromAccountId: 'team', accountId: 'me', blobIds: ['b1'] }, '0']],
|
||||
['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:blob'],
|
||||
);
|
||||
});
|
||||
|
||||
it('sends nothing within one account', async () => {
|
||||
await expect(copyBlobs('me', ['b1'], 'me')).resolves.toEqual({ b1: 'b1' });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when the server copied nothing', async () => {
|
||||
request.mockResolvedValueOnce({
|
||||
methodResponses: [['Blob/copy', { notCopied: { b1: { type: 'blobNotFound' } } }, '0']],
|
||||
});
|
||||
await expect(copyBlobs('team', ['b1'], 'me')).rejects.toThrow('blobNotFound');
|
||||
});
|
||||
});
|
||||
@@ -2490,3 +2490,28 @@ export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<str
|
||||
}
|
||||
return emailId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy blobs into another account of the login (RFC 8620 §6.3): the blob of
|
||||
* an attachment in a shared or group mailbox, filed in the user's Files, or
|
||||
* a file attached to a message of another account. Resolves with the new
|
||||
* blobId for each. Stalwart answers Blob/copy only with the blob capability
|
||||
* in `using`. Same account: the ids as they are, nothing is sent.
|
||||
*/
|
||||
export async function copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise<Record<string, string>> {
|
||||
if (blobIds.length === 0 || fromAccountId === toAccountId) {
|
||||
return Object.fromEntries(blobIds.map((id) => [id, id]));
|
||||
}
|
||||
const res = await jmapClient.request(
|
||||
[['Blob/copy', { fromAccountId, accountId: toAccountId, blobIds }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.BLOB],
|
||||
);
|
||||
const body = requireMethodResult(res, '0', 'Blob/copy');
|
||||
const copied = (body.copied ?? {}) as Record<string, string>;
|
||||
const missing = blobIds.find((id) => !copied[id]);
|
||||
if (missing) {
|
||||
const error = (body.notCopied ?? {})[missing] as { type?: string; description?: string } | undefined;
|
||||
throw new Error(error?.description || error?.type || 'Blob/copy did not copy the blob');
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, FlatList, ActivityIndicator } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ArrowLeft, X, Folder, FileText, Check, ChevronRight } from '../icons';
|
||||
import { getAllFileNodes, fileNodeKind } from '../../api/files';
|
||||
import type { FileNode } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { folderPath, pickerEntries } from '../../lib/compose-files';
|
||||
import { formatSize } from '../../lib/attachment-display';
|
||||
|
||||
type Props =
|
||||
| {
|
||||
visible: boolean;
|
||||
/** Pick files to attach (#1179). */
|
||||
mode: 'files';
|
||||
onClose: () => void;
|
||||
onPickFiles: (nodes: FileNode[]) => void;
|
||||
}
|
||||
| {
|
||||
visible: boolean;
|
||||
/** Pick the folder an attachment is saved to. */
|
||||
mode: 'folder';
|
||||
onClose: () => void;
|
||||
onPickFolder: (parentId: string | null, folderName: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user's Files, folder by folder: pick files to attach, or the folder
|
||||
* to save an attachment in (webmail file-picker-dialog and the Save to
|
||||
* Files folder picker). Own files only, without the hidden ones.
|
||||
*/
|
||||
export function FilesPickerSheet(props: Props) {
|
||||
const { visible, mode, onClose } = props;
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [nodes, setNodes] = React.useState<FileNode[] | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [here, setHere] = React.useState<string | null>(null);
|
||||
const [selected, setSelected] = React.useState<FileNode[]>([]);
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
setNodes(null);
|
||||
setError(null);
|
||||
getAllFileNodes().then(
|
||||
(list) => setNodes(list),
|
||||
(e: unknown) => {
|
||||
console.warn('[files-picker] listing failed', e);
|
||||
setError(t('email_composer.files_picker.load_failed', 'Your files could not be loaded'));
|
||||
},
|
||||
);
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!visible) return;
|
||||
setHere(null);
|
||||
setSelected([]);
|
||||
load();
|
||||
}, [visible, load]);
|
||||
|
||||
const entries = React.useMemo(
|
||||
() => (nodes ? pickerEntries(nodes, here, mode === 'folder') : []),
|
||||
[nodes, here, mode],
|
||||
);
|
||||
const path = React.useMemo(() => (nodes ? folderPath(nodes, here) : []), [nodes, here]);
|
||||
const myFiles = t('files.my_files', 'My files');
|
||||
const title = mode === 'folder'
|
||||
? t('email_viewer.save_to_files.title', 'Save to Files')
|
||||
: t('email_composer.attach_from_files', 'Attach from Files');
|
||||
|
||||
const toggle = (node: FileNode) => setSelected((prev) => (prev.some((n) => n.id === node.id)
|
||||
? prev.filter((n) => n.id !== node.id)
|
||||
: [...prev, node]));
|
||||
|
||||
const confirm = () => {
|
||||
if (props.mode === 'folder') {
|
||||
props.onPickFolder(here, path.at(-1)?.name ?? myFiles);
|
||||
} else if (selected.length > 0) {
|
||||
props.onPickFiles(selected);
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: FileNode }) => {
|
||||
const folder = fileNodeKind(item) === 'directory';
|
||||
const isSelected = selected.some((n) => n.id === item.id);
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => (folder ? setHere(item.id) : toggle(item))}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={folder ? undefined : { selected: isSelected }}
|
||||
>
|
||||
{folder ? <Folder size={18} color={c.primary} /> : <FileText size={18} color={c.textSecondary} />}
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowName} numberOfLines={1}>{item.name}</Text>
|
||||
{!folder && item.size != null && <Text style={styles.rowMeta}>{formatSize(item.size)}</Text>}
|
||||
</View>
|
||||
{folder
|
||||
? <ChevronRight size={16} color={c.textMuted} />
|
||||
: isSelected && <Check size={18} color={c.primary} />}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const confirmLabel = mode === 'folder'
|
||||
? t('email_viewer.save_to_files.save_here', 'Save here')
|
||||
: t('email_composer.files_picker.attach', '{count, plural, =0 {Attach} one {Attach 1 file} other {Attach # files}}', { count: selected.length });
|
||||
|
||||
return (
|
||||
<SafeAreaModal
|
||||
supportedOrientations={MODAL_ORIENTATIONS}
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
onRequestClose={here !== null ? () => setHere(path.at(-2)?.id ?? null) : onClose}
|
||||
>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom', 'left', 'right']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={here !== null ? () => setHere(path.at(-2)?.id ?? null) : onClose}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={here !== null ? t('common.back', 'Back') : t('common.close', 'Close')}
|
||||
>
|
||||
{here !== null ? <ArrowLeft size={22} color={c.text} /> : <X size={22} color={c.text} />}
|
||||
</Pressable>
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
|
||||
<Text style={styles.headerPath} numberOfLines={1}>
|
||||
{[myFiles, ...path.map((n) => n.name)].join(' / ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{error ? (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.error}>{error}</Text>
|
||||
<Pressable onPress={load}>
|
||||
<Text style={styles.retry}>{t('common.retry', 'Retry')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : !nodes ? (
|
||||
<View style={styles.center}><ActivityIndicator color={c.primary} /></View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={entries}
|
||||
keyExtractor={(n) => n.id}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ListEmptyComponent={(
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.empty}>{t('email_composer.files_picker.empty_folder', 'This folder is empty')}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Pressable
|
||||
onPress={confirm}
|
||||
disabled={!nodes || (mode === 'files' && selected.length === 0)}
|
||||
style={({ pressed }) => [
|
||||
styles.confirm,
|
||||
(!nodes || (mode === 'files' && selected.length === 0)) && styles.confirmDisabled,
|
||||
pressed && styles.rowPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={styles.confirmText}>{confirmLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</SafeAreaModal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md },
|
||||
headerText: { flex: 1, minWidth: 0 },
|
||||
headerTitle: { ...typography.h3, color: c.text },
|
||||
headerPath: { ...typography.caption, color: c.textMuted },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
minHeight: 52,
|
||||
},
|
||||
rowPressed: { opacity: 0.7 },
|
||||
rowMain: { flex: 1, minWidth: 0 },
|
||||
rowName: { ...typography.body, color: c.text },
|
||||
rowMeta: { ...typography.caption, color: c.textMuted },
|
||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
|
||||
empty: { ...typography.body, color: c.textSecondary },
|
||||
error: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||
retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
|
||||
footer: { padding: spacing.md, borderTopWidth: 1, borderTopColor: c.border },
|
||||
confirm: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 44,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
confirmDisabled: { opacity: 0.5 },
|
||||
confirmText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
|
||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons';
|
||||
import type { Attachment, Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -17,6 +17,10 @@ import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import type { ExtractedAttachment } from './use-body-override';
|
||||
import { previewKindOf, useFilePreview, type PreviewSource } from './use-file-preview';
|
||||
import { supportsFiles } from '../../api/files';
|
||||
import { saveAttachmentToFiles } from '../../lib/compose-files';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { FilesPickerSheet } from '../compose/FilesPickerSheet';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -64,6 +68,8 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [busyKey, setBusyKey] = React.useState<string | null>(null);
|
||||
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
|
||||
// The attachment Save to Files picks a folder for.
|
||||
const [filesTarget, setFilesTarget] = React.useState<Extract<Item, { kind: 'blob' }> | null>(null);
|
||||
const preview = useFilePreview();
|
||||
|
||||
const items = React.useMemo<Item[]>(() => {
|
||||
@@ -116,6 +122,25 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
|
||||
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
|
||||
|
||||
// File the attachment in the user's Files by its blob: nothing is
|
||||
// downloaded. A shared or group mailbox's blob is copied over first
|
||||
// (webmail save-to-files-dialog).
|
||||
const saveToFiles = (item: Extract<Item, { kind: 'blob' }>, parentId: string | null, folderName: string) => run(item.key, async () => {
|
||||
try {
|
||||
await saveAttachmentToFiles({ blobId: item.att.blobId, name: item.name, type: item.type }, parentId, jmapAccountId);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t('email_viewer.save_to_files.failed', "Couldn't save to Files"),
|
||||
e instanceof Error ? e.message : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
t('email_viewer.save_to_files.saved', 'Saved to Files'),
|
||||
{ message: t('email_viewer.save_to_files.saved_to', 'In {folder}', { folder: folderName }) },
|
||||
);
|
||||
}, t('email_viewer.save_to_files.failed', "Couldn't save to Files"));
|
||||
|
||||
const sourceOf = (item: Item): PreviewSource => (item.kind === 'blob'
|
||||
? {
|
||||
from: 'blob',
|
||||
@@ -190,6 +215,16 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
icon: <Share2 size={18} color={c.textSecondary} />,
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); void share(it); },
|
||||
},
|
||||
...(sheetItem.kind === 'blob' && supportsFiles() ? [{
|
||||
key: 'save_to_files',
|
||||
label: t('email_viewer.save_to_files.title', 'Save to Files'),
|
||||
icon: <FolderOpen size={18} color={c.textSecondary} />,
|
||||
onPress: () => {
|
||||
const it = sheetItem;
|
||||
setSheetItem(null);
|
||||
if (it.kind === 'blob') setFilesTarget(it);
|
||||
},
|
||||
}] : []),
|
||||
] : [];
|
||||
|
||||
return (
|
||||
@@ -241,6 +276,17 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
/>
|
||||
|
||||
<AttachmentPreviewModal {...preview.modalProps} />
|
||||
|
||||
<FilesPickerSheet
|
||||
visible={!!filesTarget}
|
||||
mode="folder"
|
||||
onClose={() => setFilesTarget(null)}
|
||||
onPickFolder={(parentId, folderName) => {
|
||||
const target = filesTarget;
|
||||
setFilesTarget(null);
|
||||
if (target) void saveToFiles(target, parentId, folderName);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { files, email } = vi.hoisted(() => ({
|
||||
files: {
|
||||
filesAccountId: vi.fn(() => 'me'),
|
||||
copyFileNode: vi.fn(async (node: { blobId: string }, parentId: string | null, name: string) => ({
|
||||
id: 'new-node', name, parentId, blobId: node.blobId, type: 'application/pdf',
|
||||
})),
|
||||
fileNodeKind: vi.fn((node: { nodeType?: string; blobId?: string | null }) =>
|
||||
node.nodeType ?? (node.blobId == null ? 'directory' : 'file')),
|
||||
},
|
||||
email: {
|
||||
// As the real one: the ids as they are within one account.
|
||||
copyBlobs: vi.fn(async (from: string, ids: string[], to: string) =>
|
||||
Object.fromEntries(ids.map((id) => [id, from === to ? id : `copy-of-${id}`]))),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../api/files', () => files);
|
||||
vi.mock('../../api/email', () => email);
|
||||
vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'me' } }));
|
||||
|
||||
import type { FileNode } from '../../api/types';
|
||||
import {
|
||||
attachmentFromFileNode, folderPath, pickerEntries, saveAttachmentToFiles,
|
||||
} from '../compose-files';
|
||||
|
||||
const node = (id: string, name: string, parentId: string | null, blobId?: string, extra: Partial<FileNode> = {}): FileNode =>
|
||||
({ id, name, parentId, blobId: blobId ?? null, type: blobId ? 'application/pdf' : '', ...extra });
|
||||
|
||||
const NODES = [
|
||||
node('d1', 'Projects', null),
|
||||
node('f1', 'b-report.pdf', null, 'blob-1', { size: 10 }),
|
||||
node('f0', 'a-notes.pdf', null, 'blob-0'),
|
||||
node('trash', '.Trash', null),
|
||||
node('hidden', '.cache', null, 'blob-h'),
|
||||
node('f2', 'plan.pdf', 'd1', 'blob-2'),
|
||||
node('d2', 'Archive', 'd1'),
|
||||
node('s1', 'theirs.pdf', null, 'blob-s', { isShared: true, accountId: 'team' }),
|
||||
];
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('the Files pickers', () => {
|
||||
it('lists folders first, then files by name, without hidden and shared nodes', () => {
|
||||
expect(pickerEntries(NODES, null).map((n) => n.id)).toEqual(['d1', 'f0', 'f1']);
|
||||
expect(pickerEntries(NODES, 'd1').map((n) => n.id)).toEqual(['d2', 'f2']);
|
||||
});
|
||||
|
||||
it('lists only folders to save into', () => {
|
||||
expect(pickerEntries(NODES, null, true).map((n) => n.id)).toEqual(['d1']);
|
||||
});
|
||||
|
||||
it('names the path down to a folder', () => {
|
||||
expect(folderPath(NODES, 'd2').map((n) => n.name)).toEqual(['Projects', 'Archive']);
|
||||
expect(folderPath(NODES, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attach from Files (#1179)', () => {
|
||||
it("attaches the file's blob as it is when Files and the message share the account", async () => {
|
||||
await expect(attachmentFromFileNode(NODES[1])).resolves.toEqual({
|
||||
blobId: 'blob-1', name: 'b-report.pdf', type: 'application/pdf', size: 10,
|
||||
});
|
||||
expect(email.copyBlobs).toHaveBeenCalledWith('me', ['blob-1'], 'me');
|
||||
});
|
||||
|
||||
it('copies the blob into the account of a message sent from elsewhere', async () => {
|
||||
const part = await attachmentFromFileNode(NODES[1], 'team');
|
||||
expect(email.copyBlobs).toHaveBeenCalledWith('me', ['blob-1'], 'team');
|
||||
expect(part.blobId).toBe('copy-of-blob-1');
|
||||
});
|
||||
|
||||
it('refuses a folder', async () => {
|
||||
await expect(attachmentFromFileNode(NODES[0])).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save to Files', () => {
|
||||
it('files an own attachment by its blob, in the folder picked', async () => {
|
||||
const saved = await saveAttachmentToFiles({ blobId: 'att-1', name: 'invoice.pdf', type: 'application/pdf' }, 'd1');
|
||||
expect(email.copyBlobs).toHaveBeenCalledWith('me', ['att-1'], 'me');
|
||||
expect(files.copyFileNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ blobId: 'att-1', nodeType: 'file' }), 'd1', 'invoice.pdf',
|
||||
);
|
||||
expect(saved.id).toBe('new-node');
|
||||
});
|
||||
|
||||
it("copies a group mailbox's attachment into the Files account first", async () => {
|
||||
await saveAttachmentToFiles({ blobId: 'att-1', name: 'invoice.pdf', type: 'application/pdf' }, null, 'team');
|
||||
expect(email.copyBlobs).toHaveBeenCalledWith('team', ['att-1'], 'me');
|
||||
expect(files.copyFileNode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ blobId: 'copy-of-att-1' }), null, 'invoice.pdf',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// Mail and Files meet here: a file from Files attached to a message (#1179)
|
||||
// and an attachment filed in Files (webmail save-to-files-dialog). Neither
|
||||
// downloads or uploads anything: both refer to a blob the server already
|
||||
// has. Stalwart 0.16.27 and 1.0.0 take a FileNode's blob as an attachment
|
||||
// and an attachment's blob for a FileNode within one account; across
|
||||
// accounts the blob is copied first (Blob/copy), as the webmail does.
|
||||
|
||||
import { copyBlobs } from '../api/email';
|
||||
import { copyFileNode, fileNodeKind, filesAccountId } from '../api/files';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import type { FileNode } from '../api/types';
|
||||
|
||||
/** The Files trash the webmail keeps (lib/files-trash): hidden like any dot folder. */
|
||||
const TRASH_FOLDER_NAME = '.Trash';
|
||||
|
||||
/** Whether a node is one Files hides: a dot file or folder, the trash among them. */
|
||||
export function isHiddenFileNode(node: Pick<FileNode, 'name'>): boolean {
|
||||
return node.name.startsWith('.') || node.name === TRASH_FOLDER_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes the pickers offer in `parentId` (null: My files): the user's
|
||||
* own, without hidden ones, folders first, then by name. `foldersOnly` for
|
||||
* the Save to Files folder picker.
|
||||
*/
|
||||
export function pickerEntries(nodes: FileNode[], parentId: string | null, foldersOnly = false): FileNode[] {
|
||||
return nodes
|
||||
.filter((n) => !n.isShared && !n.discoverableOnly && !isHiddenFileNode(n))
|
||||
.filter((n) => (n.parentId ?? null) === parentId)
|
||||
.filter((n) => {
|
||||
const kind = fileNodeKind(n);
|
||||
return foldersOnly ? kind === 'directory' : kind === 'directory' || kind === 'file';
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const af = fileNodeKind(a) === 'directory' ? 0 : 1;
|
||||
const bf = fileNodeKind(b) === 'directory' ? 0 : 1;
|
||||
return af - bf || a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/** The folders from My files down to `folderId`, for the picker's path. */
|
||||
export function folderPath(nodes: FileNode[], folderId: string | null): FileNode[] {
|
||||
const path: FileNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
let id = folderId;
|
||||
while (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (!node) break;
|
||||
path.unshift(node);
|
||||
id = node.parentId ?? null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file from Files as an attachment of a message in `mailAccountId`
|
||||
* (undefined: the login's own): its blob, copied into that account when
|
||||
* Files lives in another.
|
||||
*/
|
||||
export async function attachmentFromFileNode(
|
||||
node: FileNode,
|
||||
mailAccountId?: string,
|
||||
): Promise<{ blobId: string; name: string; type: string; size: number }> {
|
||||
if (!node.blobId || fileNodeKind(node) !== 'file') throw new Error('Only files can be attached');
|
||||
const from = node.accountId ?? filesAccountId();
|
||||
const to = mailAccountId ?? jmapClient.accountId;
|
||||
const blobId = (await copyBlobs(from, [node.blobId], to))[node.blobId];
|
||||
return { blobId, name: node.name, type: node.type || 'application/octet-stream', size: node.size ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* File an attachment in the user's Files, in `parentId` (null: My files).
|
||||
* `blobAccountId` is the account the message is in when it is not the
|
||||
* login's own: a shared or group mailbox's blob is copied into the Files
|
||||
* account first.
|
||||
*/
|
||||
export async function saveAttachmentToFiles(
|
||||
attachment: { blobId: string; name?: string | null; type?: string | null },
|
||||
parentId: string | null,
|
||||
blobAccountId?: string,
|
||||
): Promise<FileNode> {
|
||||
const from = blobAccountId ?? jmapClient.accountId;
|
||||
const blobId = (await copyBlobs(from, [attachment.blobId], filesAccountId()))[attachment.blobId];
|
||||
const name = attachment.name || 'attachment';
|
||||
const node: FileNode = { id: '', name, type: attachment.type || 'application/octet-stream', blobId, nodeType: 'file' };
|
||||
return copyFileNode(node, parentId, name);
|
||||
}
|
||||
@@ -68,8 +68,11 @@ import {
|
||||
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
|
||||
import { placeholderWordsFor, unfilledPlaceholdersInMessage } from '../lib/placeholder-check';
|
||||
import { sendTargetFor } from '../lib/compose-send-account';
|
||||
import { attachmentFromFileNode } from '../lib/compose-files';
|
||||
import { supportsFiles } from '../api/files';
|
||||
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
|
||||
import {
|
||||
ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
||||
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
||||
} from '../lib/compose-login';
|
||||
import {
|
||||
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
|
||||
@@ -86,7 +89,7 @@ import {
|
||||
generateSubAddress, extractDomain, suggestTagsForDomain, getTagValidationError, MAX_TAG_LENGTH,
|
||||
} from '../lib/sub-addressing';
|
||||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||
import type { EmailAddress, Identity } from '../api/types';
|
||||
import type { EmailAddress, FileNode, Identity } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import ToggleSwitch from '../components/ToggleSwitch';
|
||||
@@ -1779,12 +1782,57 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
};
|
||||
|
||||
const [attachMenuOpen, setAttachMenuOpen] = React.useState(false);
|
||||
// Files in the user's Files on the server, when it keeps them (#1179).
|
||||
const [filesPickerOpen, setFilesPickerOpen] = React.useState(false);
|
||||
const hasServerFiles = supportsFiles();
|
||||
const attachOptions: SheetOption[] = [
|
||||
{ label: t('email_composer.attach_photos', 'Photos & Videos'), onPress: () => { void pickPhotoAttachments(); } },
|
||||
{ label: t('email_composer.attach_camera', 'Camera'), onPress: () => { void takePhotoAttachment(); } },
|
||||
{ label: t('email_composer.attach_files', 'Files'), onPress: () => { void pickFileAttachments(); } },
|
||||
{
|
||||
// Next to "Attach from Files" a plain "Files" would read as the same.
|
||||
label: hasServerFiles
|
||||
? t('email_composer.attach_from_device', 'Upload from this device')
|
||||
: t('email_composer.attach_files', 'Files'),
|
||||
onPress: () => { void pickFileAttachments(); },
|
||||
},
|
||||
...(hasServerFiles ? [{
|
||||
label: t('email_composer.attach_from_files', 'Attach from Files'),
|
||||
onPress: () => { if (!refuseAttachAfterSwitch()) setFilesPickerOpen(true); },
|
||||
}] : []),
|
||||
];
|
||||
|
||||
// A file from Files is attached by its blob: nothing is downloaded or
|
||||
// uploaded, except a copy when Files lives in another account.
|
||||
const attachFileNodes = (nodes: FileNode[]) => {
|
||||
setFilesPickerOpen(false);
|
||||
for (const node of nodes) {
|
||||
if (refuseAttachAfterSwitch()) return;
|
||||
if (!checkAttachmentSize(node.size ?? 0, false)) continue;
|
||||
const entry: AttachmentEntry = {
|
||||
localId: genLocalId(),
|
||||
name: node.name,
|
||||
type: node.type || 'application/octet-stream',
|
||||
size: node.size ?? 0,
|
||||
uri: '',
|
||||
inline: false,
|
||||
uploading: true,
|
||||
};
|
||||
setAttachments((prev) => [...prev, entry]);
|
||||
void (async () => {
|
||||
try {
|
||||
assertComposerLogin(ownerLoginRef.current);
|
||||
const part = await attachmentFromFileNode(node);
|
||||
updateAttachment(entry.localId, { blobId: part.blobId, type: part.type, size: part.size || entry.size, uploading: false });
|
||||
} catch (e) {
|
||||
const message = e instanceof ComposerLoginMovedError
|
||||
? t('email_composer.upload_login_switched', 'Not uploaded: the account was switched')
|
||||
: e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed');
|
||||
updateAttachment(entry.localId, { uploading: false, error: message });
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
const insertInlineImages = async (assets: Array<{ uri: string; mimeType?: string | null; fileName?: string | null; fileSize?: number | null }>) => {
|
||||
if (refuseAttachAfterSwitch()) return;
|
||||
for (const asset of assets) {
|
||||
@@ -3131,6 +3179,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<FilesPickerSheet
|
||||
visible={filesPickerOpen}
|
||||
mode="files"
|
||||
onClose={() => setFilesPickerOpen(false)}
|
||||
onPickFiles={attachFileNodes}
|
||||
/>
|
||||
|
||||
<TemplateSheet
|
||||
visible={templateSheetOpen}
|
||||
onClose={() => setTemplateSheetOpen(false)}
|
||||
|
||||
Reference in New Issue
Block a user