feat: warn before sending a message with unfilled placeholders

This commit is contained in:
Linus Rath
2026-10-29 03:33:49 +01:00
parent 14d009de32
commit a8e7a3f49d
33 changed files with 1164 additions and 0 deletions
+23
View File
@@ -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": "الكتابة والقراءة بنص عادي فقط."
},
+28
View File
@@ -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."
},
+28
View File
@@ -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."
},
+25
View File
@@ -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."
},
+29
View File
@@ -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."
},
+30
View File
@@ -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."
},
+34
View File
@@ -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."
},
+22
View File
@@ -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": "نوشتن و خواندن فقط به‌صورت متن ساده."
},
+32
View File
@@ -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."
},
+26
View File
@@ -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": "כתיבה וקריאה בטקסט רגיל בלבד."
},
+24
View File
@@ -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."
},
+30
View File
@@ -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."
},
+28
View File
@@ -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": "プレーンテキストのみで作成・閲覧します。"
},
+26
View File
@@ -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": "일반 텍스트로만 작성하고 읽어요."
},
+27
View File
@@ -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ā."
},
+23
View File
@@ -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": "Зөвхөн энгийн текстээр бичих, унших."
},
+25
View File
@@ -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."
},
+28
View File
@@ -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."
},
+28
View File
@@ -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."
},
+31
View File
@@ -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."
},
+28
View File
@@ -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."
},
+31
View File
@@ -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": "Писать и читать письма только в виде простого текста."
},
+27
View File
@@ -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."
},
+29
View File
@@ -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."
},
+30
View File
@@ -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": "Писати й читати листи лише як простий текст."
},
+27
View File
@@ -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": "僅以純文字撰寫與閱讀。"
},
+27
View File
@@ -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": "仅以纯文本撰写和阅读。"
},
@@ -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() {
<ToggleSwitch checked={emptySubjectWarningEnabled} onChange={(v) => updateSetting('emptySubjectWarningEnabled', v)} />
</SettingItem>
<SettingItem
label={t('settings.email_behavior.placeholder_warning.label', 'Placeholder warning')}
description={t('settings.email_behavior.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.')}
>
<ToggleSwitch checked={placeholderWarningEnabled} onChange={(v) => updateSetting('placeholderWarningEnabled', v)} />
</SettingItem>
<View style={styles.subBlock}>
<Text style={styles.subLabel}>{t('settings.email_behavior.signature_position.label', "Signature Position")}</Text>
<Text style={styles.subDescription}>
+184
View File
@@ -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/<lang>.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<string, unknown>;
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 = '<p>See you at [TIME].</p>'
+ `<p ${SIGNATURE_RANGE_MARKER}="separator">-- </p><p>Me, [YOUR TITLE]</p><p ${SIGNATURE_RANGE_MARKER}="end"></p>`
+ '<blockquote><p>Please confirm [DATE]</p></blockquote>';
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]']);
});
});
+171
View File
@@ -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<string>();
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(`(?<![\\p{L}\\d_])(?:${alternation(words)})(?![\\p{L}\\d_])`, 'gu');
}
// Japanese and Korean stand-ins for a name or number: 〇〇様, ○○○ 님. Signs
// rather than words, so they are not in the locale files.
const CIRCLE_MARKER = /[〇○◯]{2,}/gu;
interface Match {
start: number;
end: number;
text: string;
}
function collect(text: string, patterns: RegExp[]): Match[] {
const matches: Match[] = [];
for (const pattern of patterns) {
for (const m of text.matchAll(pattern)) {
if (pattern === CAPS_BRACKET && (m[0].match(/\p{Lu}/gu)?.length ?? 0) < 2) continue;
const start = m.index ?? 0;
matches.push({ start, end: start + m[0].length, text: m[0] });
}
}
// "[TODO]" is a bracket and a word; keep the outer match only.
matches.sort((a, b) => 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<string, unknown>): PlaceholderWords {
const composer = dictionary.email_composer as Record<string, unknown> | 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);
}
+1
View File
@@ -99,6 +99,7 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'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',
+51
View File
@@ -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<string>());
const passesPlaceholderCheck = (body: string): Promise<boolean> => {
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<boolean>((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
+3
View File
@@ -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: {},