feat: attach files from Files and save attachments to Files

This commit is contained in:
Linus Rath
2026-10-29 03:51:33 +01:00
parent 31d8e4a4b7
commit 8e07b7d891
34 changed files with 920 additions and 4 deletions
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+7
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+13
View File
@@ -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})",
+38
View File
@@ -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');
});
});
+25
View File
@@ -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;
}
+224
View File
@@ -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 },
});
}
+47 -1
View File
@@ -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>
);
}
+95
View File
@@ -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',
);
});
});
+88
View File
@@ -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);
}
+58 -3
View File
@@ -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)}