diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 464526d0..7e058448 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -292,6 +292,25 @@ "no_sent_folder": "لم يتم العثور على مجلد المرسل", "permission_camera": "يلزم إذن الكاميرا لالتقاط صورة.", "permission_photos": "يلزم إذن مكتبة الصور لإرفاق الصور.", + "placeholder_warning": { + "title": "التحقق من العناصر النائبة؟", + "message": "تحتوي رسالتك على نص نائب سيراه المستلمون. هل تريد تعديله قبل الإرسال؟", + "found": "العناصر النائبة التي عُثر عليها", + "more": "و{count} أخرى", + "dont_ask_again": "عدم السؤال مرة أخرى", + "send_anyway": "الإرسال على أي حال", + "back": "العودة إلى التعديل" + }, + "placeholder_words": { + "before": [ + "أدخل", + "ادخل", + "أضف", + "اكتب" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "إضافة إلى جهات الاتصال", "recipient_copy": "نسخ العنوان", "recipient_move_to": "نقل إلى {field}", @@ -820,6 +839,10 @@ "ask_mobile": "السؤال", "block_mobile": "الحظر" }, + "placeholder_warning": { + "label": "تحذير العناصر النائبة", + "description": "طلب تأكيد قبل إرسال رسالة لا تزال تحتوي على عناصر نائبة مثل [TIME] أو [أدخل الاسم]، أو حقول قالب فارغة، أو نص غير مكتمل مثل TODO أو TBD." + }, "plain_text_mode": { "description_mobile": "الكتابة والقراءة بنص عادي فقط." }, diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 32c92040..44d6ff65 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -292,6 +292,30 @@ "no_sent_folder": "No s'ha trobat la carpeta Enviats", "permission_camera": "Cal permís per a la càmera per fer una foto.", "permission_photos": "Cal permís per a la galeria de fotos per adjuntar imatges.", + "placeholder_warning": { + "title": "Voleu revisar els marcadors?", + "message": "El missatge té text de marcador que veuran els destinataris. Voleu editar-lo abans d'enviar-lo?", + "found": "Marcadors trobats", + "more": "i {count} més", + "dont_ask_again": "No ho tornis a preguntar", + "send_anyway": "Envia igualment", + "back": "Torna a l'edició" + }, + "placeholder_words": { + "before": [ + "el teu", + "la teva", + "el vostre", + "la vostra", + "insereix", + "inseriu", + "afegeix", + "afegiu", + "escriviu" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Afegeix als contactes", "recipient_copy": "Copia l'adreça", "recipient_move_to": "Mou a {field}", @@ -820,6 +844,10 @@ "ask_mobile": "Pregunta", "block_mobile": "Bloqueja" }, + "placeholder_warning": { + "label": "Avís de marcadors", + "description": "Demana confirmació abans d'enviar un missatge que encara té marcadors com [TIME] o [El teu nom], camps de plantilla buits o text inacabat com TODO o TBD." + }, "plain_text_mode": { "description_mobile": "Redacta i llegeix només en text sense format." }, diff --git a/locales/rn/cs.json b/locales/rn/cs.json index cb54679a..5de98a98 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -292,6 +292,30 @@ "no_sent_folder": "Složka Odeslané nebyla nalezena", "permission_camera": "K pořízení fotky je potřeba oprávnění k fotoaparátu.", "permission_photos": "K připojení obrázků je potřeba oprávnění ke knihovně fotek.", + "placeholder_warning": { + "title": "Zkontrolovat zástupné texty?", + "message": "Vaše zpráva obsahuje zástupný text, který příjemci uvidí. Chcete ji před odesláním upravit?", + "found": "Nalezené zástupné texty", + "more": "a další ({count})", + "dont_ask_again": "Znovu se neptat", + "send_anyway": "Přesto odeslat", + "back": "Zpět k úpravám" + }, + "placeholder_words": { + "before": [ + "vaše", + "váš", + "vaši", + "tvoje", + "tvůj", + "vložte", + "doplňte", + "zadejte", + "uveďte" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Přidat do kontaktů", "recipient_copy": "Kopírovat adresu", "recipient_move_to": "Přesunout do pole {field}", @@ -820,6 +844,10 @@ "ask_mobile": "Ptát se", "block_mobile": "Blokovat" }, + "placeholder_warning": { + "label": "Upozornění na zástupné texty", + "description": "Před odesláním zprávy, která stále obsahuje zástupné texty jako [TIME] nebo [Vaše jméno], nevyplněná pole šablony nebo nedokončený text jako TODO či TBD, požádat o potvrzení." + }, "plain_text_mode": { "description_mobile": "Psát a číst pouze v prostém textu." }, diff --git a/locales/rn/da.json b/locales/rn/da.json index 70bcabfd..e3cd0e49 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -292,6 +292,27 @@ "no_sent_folder": "Ingen Sendt-mappe fundet", "permission_camera": "Der kræves kameratilladelse for at tage et foto.", "permission_photos": "Der kræves tilladelse til fotobiblioteket for at vedhæfte billeder.", + "placeholder_warning": { + "title": "Tjek pladsholdere?", + "message": "Din besked indeholder pladsholdertekst, som modtagerne vil se. Vil du redigere den, før du sender?", + "found": "Fundne pladsholdere", + "more": "og {count} mere", + "dont_ask_again": "Spørg ikke igen", + "send_anyway": "Send alligevel", + "back": "Tilbage til redigering" + }, + "placeholder_words": { + "before": [ + "din", + "dit", + "dine", + "indsæt", + "tilføj", + "skriv" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Tilføj til kontakter", "recipient_copy": "Kopiér adresse", "recipient_move_to": "Flyt til {field}", @@ -820,6 +841,10 @@ "ask_mobile": "Spørg", "block_mobile": "Blokér" }, + "placeholder_warning": { + "label": "Advarsel om pladsholdere", + "description": "Bed om bekræftelse, før en besked sendes, der stadig har pladsholdere som [TIME] eller [Dit navn], tomme skabelonfelter eller ufærdig tekst som TODO eller TBD." + }, "plain_text_mode": { "description_mobile": "Skriv og læs kun i ren tekst." }, diff --git a/locales/rn/de.json b/locales/rn/de.json index 4fdbc8bb..03a89a1c 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -294,6 +294,31 @@ "no_sent_folder": "Kein Gesendet-Ordner gefunden", "permission_camera": "Zum Aufnehmen eines Fotos ist die Kameraberechtigung erforderlich.", "permission_photos": "Zum Anhängen von Bildern ist die Berechtigung für die Fotomediathek erforderlich.", + "placeholder_warning": { + "title": "Platzhalter prüfen?", + "message": "Die Nachricht enthält Platzhaltertext, den die Empfänger sehen. Vor dem Senden bearbeiten?", + "found": "Gefundene Platzhalter", + "more": "und {count} weitere", + "dont_ask_again": "Nicht mehr fragen", + "send_anyway": "Trotzdem senden", + "back": "Zurück zur Bearbeitung" + }, + "placeholder_words": { + "before": [ + "ihr", + "ihre", + "ihren", + "dein", + "deine", + "deinen" + ], + "after": [ + "einfügen", + "eintragen", + "ergänzen" + ], + "unfinished": [] + }, "recipient_add_contact": "Zu Kontakten hinzufügen", "recipient_copy": "Adresse kopieren", "recipient_move_to": "Verschieben nach {field}", @@ -829,6 +854,10 @@ "ask_mobile": "Fragen", "block_mobile": "Blockieren" }, + "placeholder_warning": { + "label": "Warnung bei Platzhaltern", + "description": "Vor dem Senden einer Nachricht nachfragen, die noch Platzhalter wie [TIME] oder [Ihr Name], leere Vorlagenfelder oder unfertigen Text wie TODO oder TBD enthält." + }, "plain_text_mode": { "description_mobile": "Nur im Klartext verfassen und lesen." }, diff --git a/locales/rn/en.json b/locales/rn/en.json index 983d1aea..3819effd 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -306,6 +306,32 @@ "no_sent_folder": "No Sent folder found", "permission_camera": "Camera permission is required to take a photo.", "permission_photos": "Photo library permission is required to attach images.", + "placeholder_warning": { + "back": "Back to editing", + "dont_ask_again": "Don't ask again", + "found": "Placeholders found", + "message": "Your message has placeholder text that recipients will see. Do you want to edit it before sending?", + "more": "and {count} more", + "send_anyway": "Send anyway", + "title": "Check placeholders?" + }, + "placeholder_words": { + "after": [], + "before": [ + "your", + "insert", + "enter", + "add" + ], + "unfinished": [ + "TODO", + "TBD", + "TBA", + "TBC", + "FIXME", + "XXX" + ] + }, "read_receipt_off": "Request a read receipt", "recipient_add_contact": "Add to contacts", "recipient_copy": "Copy address", @@ -911,6 +937,10 @@ "ask_mobile": "Ask", "block_mobile": "Block" }, + "placeholder_warning": { + "description": "Ask for confirmation before sending a message that still has placeholders like [TIME] or [Your Name], template fields left empty, or unfinished text like TODO or TBD.", + "label": "Placeholder warning" + }, "plain_text_mode": { "description_mobile": "Compose and read in plain text only." }, diff --git a/locales/rn/es.json b/locales/rn/es.json index 444d7611..65890c30 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -292,6 +292,36 @@ "no_sent_folder": "No se encontró la carpeta Enviados", "permission_camera": "Se necesita permiso de cámara para tomar una foto.", "permission_photos": "Se necesita acceso a la galería de fotos para adjuntar imágenes.", + "placeholder_warning": { + "title": "¿Revisar los marcadores?", + "message": "El mensaje tiene texto de marcador que verán los destinatarios. ¿Editarlo antes de enviar?", + "found": "Marcadores encontrados", + "more": "y {count} más", + "dont_ask_again": "No volver a preguntar", + "send_anyway": "Enviar de todos modos", + "back": "Volver a editar" + }, + "placeholder_words": { + "before": [ + "tu", + "tus", + "su", + "sus", + "insertar", + "inserte", + "inserta", + "añadir", + "añade", + "agregar", + "agrega", + "escribe", + "escriba", + "indica", + "indique" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Añadir a contactos", "recipient_copy": "Copiar dirección", "recipient_move_to": "Mover a {field}", @@ -820,6 +850,10 @@ "ask_mobile": "Preguntar", "block_mobile": "Bloquear" }, + "placeholder_warning": { + "label": "Aviso de marcadores", + "description": "Pedir confirmación antes de enviar un mensaje que todavía tiene marcadores como [TIME] o [Tu nombre], campos de plantilla vacíos o texto sin terminar como TODO o TBD." + }, "plain_text_mode": { "description_mobile": "Redactar y leer solo en texto sin formato." }, diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 39d797d8..6c7ec588 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -292,6 +292,24 @@ "no_sent_folder": "پوشه ارسال شده پیدا نشد", "permission_camera": "برای گرفتن عکس، دسترسی به دوربین لازم است.", "permission_photos": "برای پیوست کردن تصاویر، دسترسی به گالری عکس لازم است.", + "placeholder_warning": { + "title": "متن‌های جایگزین بررسی شود؟", + "message": "پیام شما متن جایگزینی دارد که گیرندگان آن را می‌بینند. می‌خواهید پیش از ارسال آن را ویرایش کنید؟", + "found": "متن‌های جایگزین یافت‌شده", + "more": "و {count} مورد دیگر", + "dont_ask_again": "دیگر نپرس", + "send_anyway": "ارسال به هر حال", + "back": "بازگشت به ویرایش" + }, + "placeholder_words": { + "before": [], + "after": [ + "شما", + "وارد کنید", + "اضافه کنید" + ], + "unfinished": [] + }, "recipient_add_contact": "افزودن به مخاطبین", "recipient_copy": "کپی نشانی", "recipient_move_to": "انتقال به {field}", @@ -820,6 +838,10 @@ "ask_mobile": "بپرس", "block_mobile": "مسدود" }, + "placeholder_warning": { + "label": "هشدار متن جایگزین", + "description": "پیش از ارسال پیامی که هنوز متن جایگزین مانند [TIME] یا [نام شما]، فیلدهای خالی الگو یا متن ناتمام مانند TODO یا TBD دارد، تأیید بگیرید." + }, "plain_text_mode": { "description_mobile": "نوشتن و خواندن فقط به‌صورت متن ساده." }, diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 1a4d07db..f06fe0e0 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -292,6 +292,34 @@ "no_sent_folder": "Aucun dossier Envoyés trouvé", "permission_camera": "L'autorisation d'accès à l'appareil photo est nécessaire pour prendre une photo.", "permission_photos": "L'autorisation d'accès à la photothèque est nécessaire pour joindre des images.", + "placeholder_warning": { + "title": "Vérifier les espaces réservés ?", + "message": "Votre message contient du texte d'espace réservé que les destinataires verront. Voulez-vous le modifier avant l'envoi ?", + "found": "Espaces réservés trouvés", + "more": "et {count} de plus", + "dont_ask_again": "Ne plus me demander", + "send_anyway": "Envoyer quand même", + "back": "Retour à l'édition" + }, + "placeholder_words": { + "before": [ + "votre", + "vos", + "ton", + "ta", + "tes", + "insérer", + "insérez", + "ajouter", + "ajoutez", + "saisir", + "saisissez", + "indiquer", + "indiquez" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Ajouter aux contacts", "recipient_copy": "Copier l'adresse", "recipient_move_to": "Déplacer vers {field}", @@ -820,6 +848,10 @@ "ask_mobile": "Demander", "block_mobile": "Bloquer" }, + "placeholder_warning": { + "label": "Avertissement d'espaces réservés", + "description": "Demander confirmation avant d'envoyer un message qui contient encore des espaces réservés comme [TIME] ou [Votre nom], des champs de modèle vides ou du texte inachevé comme TODO ou TBD." + }, "plain_text_mode": { "description_mobile": "Rédiger et lire uniquement en texte brut." }, diff --git a/locales/rn/he.json b/locales/rn/he.json index f20d0c1f..e2284ede 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -292,6 +292,28 @@ "no_sent_folder": "לא נמצאה תיקיית \"נשלח\"", "permission_camera": "נדרשת הרשאת מצלמה כדי לצלם תמונה.", "permission_photos": "נדרשת הרשאה לספריית התמונות כדי לצרף תמונות.", + "placeholder_warning": { + "title": "לבדוק מצייני מיקום?", + "message": "ההודעה מכילה טקסט מציין מיקום שהנמענים יראו. לערוך אותה לפני השליחה?", + "found": "מצייני מיקום שנמצאו", + "more": "ועוד {count}", + "dont_ask_again": "אל תשאל שוב", + "send_anyway": "שלח בכל זאת", + "back": "חזור לעריכה" + }, + "placeholder_words": { + "before": [ + "הכנס", + "הכניסו", + "הזן", + "הוסף" + ], + "after": [ + "שלך", + "שלכם" + ], + "unfinished": [] + }, "recipient_add_contact": "הוסף לאנשי הקשר", "recipient_copy": "העתק כתובת", "recipient_move_to": "העבר אל {field}", @@ -820,6 +842,10 @@ "ask_mobile": "שאל", "block_mobile": "חסום" }, + "placeholder_warning": { + "label": "אזהרה על מצייני מיקום", + "description": "בקש אישור לפני שליחת הודעה שעדיין יש בה מצייני מיקום כמו [TIME] או [השם שלך], שדות תבנית ריקים או טקסט לא גמור כמו TODO או TBD." + }, "plain_text_mode": { "description_mobile": "כתיבה וקריאה בטקסט רגיל בלבד." }, diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 834df745..1097b64e 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -292,6 +292,26 @@ "no_sent_folder": "Nem található Elküldött mappa", "permission_camera": "Fénykép készítéséhez kameraengedély szükséges.", "permission_photos": "Képek csatolásához hozzáférés szükséges a fotókhoz.", + "placeholder_warning": { + "title": "Ellenőrzi a helykitöltőket?", + "message": "Az üzenet olyan helykitöltő szöveget tartalmaz, amelyet a címzettek is látni fognak. Szerkeszti küldés előtt?", + "found": "Talált helykitöltők", + "more": "és még {count}", + "dont_ask_again": "Ne kérdezze többé", + "send_anyway": "Küldés így is", + "back": "Vissza a szerkesztéshez" + }, + "placeholder_words": { + "before": [ + "az ön", + "a te", + "írja be", + "illessze be", + "adja meg" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Hozzáadás a névjegyekhez", "recipient_copy": "Cím másolása", "recipient_move_to": "Áthelyezés ide: {field}", @@ -820,6 +840,10 @@ "ask_mobile": "Rákérdezés", "block_mobile": "Blokkolás" }, + "placeholder_warning": { + "label": "Figyelmeztetés helykitöltők esetén", + "description": "Megerősítés kérése olyan üzenet küldése előtt, amelyben még helykitöltők vannak, például [TIME] vagy [Az Ön neve], üres sablonmezők vagy befejezetlen szöveg, például TODO vagy TBD." + }, "plain_text_mode": { "description_mobile": "Írás és olvasás csak egyszerű szövegként." }, diff --git a/locales/rn/it.json b/locales/rn/it.json index 771ce157..af57f520 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -292,6 +292,32 @@ "no_sent_folder": "Nessuna cartella Inviati trovata", "permission_camera": "Serve l'autorizzazione alla fotocamera per scattare una foto.", "permission_photos": "Serve l'autorizzazione alla galleria foto per allegare immagini.", + "placeholder_warning": { + "title": "Controllare i segnaposto?", + "message": "Il messaggio contiene testo segnaposto che i destinatari vedranno. Vuoi modificarlo prima di inviarlo?", + "found": "Segnaposto trovati", + "more": "e altri {count}", + "dont_ask_again": "Non chiedere più", + "send_anyway": "Invia comunque", + "back": "Torna alla modifica" + }, + "placeholder_words": { + "before": [ + "il tuo", + "la tua", + "i tuoi", + "le tue", + "il suo", + "la sua", + "inserisci", + "inserire", + "aggiungi", + "aggiungere", + "indica" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Aggiungi ai contatti", "recipient_copy": "Copia indirizzo", "recipient_move_to": "Sposta in {field}", @@ -820,6 +846,10 @@ "ask_mobile": "Chiedi", "block_mobile": "Blocca" }, + "placeholder_warning": { + "label": "Avviso segnaposto", + "description": "Chiedi conferma prima di inviare un messaggio che contiene ancora segnaposto come [TIME] o [Il tuo nome], campi del modello vuoti o testo incompleto come TODO o TBD." + }, "plain_text_mode": { "description_mobile": "Scrivi e leggi solo in testo normale." }, diff --git a/locales/rn/ja.json b/locales/rn/ja.json index bb6e0c9c..8aa12d2c 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -292,6 +292,30 @@ "no_sent_folder": "送信済みフォルダーが見つかりません", "permission_camera": "写真を撮影するには、カメラへのアクセス許可が必要です。", "permission_photos": "画像を添付するには、写真ライブラリへのアクセス許可が必要です。", + "placeholder_warning": { + "title": "プレースホルダーを確認しますか?", + "message": "メッセージに受信者に表示されるプレースホルダーのテキストが含まれています。送信前に編集しますか?", + "found": "見つかったプレースホルダー", + "more": "ほか {count} 件", + "dont_ask_again": "今後は確認しない", + "send_anyway": "そのまま送信", + "back": "編集に戻る" + }, + "placeholder_words": { + "before": [ + "あなたの-", + "貴社の-", + "御社の-" + ], + "after": [ + "-を入力", + "-を記入", + "-を挿入", + "-を追加", + "-を入れる" + ], + "unfinished": [] + }, "recipient_add_contact": "連絡先に追加", "recipient_copy": "アドレスをコピー", "recipient_move_to": "{field}に移動", @@ -820,6 +844,10 @@ "ask_mobile": "確認", "block_mobile": "ブロック" }, + "placeholder_warning": { + "label": "プレースホルダーの警告", + "description": "[TIME] や [あなたの名前] などのプレースホルダー、空のテンプレート項目、TODO や TBD などの未完成のテキストが残っているメッセージを送信する前に確認します。" + }, "plain_text_mode": { "description_mobile": "プレーンテキストのみで作成・閲覧します。" }, diff --git a/locales/rn/ko.json b/locales/rn/ko.json index d3d768b7..2e9a1745 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -292,6 +292,28 @@ "no_sent_folder": "보낸편지함을 찾을 수 없어요", "permission_camera": "사진을 찍으려면 카메라 권한이 필요해요.", "permission_photos": "이미지를 첨부하려면 사진 보관함 권한이 필요해요.", + "placeholder_warning": { + "title": "자리표시자를 확인할까요?", + "message": "메시지에 받는 사람에게 보이는 자리표시자 텍스트가 있습니다. 보내기 전에 수정할까요?", + "found": "찾은 자리표시자", + "more": "외 {count}개", + "dont_ask_again": "다시 묻지 않기", + "send_anyway": "그래도 보내기", + "back": "편집으로 돌아가기" + }, + "placeholder_words": { + "before": [ + "귀하의", + "당신의", + "고객님의" + ], + "after": [ + "입력", + "삽입", + "추가" + ], + "unfinished": [] + }, "recipient_add_contact": "연락처에 추가", "recipient_copy": "주소 복사", "recipient_move_to": "{field}(으)로 이동", @@ -820,6 +842,10 @@ "ask_mobile": "묻기", "block_mobile": "차단" }, + "placeholder_warning": { + "label": "자리표시자 경고", + "description": "[TIME]이나 [귀하의 이름] 같은 자리표시자, 비어 있는 템플릿 필드, TODO나 TBD 같은 미완성 텍스트가 남아 있는 메시지를 보내기 전에 확인을 요청합니다." + }, "plain_text_mode": { "description_mobile": "일반 텍스트로만 작성하고 읽어요." }, diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 10c44956..1d0def7a 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -292,6 +292,29 @@ "no_sent_folder": "Mape Nosūtītie nav atrasta", "permission_camera": "Lai uzņemtu fotoattēlu, nepieciešama kameras atļauja.", "permission_photos": "Lai pievienotu attēlus, nepieciešama fotoattēlu bibliotēkas atļauja.", + "placeholder_warning": { + "title": "Pārbaudīt vietturus?", + "message": "Ziņā ir viettura teksts, ko redzēs adresāti. Vai rediģēt to pirms sūtīšanas?", + "found": "Atrastie vietturi", + "more": "un vēl {count}", + "dont_ask_again": "Vairs nejautāt", + "send_anyway": "Sūtīt tik un tā", + "back": "Atpakaļ pie rediģēšanas" + }, + "placeholder_words": { + "before": [ + "jūsu", + "tavs", + "tava", + "ievadiet", + "ierakstiet", + "ievietojiet", + "pievienojiet", + "norādiet" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Pievienot kontaktpersonām", "recipient_copy": "Kopēt adresi", "recipient_move_to": "Pārvietot uz lauku {field}", @@ -820,6 +843,10 @@ "ask_mobile": "Jautāt", "block_mobile": "Bloķēt" }, + "placeholder_warning": { + "label": "Brīdinājums par vietturiem", + "description": "Pieprasīt apstiprinājumu pirms tādas ziņas sūtīšanas, kurā vēl ir vietturi, piemēram, [TIME] vai [Jūsu vārds], tukši veidnes lauki vai nepabeigts teksts, piemēram, TODO vai TBD." + }, "plain_text_mode": { "description_mobile": "Rakstīt un lasīt tikai vienkāršā tekstā." }, diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 96eced5f..2096217a 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -292,6 +292,25 @@ "no_sent_folder": "Илгээсэн хавтас олдсонгүй", "permission_camera": "Зураг авахын тулд камерын зөвшөөрөл шаардлагатай.", "permission_photos": "Зураг хавсаргахын тулд зургийн сангийн зөвшөөрөл шаардлагатай.", + "placeholder_warning": { + "title": "Орлуулагч текстийг шалгах уу?", + "message": "Таны зурваст хүлээн авагчид харагдах орлуулагч текст байна. Илгээхээс өмнө засах уу?", + "found": "Олдсон орлуулагч текст", + "more": "болон өөр {count}", + "dont_ask_again": "Дахиж битгий асуу", + "send_anyway": "Ямар ч байсан илгээнэ үү", + "back": "Засварлах руу буцах" + }, + "placeholder_words": { + "before": [ + "таны", + "чиний" + ], + "after": [ + "оруулна уу" + ], + "unfinished": [] + }, "recipient_add_contact": "Харилцагчдад нэмэх", "recipient_copy": "Хаягийг хуулах", "recipient_move_to": "\"{field}\" талбар руу зөөх", @@ -820,6 +839,10 @@ "ask_mobile": "Асуух", "block_mobile": "Блоклох" }, + "placeholder_warning": { + "label": "Орлуулагч текстийн анхааруулга", + "description": "[TIME] эсвэл [Таны нэр] зэрэг орлуулагч текст, хоосон загварын талбар, эсвэл TODO, TBD зэрэг дуусаагүй текст үлдсэн мессеж илгээхээс өмнө баталгаажуулахыг хүс." + }, "plain_text_mode": { "description_mobile": "Зөвхөн энгийн текстээр бичих, унших." }, diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 99e55e64..fa91a3af 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -292,6 +292,27 @@ "no_sent_folder": "Fant ingen Sendt-mappe", "permission_camera": "Du må gi tilgang til kameraet for å ta et bilde.", "permission_photos": "Du må gi tilgang til bildebiblioteket for å legge ved bilder.", + "placeholder_warning": { + "title": "Sjekke plassholdere?", + "message": "Meldingen har plassholdertekst som mottakerne vil se. Vil du redigere den før du sender?", + "found": "Plassholdere som ble funnet", + "more": "og {count} til", + "dont_ask_again": "Ikke spør igjen", + "send_anyway": "Send likevel", + "back": "Tilbake til redigering" + }, + "placeholder_words": { + "before": [ + "din", + "ditt", + "dine", + "sett inn", + "legg til", + "skriv" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Legg til som kontakt", "recipient_copy": "Kopier adressen", "recipient_move_to": "Flytt til {field}", @@ -820,6 +841,10 @@ "ask_mobile": "Spør", "block_mobile": "Blokker" }, + "placeholder_warning": { + "label": "Advarsel om plassholdere", + "description": "Be om bekreftelse før du sender en melding som fortsatt har plassholdere som [TIME] eller [Ditt navn], tomme malfelt eller uferdig tekst som TODO eller TBD." + }, "plain_text_mode": { "description_mobile": "Skriv og les bare ren tekst." }, diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 4033f650..1f791dfb 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -292,6 +292,30 @@ "no_sent_folder": "Geen map Verzonden gevonden", "permission_camera": "Toestemming voor de camera is nodig om een foto te maken.", "permission_photos": "Toestemming voor de fotobibliotheek is nodig om afbeeldingen bij te voegen.", + "placeholder_warning": { + "title": "Tijdelijke aanduidingen controleren?", + "message": "Je bericht bevat tijdelijke tekst die ontvangers zullen zien. Wil je het bewerken voordat je het verzendt?", + "found": "Gevonden tijdelijke aanduidingen", + "more": "en nog {count}", + "dont_ask_again": "Niet meer vragen", + "send_anyway": "Toch verzenden", + "back": "Terug naar bewerken" + }, + "placeholder_words": { + "before": [ + "je", + "jouw", + "uw", + "voeg", + "vul" + ], + "after": [ + "invoegen", + "invullen", + "toevoegen" + ], + "unfinished": [] + }, "recipient_add_contact": "Aan contacten toevoegen", "recipient_copy": "Adres kopiëren", "recipient_move_to": "Verplaatsen naar {field}", @@ -820,6 +844,10 @@ "ask_mobile": "Vragen", "block_mobile": "Blokkeren" }, + "placeholder_warning": { + "label": "Waarschuwing bij tijdelijke aanduidingen", + "description": "Vraag om bevestiging voordat een bericht wordt verzonden dat nog tijdelijke aanduidingen bevat zoals [TIME] of [Uw naam], lege sjabloonvelden of onafgemaakte tekst zoals TODO of TBD." + }, "plain_text_mode": { "description_mobile": "Alleen in platte tekst opstellen en lezen." }, diff --git a/locales/rn/pl.json b/locales/rn/pl.json index f28262cf..4110ae81 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -292,6 +292,30 @@ "no_sent_folder": "Nie znaleziono folderu Wysłane", "permission_camera": "Aby zrobić zdjęcie, potrzebny jest dostęp do aparatu.", "permission_photos": "Aby załączać obrazy, potrzebny jest dostęp do galerii zdjęć.", + "placeholder_warning": { + "title": "Sprawdzić symbole zastępcze?", + "message": "Wiadomość zawiera tekst zastępczy, który zobaczą odbiorcy. Czy chcesz go poprawić przed wysłaniem?", + "found": "Znalezione symbole zastępcze", + "more": "i jeszcze {count}", + "dont_ask_again": "Nie pytaj ponownie", + "send_anyway": "Wyślij mimo to", + "back": "Wróć do edycji" + }, + "placeholder_words": { + "before": [ + "twoje", + "twój", + "twoja", + "pana", + "pani", + "wstaw", + "wpisz", + "dodaj", + "podaj" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Dodaj do kontaktów", "recipient_copy": "Kopiuj adres", "recipient_move_to": "Przenieś do pola {field}", @@ -820,6 +844,10 @@ "ask_mobile": "Pytaj", "block_mobile": "Blokuj" }, + "placeholder_warning": { + "label": "Ostrzeżenie o symbolach zastępczych", + "description": "Poproś o potwierdzenie przed wysłaniem wiadomości, która wciąż zawiera symbole zastępcze, takie jak [TIME] lub [Twoje imię], puste pola szablonu albo niedokończony tekst, taki jak TODO lub TBD." + }, "plain_text_mode": { "description_mobile": "Twórz i czytaj wiadomości tylko jako zwykły tekst." }, diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 904d72a1..9dfe4dba 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -292,6 +292,33 @@ "no_sent_folder": "Nenhuma pasta Enviados encontrada", "permission_camera": "É necessária permissão da câmera para tirar uma foto.", "permission_photos": "É necessária permissão da galeria de fotos para anexar imagens.", + "placeholder_warning": { + "title": "Verificar marcadores?", + "message": "A mensagem tem texto de marcador que os destinatários vão ver. Quer editá-la antes de enviar?", + "found": "Marcadores encontrados", + "more": "e mais {count}", + "dont_ask_again": "Não perguntar novamente", + "send_anyway": "Enviar mesmo assim", + "back": "Voltar à edição" + }, + "placeholder_words": { + "before": [ + "seu", + "sua", + "seus", + "suas", + "teu", + "tua", + "inserir", + "insira", + "adicionar", + "adicione", + "digite", + "informe" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Adicionar aos contatos", "recipient_copy": "Copiar endereço", "recipient_move_to": "Mover para {field}", @@ -820,6 +847,10 @@ "ask_mobile": "Perguntar", "block_mobile": "Bloquear" }, + "placeholder_warning": { + "label": "Aviso de marcadores", + "description": "Pedir confirmação antes de enviar uma mensagem que ainda tem marcadores como [TIME] ou [Seu nome], campos de modelo vazios ou texto inacabado como TODO ou TBD." + }, "plain_text_mode": { "description_mobile": "Escrever e ler apenas em texto simples." }, diff --git a/locales/rn/ro.json b/locales/rn/ro.json index 6cab3dc2..7ee57d75 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -292,6 +292,30 @@ "no_sent_folder": "Nu s-a găsit folderul Trimise", "permission_camera": "Este necesară permisiunea pentru cameră pentru a face o fotografie.", "permission_photos": "Este necesară permisiunea pentru galeria foto pentru a atașa imagini.", + "placeholder_warning": { + "title": "Verificați substituenții?", + "message": "Mesajul conține text substituent pe care destinatarii îl vor vedea. Doriți să-l editați înainte de trimitere?", + "found": "Substituenți găsiți", + "more": "și încă {count}", + "dont_ask_again": "Nu mai întreba", + "send_anyway": "Trimite oricum", + "back": "Înapoi la editare" + }, + "placeholder_words": { + "before": [ + "introduceți", + "inserați", + "adăugați", + "completați" + ], + "after": [ + "tău", + "ta", + "dvs.", + "dumneavoastră" + ], + "unfinished": [] + }, "recipient_add_contact": "Adaugă la contacte", "recipient_copy": "Copiați adresa", "recipient_move_to": "Mutați la {field}", @@ -820,6 +844,10 @@ "ask_mobile": "Întrebați", "block_mobile": "Blocați" }, + "placeholder_warning": { + "label": "Avertisment pentru substituenți", + "description": "Cere confirmare înainte de a trimite un mesaj care încă are substituenți precum [TIME] sau [Numele tău], câmpuri de șablon goale sau text neterminat precum TODO sau TBD." + }, "plain_text_mode": { "description_mobile": "Scrieți și citiți numai în text simplu." }, diff --git a/locales/rn/ru.json b/locales/rn/ru.json index abf34836..47716fa5 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -292,6 +292,33 @@ "no_sent_folder": "Папка «Отправленные» не найдена", "permission_camera": "Чтобы сделать фото, нужен доступ к камере.", "permission_photos": "Чтобы прикреплять изображения, нужен доступ к фотогалерее.", + "placeholder_warning": { + "title": "Проверить заполнители?", + "message": "В письме есть текст-заполнитель, который увидят получатели. Отредактировать его перед отправкой?", + "found": "Найденные заполнители", + "more": "и ещё {count}", + "dont_ask_again": "Больше не спрашивать", + "send_anyway": "Отправить всё равно", + "back": "Вернуться к редактированию" + }, + "placeholder_words": { + "before": [ + "ваше", + "ваш", + "ваша", + "ваши", + "твоё", + "твое", + "твой", + "твоя", + "вставьте", + "укажите", + "введите", + "добавьте" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Добавить в контакты", "recipient_copy": "Копировать адрес", "recipient_move_to": "Переместить в «{field}»", @@ -820,6 +847,10 @@ "ask_mobile": "Спрашивать", "block_mobile": "Блокировать" }, + "placeholder_warning": { + "label": "Предупреждение о заполнителях", + "description": "Запрашивать подтверждение перед отправкой письма, в котором остались заполнители вроде [TIME] или [Ваше имя], пустые поля шаблона или незаконченный текст вроде TODO или TBD." + }, "plain_text_mode": { "description_mobile": "Писать и читать письма только в виде простого текста." }, diff --git a/locales/rn/sk.json b/locales/rn/sk.json index f360b4a8..8ceabca5 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -292,6 +292,29 @@ "no_sent_folder": "Priečinok Odoslané sa nenašiel", "permission_camera": "Na odfotenie je potrebné povolenie pre fotoaparát.", "permission_photos": "Na priloženie obrázkov je potrebné povolenie pre knižnicu fotiek.", + "placeholder_warning": { + "title": "Skontrolovať zástupné texty?", + "message": "Vaša správa obsahuje zástupný text, ktorý uvidia príjemcovia. Chcete ju pred odoslaním upraviť?", + "found": "Nájdené zástupné texty", + "more": "a ďalšie ({count})", + "dont_ask_again": "Znova sa nepýtať", + "send_anyway": "Odoslať aj tak", + "back": "Späť na úpravu" + }, + "placeholder_words": { + "before": [ + "vaše", + "váš", + "vaša", + "tvoje", + "vložte", + "doplňte", + "zadajte", + "uveďte" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Pridať do kontaktov", "recipient_copy": "Kopírovať adresu", "recipient_move_to": "Presunúť do poľa {field}", @@ -820,6 +843,10 @@ "ask_mobile": "Pýtať sa", "block_mobile": "Blokovať" }, + "placeholder_warning": { + "label": "Upozornenie na zástupné texty", + "description": "Pred odoslaním správy, ktorá ešte obsahuje zástupné texty ako [TIME] alebo [Vaše meno], prázdne polia šablóny alebo nedokončený text ako TODO či TBD, požiadať o potvrdenie." + }, "plain_text_mode": { "description_mobile": "Písať a čítať iba v obyčajnom texte." }, diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 58ea6bcf..680a1923 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -292,6 +292,31 @@ "no_sent_folder": "Gönderilenler klasörü bulunamadı", "permission_camera": "Fotoğraf çekmek için kamera izni gerekiyor.", "permission_photos": "Görsel eklemek için fotoğraf arşivi izni gerekiyor.", + "placeholder_warning": { + "title": "Yer tutucular kontrol edilsin mi?", + "message": "İletinizde alıcıların göreceği yer tutucu metin var. Göndermeden önce düzenlemek ister misiniz?", + "found": "Bulunan yer tutucular", + "more": "ve {count} tane daha", + "dont_ask_again": "Bir daha sorma", + "send_anyway": "Yine de gönder", + "back": "Düzenlemeye dön" + }, + "placeholder_words": { + "before": [ + "şirketinizin", + "buraya" + ], + "after": [ + "ekleyin", + "girin", + "yazın", + "-ınız", + "-iniz", + "-unuz", + "-ünüz" + ], + "unfinished": [] + }, "recipient_add_contact": "Kişilere ekle", "recipient_copy": "Adresi kopyala", "recipient_move_to": "{field} alanına taşı", @@ -820,6 +845,10 @@ "ask_mobile": "Sor", "block_mobile": "Engelle" }, + "placeholder_warning": { + "label": "Yer tutucu uyarısı", + "description": "Hâlâ [TIME] veya [Adınız] gibi yer tutucular, boş şablon alanları ya da TODO veya TBD gibi bitmemiş metin içeren bir ileti gönderilmeden önce onay iste." + }, "plain_text_mode": { "description_mobile": "Yalnızca düz metin olarak yazın ve okuyun." }, diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 11062614..6b9479fd 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -292,6 +292,32 @@ "no_sent_folder": "Папку «Надіслані» не знайдено", "permission_camera": "Щоб зробити фото, потрібен доступ до камери.", "permission_photos": "Щоб вкладати зображення, потрібен доступ до фотогалереї.", + "placeholder_warning": { + "title": "Перевірити заповнювачі?", + "message": "У листі є текст-заповнювач, який побачать одержувачі. Відредагувати його перед надсиланням?", + "found": "Знайдені заповнювачі", + "more": "і ще {count}", + "dont_ask_again": "Більше не запитувати", + "send_anyway": "Все одно надішліть", + "back": "Назад до редагування" + }, + "placeholder_words": { + "before": [ + "ваше", + "ваш", + "ваша", + "ваші", + "твоє", + "твій", + "твоя", + "вставте", + "вкажіть", + "введіть", + "додайте" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "Додати до контактів", "recipient_copy": "Копіювати адресу", "recipient_move_to": "Перемістити в «{field}»", @@ -820,6 +846,10 @@ "ask_mobile": "Питати", "block_mobile": "Блокувати" }, + "placeholder_warning": { + "label": "Попередження про заповнювачі", + "description": "Запитувати підтвердження перед надсиланням листа, у якому лишилися заповнювачі на кшталт [TIME] або [Ваше ім’я], порожні поля шаблону чи незавершений текст на кшталт TODO або TBD." + }, "plain_text_mode": { "description_mobile": "Писати й читати листи лише як простий текст." }, diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index f6d92625..5b4f4db2 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -292,6 +292,29 @@ "no_sent_folder": "找不到寄件備份信件匣", "permission_camera": "需要相機權限才能拍照。", "permission_photos": "需要相片圖庫權限才能附加圖片。", + "placeholder_warning": { + "title": "檢查預留位置?", + "message": "你的郵件中有收件者會看到的預留位置文字。要在傳送前編輯嗎?", + "found": "找到的預留位置", + "more": "以及另外 {count} 個", + "dont_ask_again": "不再提示", + "send_anyway": "仍然傳送", + "back": "繼續編輯" + }, + "placeholder_words": { + "before": [ + "您的-", + "你的-", + "貴公司-", + "插入-", + "輸入-", + "填寫-", + "新增-", + "在此-" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "新增至聯絡人", "recipient_copy": "複製地址", "recipient_move_to": "移至{field}", @@ -820,6 +843,10 @@ "ask_mobile": "詢問", "block_mobile": "封鎖" }, + "placeholder_warning": { + "label": "預留位置提醒", + "description": "傳送仍含有預留位置(如 [TIME] 或 [您的姓名])、空白範本欄位或未完成文字(如 TODO 或 TBD)的郵件前請求確認。" + }, "plain_text_mode": { "description_mobile": "僅以純文字撰寫與閱讀。" }, diff --git a/locales/rn/zh.json b/locales/rn/zh.json index 57166cb1..586df249 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -292,6 +292,29 @@ "no_sent_folder": "未找到“已发送”文件夹", "permission_camera": "需要相机权限才能拍照。", "permission_photos": "需要相册权限才能附加图片。", + "placeholder_warning": { + "title": "检查占位符?", + "message": "你的邮件中有收件人会看到的占位符文本。要在发送前编辑吗?", + "found": "找到的占位符", + "more": "以及另外 {count} 个", + "dont_ask_again": "不再提示", + "send_anyway": "仍然发送", + "back": "返回编辑" + }, + "placeholder_words": { + "before": [ + "您的-", + "你的-", + "贵公司-", + "插入-", + "输入-", + "填写-", + "添加-", + "在此-" + ], + "after": [], + "unfinished": [] + }, "recipient_add_contact": "添加到联系人", "recipient_copy": "复制地址", "recipient_move_to": "移至{field}", @@ -820,6 +843,10 @@ "ask_mobile": "询问", "block_mobile": "阻止" }, + "placeholder_warning": { + "label": "占位符提醒", + "description": "发送仍含有占位符(如 [TIME] 或 [您的姓名])、空白模板字段或未完成文本(如 TODO 或 TBD)的邮件前请求确认。" + }, "plain_text_mode": { "description_mobile": "仅以纯文本撰写和阅读。" }, diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index a1581101..ca93aca6 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -26,6 +26,7 @@ export function ComposingSettings() { const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled); const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault); const emptySubjectWarningEnabled = useSettingsStore((s) => s.emptySubjectWarningEnabled); + const placeholderWarningEnabled = useSettingsStore((s) => s.placeholderWarningEnabled); const autoSaveDraftInterval = useSettingsStore((s) => s.autoSaveDraftInterval); const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter); const updateSetting = useSettingsStore((s) => s.updateSetting); @@ -141,6 +142,13 @@ export function ComposingSettings() { updateSetting('emptySubjectWarningEnabled', v)} /> + + updateSetting('placeholderWarningEnabled', v)} /> + + {t('settings.email_behavior.signature_position.label', "Signature Position")} diff --git a/src/lib/__tests__/placeholder-check.test.ts b/src/lib/__tests__/placeholder-check.test.ts new file mode 100644 index 00000000..32ff0515 --- /dev/null +++ b/src/lib/__tests__/placeholder-check.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { SUPPORTED_LOCALES } from '../../i18n'; +import { + findUnfilledPlaceholders, placeholderWordsFor, unfilledPlaceholdersInMessage, type PlaceholderWords, +} from '../placeholder-check'; +import { SIGNATURE_RANGE_MARKER } from '../signature-utils'; + +// Ported from the webmail's lib/__tests__/placeholder-check.test.ts. The words +// come from the RN overlays (locales/rn/.json) until the vendored +// catalogs carry them. +const locales = SUPPORTED_LOCALES.map((l) => l.code as string); + +function wordsOf(locale: string): PlaceholderWords { + return placeholderWordsFor(locale).at(-1)!; +} + +// What the composer passes: English and the UI language. +const withUi = (locale: string) => placeholderWordsFor(locale); +const EN = withUi('en'); +const ALL = locales.map(wordsOf); + +describe('findUnfilledPlaceholders', () => { + it('finds template placeholders in the subject and the body', () => { + expect(findUnfilledPlaceholders('Follow-up: {{subject}}', 'Hi {{ recipient_name }},')).toEqual([ + '{{subject}}', + '{{ recipient_name }}', + ]); + }); + + it('finds upper-case fill-ins in brackets', () => { + expect(findUnfilledPlaceholders('', 'See you at [TIME]. It costs [PRICE] per [UNIT_NAME].')) + .toEqual(['[TIME]', '[PRICE]', '[UNIT_NAME]']); + expect(findUnfilledPlaceholders('', 'Best regards, [YOUR NAME]')).toEqual(['[YOUR NAME]']); + expect(findUnfilledPlaceholders('', 'Treffpunkt: [ÜBERGABEORT]')).toEqual(['[ÜBERGABEORT]']); + }); + + it('finds fill-ins that ask the sender to write something', () => { + expect(findUnfilledPlaceholders('', 'Best regards,\n[Your Name]\n[Insert date]', EN)).toEqual([ + '[Your Name]', + '[Insert date]', + ]); + expect(findUnfilledPlaceholders('', 'Viele Grüße\n[Ihr Name], [Datum einfügen]', withUi('de'))).toEqual([ + '[Ihr Name]', + '[Datum einfügen]', + ]); + }); + + it.each([ + ['ar', '[أدخل الاسم]'], + ['ca', '[El teu nom]'], + ['cs', '[Vaše jméno]'], + ['da', '[Dit navn]'], + ['de', '[Unterschrift eintragen]'], + ['es', '[Tu nombre]'], + ['es', '[Inserte la fecha]'], + ['fa', '[نام شما]'], + ['fr', '[Votre nom]'], + ['fr', '[Insérer la date]'], + ['he', '[השם שלך]'], + ['hu', '[Az Ön neve]'], + ['it', '[Il tuo nome]'], + ['it', '[Inserisci la data]'], + ['ja', '[あなたの名前]'], + ['ja', '[会社名を入力]'], + ['ko', '[귀하의 이름]'], + ['ko', '[날짜 입력]'], + ['lv', '[Jūsu vārds]'], + ['mn', '[Таны нэр]'], + ['nb', '[Ditt navn]'], + ['nl', '[Uw naam]'], + ['nl', '[Datum invullen]'], + ['pl', '[Twoje imię]'], + ['pt', '[Seu nome]'], + ['ro', '[Numele tău]'], + ['ru', '[Ваше имя]'], + ['ru', '[Укажите дату]'], + ['sk', '[Vaše meno]'], + ['tr', '[Adınız]'], + ['tr', '[Tarih ekleyin]'], + ['uk', '[Ваше ім’я]'], + ['zh', '[您的姓名]'], + ['zh', '【插入日期】'], + ['zh-TW', '[填寫公司名稱]'], + ])('finds a %s fill-in with that UI language: %s', (locale, fillIn) => { + expect(findUnfilledPlaceholders('', `Text ${fillIn} text`, withUi(locale))).toEqual([fillIn]); + }); + + it('checks only the languages it is given', () => { + expect(findUnfilledPlaceholders('', 'Cordialement, [Votre nom]', withUi('de'))).toEqual([]); + expect(findUnfilledPlaceholders('', 'Cordialement, [Votre nom]', withUi('fr'))).toEqual(['[Votre nom]']); + }); + + it('finds English fill-ins whatever the UI language', () => { + expect(findUnfilledPlaceholders('', 'Thanks, [Your Name]. TODO', withUi('ja'))).toEqual(['[Your Name]', 'TODO']); + }); + + it('skips word lists that are not lists of strings', () => { + const broken = [{ before: 'your' }, { before: [1, '', '-', null] }, null, { after: {} }] as unknown as PlaceholderWords[]; + expect(findUnfilledPlaceholders('', '[Your Name] [TIME]', broken)).toEqual(['[TIME]']); + }); + + it('finds the circles that stand in for a name in Japanese and Korean', () => { + expect(findUnfilledPlaceholders('', '〇〇様\nお世話になっております。○○○ 님')).toEqual(['〇〇', '○○○']); + }); + + it('leaves bracketed words and headings in other languages alone', () => { + const text = [ + '【重要】お知らせ 【通知】', + '[Votre] [Ваше] [您的]', + 'Le [ton] juste. Vote [tu].', + 'Siehe [Anlage 2] und [Kapitel 3].', + 'Termin: ○ und ◯.', + ].join('\n'); + expect(findUnfilledPlaceholders('', text, ALL)).toEqual([]); + }); + + it('finds unfinished-text markers', () => { + expect(findUnfilledPlaceholders('', 'The venue is TBD. TODO: add the agenda. Room XXX', EN)).toEqual([ + 'TBD', + 'TODO', + 'XXX', + ]); + }); + + it('reports a marker in brackets once', () => { + expect(findUnfilledPlaceholders('', 'Agenda: [TODO]', EN)).toEqual(['[TODO]']); + }); + + it('lists each placeholder once', () => { + expect(findUnfilledPlaceholders('{{name}}', 'Hi {{name}}, at [TIME] or [TIME]?')).toEqual([ + '{{name}}', + '[TIME]', + ]); + }); + + it('leaves ordinary text alone', () => { + const text = [ + 'See footnote [1] and tick [x].', + 'Option [A] is fine, so is [3D] and [2026].', + 'I still have a todo list and the date is tba.', + 'Read [the docs](https://example.com) and [Your account](https://example.com/me).', + 'The mastodon handle is TODOS and the size is XXXL.', + 'Signed, [Yours truly].', + 'A {single} brace and {{}} are not placeholders.', + ].join('\n'); + expect(findUnfilledPlaceholders('', text, ALL)).toEqual([]); + }); + + it('ignores bracket tags and markers in the subject', () => { + expect(findUnfilledPlaceholders('Re: [EXTERNAL] Venue TBD', 'Thanks!', ALL)).toEqual([]); + }); +}); + +describe('placeholder words in the locale files', () => { + it.each(locales)('%s has lists of strings', (locale) => { + const words = wordsOf(locale) as Record; + for (const key of ['before', 'after', 'unfinished']) { + expect(Array.isArray(words[key]), `${locale}: ${key} is not a list`).toBe(true); + for (const word of words[key] as unknown[]) { + expect(typeof word === 'string' && word.replace(/-/g, '').trim() !== '', `${locale}: ${JSON.stringify(word)}`).toBe(true); + } + } + }); +}); + +describe('unfilledPlaceholdersInMessage', () => { + const signature = { name: 'Me', email: 'me@example.com', textSignature: 'Me\n[YOUR TITLE]', htmlSignature: '' }; + + it('leaves the embedded signature and the quoted original out', () => { + const body = '

See you at [TIME].

' + + `

--

Me, [YOUR TITLE]

` + + '

Please confirm [DATE]

'; + expect(unfilledPlaceholdersInMessage({ + subject: 'Re: hello', body, plainTextMode: false, signature, words: EN, + })).toEqual(['[TIME]']); + }); + + it('leaves the plain text signature and quoted lines out', () => { + const body = 'See you at [TIME].\n\n-- \nMe\n[YOUR TITLE]\n\n> Please confirm [DATE]'; + expect(unfilledPlaceholdersInMessage({ + subject: '{{subject}}', body, plainTextMode: true, signature, words: EN, + })).toEqual(['{{subject}}', '[TIME]']); + }); +}); diff --git a/src/lib/placeholder-check.ts b/src/lib/placeholder-check.ts new file mode 100644 index 00000000..33e19c67 --- /dev/null +++ b/src/lib/placeholder-check.ts @@ -0,0 +1,171 @@ +// Finds text the sender meant to replace before sending: template +// placeholders, bracketed fill-ins that AI drafts and copied templates leave +// behind, and markers like TODO. Port of the webmail's lib/placeholder-check.ts. + +import { getDictionary, isSupportedLocale } from '../i18n'; +import { extractUserAuthoredText } from './compose-html'; +import { getPlainTextSignature, stripEmbeddedSignature, type SignatureSource } from './signature-utils'; + +// Template syntax, as in template-utils. Spaces inside the braces are allowed +// because templates pasted from other tools often have them. +const TEMPLATE_PLACEHOLDER = /\{\{\s*[\w.-]+\s*\}\}/g; + +// [TIME], [PRICE], [YOUR NAME]. Not followed by "(", which would make it a +// Markdown link. +const CAPS_BRACKET = /\[[\p{Lu}\d][\p{Lu}\d _./-]{0,28}[\p{Lu}\d]\](?!\()/gu; + +/** + * The words that mark a fill-in in one language. They live in each locale + * file under email_composer.placeholder_words, so a new translation brings + * its own. + * + * - `before` opens a fill-in: "your" in [Your name], "insert" in [Insert date]. + * - `after` closes one, for languages that put the verb or "your" last: + * "einfügen" in [Datum einfügen]. + * - A "-" on the joining side means the word is written onto the next one + * without a space: "您的-" for [您的姓名], "-ınız" for [Adınız]. + * - `unfinished` are words in capitals that mark unfinished text, like TODO. + * They match as written, so "todo" in a sentence does not count. + */ +export interface PlaceholderWords { + before?: unknown; + after?: unknown; + unfinished?: unknown; +} + +const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const alternation = (words: string[]) => words.map(escapeRegExp).join('|'); + +// The lists come from locale files that contributors edit, so anything that +// is not a list of strings is skipped. +function wordsOf(lists: PlaceholderWords[], key: keyof PlaceholderWords): string[] { + const words = new Set(); + for (const list of lists) { + const value = list?.[key]; + if (!Array.isArray(value)) continue; + for (const word of value) { + if (typeof word === 'string' && word.replace(/-/g, '').trim()) words.add(word.trim()); + } + } + return Array.from(words); +} + +// Plain brackets, and the full-width and lenticular ones of CJK text. +const OPEN = '[\\[[【]'; +const CLOSE = '[\\]]】]'; +const INNER = '[^\\[\\][]【】\\n]'; + +function promptBracketPattern(lists: PlaceholderWords[]): RegExp | null { + const before = wordsOf(lists, 'before'); + const after = wordsOf(lists, 'after'); + const branches: string[] = []; + const add = (words: string[], branch: (alt: string) => string) => { + if (words.length > 0) branches.push(branch(alternation(words))); + }; + add(before.filter((w) => !w.endsWith('-')), (alt) => `(?:${alt})\\s${INNER}{1,40}`); + add(before.filter((w) => w.endsWith('-')).map((w) => w.slice(0, -1)), (alt) => `(?:${alt})${INNER}{1,40}`); + add(after.filter((w) => !w.startsWith('-')), (alt) => `${INNER}{1,40}\\s(?:${alt})`); + add(after.filter((w) => w.startsWith('-')).map((w) => w.slice(1)), (alt) => `${INNER}{1,40}(?:${alt})`); + if (branches.length === 0) return null; + return new RegExp(`${OPEN}(?:${branches.join('|')})${CLOSE}(?!\\()`, 'giu'); +} + +function unfinishedWordPattern(lists: PlaceholderWords[]): RegExp | null { + const words = wordsOf(lists, 'unfinished'); + if (words.length === 0) return null; + return new RegExp(`(? a.start - b.start || b.end - a.end); + const kept: Match[] = []; + for (const match of matches) { + const last = kept[kept.length - 1]; + if (last && match.start < last.end) continue; + kept.push(match); + } + return kept; +} + +/** + * The unfilled placeholders in a message, each once, subject first. The + * subject is only checked for template placeholders: it often carries tags + * like [EXTERNAL] or words from the original message, which the sender did + * not write. `bodyText` should be the text the sender wrote, without quoted + * replies and the signature. `words` are the fill-in words of the languages + * to check (see PlaceholderWords). + */ +export function findUnfilledPlaceholders(subject: string, bodyText: string, words: PlaceholderWords[] = []): string[] { + const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, CIRCLE_MARKER]; + const prompt = promptBracketPattern(words); + const unfinished = unfinishedWordPattern(words); + if (prompt) bodyPatterns.push(prompt); + if (unfinished) bodyPatterns.push(unfinished); + const found = [ + ...collect(subject, [TEMPLATE_PLACEHOLDER]), + ...collect(bodyText, bodyPatterns), + ].map((m) => m.text); + return Array.from(new Set(found)); +} + +function placeholderWordsOf(dictionary: Record): PlaceholderWords { + const composer = dictionary.email_composer as Record | undefined; + const words = composer?.placeholder_words; + return words && typeof words === 'object' ? (words as PlaceholderWords) : {}; +} + +/** + * The fill-in words to check: the UI language's and English, since AI drafts + * and templates are often English whatever the UI language is. + */ +export function placeholderWordsFor(locale: string): PlaceholderWords[] { + const lists = [placeholderWordsOf(getDictionary('en'))]; + if (locale !== 'en' && isSupportedLocale(locale)) lists.push(placeholderWordsOf(getDictionary(locale))); + return lists; +} + +/** + * The unfilled placeholders of a message as the composer holds it: the body + * without its signature (a placeholder there would stop every message) and + * without the quoted original. + */ +export function unfilledPlaceholdersInMessage(message: { + subject: string; + body: string; + plainTextMode: boolean; + signature?: SignatureSource | null; + forwardedSeparator?: string; + words: PlaceholderWords[]; +}): string[] { + let authored: string; + if (message.plainTextMode) { + const signature = getPlainTextSignature(message.signature ?? undefined); + authored = signature ? message.body.split(signature).join('') : message.body; + } else { + authored = stripEmbeddedSignature(message.body); + } + const bodyText = extractUserAuthoredText(authored, { + plainTextMode: message.plainTextMode, + forwardedSeparator: message.forwardedSeparator, + }); + return findUnfilledPlaceholders(message.subject, bodyText, message.words); +} diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index fba469f1..47901d50 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -99,6 +99,7 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.email_behavior.plain_text_mode', 'settings.email_behavior.request_read_receipt', 'settings.email_behavior.empty_subject_warning', + 'settings.email_behavior.placeholder_warning', 'settings.email_behavior.signature_separator', 'settings.email_behavior.signature_position', 'settings.email_behavior.send_delay', diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 572d29bb..764f6d24 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -66,6 +66,7 @@ import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; +import { placeholderWordsFor, unfilledPlaceholdersInMessage } from '../lib/placeholder-check'; import { ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin, } from '../lib/compose-login'; @@ -482,6 +483,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled); const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault); const emptySubjectWarningEnabled = useSettingsStore((s) => s.emptySubjectWarningEnabled); + const placeholderWarningEnabled = useSettingsStore((s) => s.placeholderWarningEnabled); const autoSaveDraftInterval = useSettingsStore((s) => s.autoSaveDraftInterval); const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter); const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds); @@ -2049,6 +2051,54 @@ export default function ComposeScreen({ route, navigation }: Props) { }); }; + // Unfilled placeholders: template fields left as {{name}}, fill-ins like + // [TIME] from AI drafts, and markers like TODO (webmail + // lib/placeholder-check). The signature is left out, or a placeholder in + // it would stop every message. Asked once per placeholder: after "Send + // anyway" and a trip back to add an attachment, the same ones pass. + const confirmedPlaceholdersRef = React.useRef(new Set()); + const passesPlaceholderCheck = (body: string): Promise => { + if (!placeholderWarningEnabled) return Promise.resolve(true); + const found = unfilledPlaceholdersInMessage({ + subject, + body, + plainTextMode, + signature: signatureIdentity, + forwardedSeparator: quoteLabels.forwardedSeparator, + words: placeholderWordsFor(locale), + }); + if (!found.some((p) => !confirmedPlaceholdersRef.current.has(p))) return Promise.resolve(true); + const SHOWN = 5; + const more = found.length > SHOWN + ? ` ${t('email_composer.placeholder_warning.more', 'and {count} more', { count: found.length - SHOWN })}` + : ''; + const message = t( + 'email_composer.placeholder_warning.message', + 'Your message has placeholder text that recipients will see. Do you want to edit it before sending?', + ); + const label = t('email_composer.placeholder_warning.found', 'Placeholders found'); + return new Promise((resolve) => { + Alert.alert( + t('email_composer.placeholder_warning.title', 'Check placeholders?'), + `${message}\n\n${label}: ${found.slice(0, SHOWN).join(', ')}${more}`, + [ + { text: t('email_composer.placeholder_warning.back', 'Back to editing'), style: 'cancel', onPress: () => resolve(false) }, + { + text: t('email_composer.placeholder_warning.dont_ask_again', "Don't ask again"), + onPress: () => { updateSetting('placeholderWarningEnabled', false); resolve(true); }, + }, + { + text: t('email_composer.placeholder_warning.send_anyway', 'Send anyway'), + onPress: () => { + for (const p of found) confirmedPlaceholdersRef.current.add(p); + resolve(true); + }, + }, + ], + ); + }); + }; + // Translate an absolute "send at" time into the HOLDFOR seconds the server // expects, clamping to what it actually supports. Returns null (and alerts) // when scheduling isn't possible so the caller can abort. @@ -2198,6 +2248,7 @@ export default function ComposeScreen({ route, navigation }: Props) { } if (!(await passesEmptySubjectCheck())) return; + if (!(await passesPlaceholderCheck(plainTextMode ? plainBody : liveBodyHtml))) return; if (!(await passesAttachmentReminder(plainTextMode ? plainBody : liveBodyHtml))) return; // Autosave must not race the send: cancel a scheduled save and let an diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 873b4cc5..efc755b8 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -173,6 +173,8 @@ interface PersistedSettings { requestReadReceiptDefault: boolean; // Confirm before sending a message without a subject (#684). emptySubjectWarningEnabled: boolean; + // Confirm before sending a message with unfilled placeholders like [TIME] or TODO. + placeholderWarningEnabled: boolean; // Draft autosave debounce, milliseconds. autoSaveDraftInterval: number; // Character separating user from tag (e.g. "user+tag@"), RFC 5233. @@ -362,6 +364,7 @@ const DEFAULT_PERSISTED: PersistedSettings = { signatureSeparatorEnabled: true, requestReadReceiptDefault: false, emptySubjectWarningEnabled: true, + placeholderWarningEnabled: true, autoSaveDraftInterval: 60000, subAddressDelimiter: '+', preferredIdentityIds: {},