diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 7e058448..bfbf4bfb 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -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})", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 44d6ff65..ea0450ba 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -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})", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 5de98a98..e429a32b 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -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})", diff --git a/locales/rn/da.json b/locales/rn/da.json index e3cd0e49..f774591d 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -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})", diff --git a/locales/rn/de.json b/locales/rn/de.json index 03a89a1c..7e31a4dc 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -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})", diff --git a/locales/rn/en.json b/locales/rn/en.json index 3819effd..bc777dc4 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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})", diff --git a/locales/rn/es.json b/locales/rn/es.json index 65890c30..c3116a2f 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -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})", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 6c7ec588..0199e8a0 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -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})", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index f06fe0e0..99b47bd0 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -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})", diff --git a/locales/rn/he.json b/locales/rn/he.json index e2284ede..90c456cc 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -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})", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 1097b64e..da5ec2f7 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -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})", diff --git a/locales/rn/it.json b/locales/rn/it.json index af57f520..3a7a1834 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -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})", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 8aa12d2c..777d80e7 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -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})", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 2e9a1745..37ef409a 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -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})", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 1d0def7a..830970af 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -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})", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 2096217a..805de501 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -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})", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index fa91a3af..4bb5a948 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -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})", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 1f791dfb..7c1afbe9 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -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})", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 4110ae81..8b35dfaf 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -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})", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 9dfe4dba..2f6cc9e3 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -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})", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 7ee57d75..15beeed3 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -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})", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index 47716fa5..5332f49a 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -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})", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 8ceabca5..c7969f07 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -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})", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 680a1923..b7d53793 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -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})", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 6b9479fd..dd7c950b 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -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})", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 5b4f4db2..9cdca666 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -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})", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 586df249..9a33879d 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -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})", diff --git a/src/api/__tests__/blob-copy.test.ts b/src/api/__tests__/blob-copy.test.ts new file mode 100644 index 00000000..e9f57c9a --- /dev/null +++ b/src/api/__tests__/blob-copy.test.ts @@ -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'); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index 61ba9fe8..75fedc13 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -2490,3 +2490,28 @@ export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise> { + 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; + 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; +} diff --git a/src/components/compose/FilesPickerSheet.tsx b/src/components/compose/FilesPickerSheet.tsx new file mode 100644 index 00000000..c624f5c4 --- /dev/null +++ b/src/components/compose/FilesPickerSheet.tsx @@ -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(null); + const [error, setError] = React.useState(null); + const [here, setHere] = React.useState(null); + const [selected, setSelected] = React.useState([]); + + 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 ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => (folder ? setHere(item.id) : toggle(item))} + accessibilityRole="button" + accessibilityState={folder ? undefined : { selected: isSelected }} + > + {folder ? : } + + {item.name} + {!folder && item.size != null && {formatSize(item.size)}} + + {folder + ? + : isSelected && } + + ); + }; + + 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 ( + setHere(path.at(-2)?.id ?? null) : onClose} + > + + + 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 ? : } + + + {title} + + {[myFiles, ...path.map((n) => n.name)].join(' / ')} + + + + + {error ? ( + + {error} + + {t('common.retry', 'Retry')} + + + ) : !nodes ? ( + + ) : ( + n.id} + renderItem={renderItem} + ItemSeparatorComponent={() => } + ListEmptyComponent={( + + {t('email_composer.files_picker.empty_folder', 'This folder is empty')} + + )} + /> + )} + + + [ + styles.confirm, + (!nodes || (mode === 'files' && selected.length === 0)) && styles.confirmDisabled, + pressed && styles.rowPressed, + ]} + accessibilityRole="button" + > + {confirmLabel} + + + + + ); +} + +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 }, + }); +} diff --git a/src/components/email/AttachmentChips.tsx b/src/components/email/AttachmentChips.tsx index 3f3232a1..b194485f 100644 --- a/src/components/email/AttachmentChips.tsx +++ b/src/components/email/AttachmentChips.tsx @@ -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(null); const [sheetItem, setSheetItem] = React.useState(null); + // The attachment Save to Files picks a folder for. + const [filesTarget, setFilesTarget] = React.useState | null>(null); const preview = useFilePreview(); const items = React.useMemo(() => { @@ -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, 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: , 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: , + onPress: () => { + const it = sheetItem; + setSheetItem(null); + if (it.kind === 'blob') setFilesTarget(it); + }, + }] : []), ] : []; return ( @@ -241,6 +276,17 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne /> + + setFilesTarget(null)} + onPickFolder={(parentId, folderName) => { + const target = filesTarget; + setFilesTarget(null); + if (target) void saveToFiles(target, parentId, folderName); + }} + /> ); } diff --git a/src/lib/__tests__/compose-files.test.ts b/src/lib/__tests__/compose-files.test.ts new file mode 100644 index 00000000..07010c62 --- /dev/null +++ b/src/lib/__tests__/compose-files.test.ts @@ -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 => + ({ 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', + ); + }); +}); diff --git a/src/lib/compose-files.ts b/src/lib/compose-files.ts new file mode 100644 index 00000000..bd56ad95 --- /dev/null +++ b/src/lib/compose-files.ts @@ -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): 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(); + 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 { + 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); +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index d21ebe7c..7c85eaf9 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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) { + setFilesPickerOpen(false)} + onPickFiles={attachFileNodes} + /> + setTemplateSheetOpen(false)}