diff --git a/build.py b/build.py index 9220e34bb..d1e14a127 100644 --- a/build.py +++ b/build.py @@ -358,6 +358,7 @@ def _prepare_ffmpeg(): "src/util/callMediaBridge.ts", "src/util/forwardRuntime.ts", "src/util/statusReactionRuntime.ts", + "src/util/quotedMessagePreflight.ts", "src/util/contactPresenceRuntime.ts", "src/util/listChatsDiag.ts", "src/util/createSessionUtil.ts", diff --git a/client/api_patches/src/controller/messageController.ts b/client/api_patches/src/controller/messageController.ts index 0ad169c86..da1a36029 100644 --- a/client/api_patches/src/controller/messageController.ts +++ b/client/api_patches/src/controller/messageController.ts @@ -18,6 +18,10 @@ import { Request, Response } from 'express'; import { unlinkAsync } from '../util/functions'; import { writePinnedMessage } from '../util/pinnedMessagesRuntime'; +import { + missingReplyQuoteResponse, + replyQuoteIsMissing, +} from '../util/quotedMessagePreflight'; function returnError(req: Request, res: Response, error: any) { req.logger.error(error); @@ -1546,6 +1550,14 @@ export async function replyMessage(req: Request, res: Response) { const { phone, message, messageId } = req.body; try { + if ( + typeof messageId === 'string' && + messageId && + !messageId.includes('status@broadcast') && + (await replyQuoteIsMissing(req.client, messageId)) + ) { + return missingReplyQuoteResponse(res); + } const results: any = []; for (const contato of phone) { if ( @@ -1653,6 +1665,14 @@ export async function sendMentioned(req: Request, res: Response) { const { phone, message, mentioned, messageId } = req.body; try { + if ( + typeof messageId === 'string' && + messageId && + !messageId.includes('status@broadcast') && + (await replyQuoteIsMissing(req.client, messageId)) + ) { + return missingReplyQuoteResponse(res); + } let response; for (const contato of phone) { response = auditSendResult( diff --git a/client/api_patches/src/util/quotedMessagePreflight.ts b/client/api_patches/src/util/quotedMessagePreflight.ts new file mode 100644 index 000000000..5d2c730ee --- /dev/null +++ b/client/api_patches/src/util/quotedMessagePreflight.ts @@ -0,0 +1,33 @@ +// Resolve the quote before ANY recipient is sent to. Keep the verdict in the +// page: Puppeteer does not reliably preserve a WPPError's code across a throw. +export const QUOTED_MESSAGE_PREFLIGHT = String.raw` +async function(messageId) { + try { + const message = await window.WPP.chat.getMessageById(messageId); + if (!message) throw new Error('quote lookup returned no verdict'); + return false; + } catch (error) { + if (error && error.code === 'msg_not_found') return true; + throw error; + } +}`; + +export async function replyQuoteIsMissing(client, messageId) { + return ( + (await client.page.evaluate( + `(${QUOTED_MESSAGE_PREFLIGHT})(${JSON.stringify(messageId)})` + )) === true + ); +} + +export function missingReplyQuoteResponse(res) { + // Deliberately 500: older WinZapp clients strip quotes and resend on 4xx. + // Only clients understanding this complete pre-send verdict may call it a + // definite refusal. Never add this marker to a catch around a SEND. + return res.status(500).json({ + status: 'error', + code: 'quoted_message_not_found', + stage: 'quote_resolution', + sendAttempted: false, + }); +} diff --git a/client/changelog_en-US.txt b/client/changelog_en-US.txt index ea1cb3017..43eaec164 100644 --- a/client/changelog_en-US.txt +++ b/client/changelog_en-US.txt @@ -88,6 +88,8 @@ FIXES - Updates: "Check for updates" in the Help menu now tells you when WinZapp could not reach the update server, instead of saying nothing. +- Replies: when the original message cannot be found before sending, WinZapp reports the missing quote and keeps your text for editing from the message menu. It does not silently send without the quote or report a connection loss. Uncertain sends are still not retried automatically. + - Synchronization: replayed history messages keep their chronological position instead of appearing as new arrivals. Temporary failures when checking an alternate chat address remain queued for retry. - Forwarding no longer opens a box when it worked. "Message forwarded." (or how many messages were forwarded) is now said by the screen reader, with no window to close and without cutting off what is being read; it works with WinZapp in the tray too. diff --git a/client/changelog_es-ES.txt b/client/changelog_es-ES.txt index e13acba31..f3a8c75cd 100644 --- a/client/changelog_es-ES.txt +++ b/client/changelog_es-ES.txt @@ -88,6 +88,8 @@ CORRECCIONES - Actualizaciones: "Comprobar actualizaciones", en el menú Ayuda, ahora avisa cuando WinZapp no puede conectar con el servidor de actualizaciones, en lugar de no decir nada. +- Respuestas: si no se encuentra el mensaje original antes de enviar, WinZapp informa de la cita ausente y conserva el texto para editarlo desde el menú del mensaje. No lo envía silenciosamente sin la cita ni anuncia una pérdida de conexión. Los envíos inciertos siguen sin reintentarse automáticamente. + - Sincronización: los mensajes antiguos recibidos de nuevo mantienen su orden cronológico en lugar de aparecer como nuevos. Los fallos temporales al consultar una dirección alternativa del chat quedan pendientes para volver a intentarlo. - Reenviar ya no abre un cuadro cuando sale bien. "Mensaje reenviado." (o cuántos mensajes se han reenviado) ahora lo dice el lector de pantalla, sin ventana que cerrar y sin interrumpir lo que se está leyendo; también funciona con WinZapp en la bandeja. diff --git a/client/changelog_pl.txt b/client/changelog_pl.txt index e8c9c8d1a..bb004b8ca 100644 --- a/client/changelog_pl.txt +++ b/client/changelog_pl.txt @@ -88,6 +88,8 @@ POPRAWKI - Aktualizacje: „Sprawdź aktualizacje” w menu Pomoc informuje teraz, gdy WinZapp nie może połączyć się z serwerem aktualizacji, zamiast nic nie mówić. +- Odpowiedzi: jeśli przed wysłaniem nie można znaleźć oryginalnej wiadomości, WinZapp informuje o brakującym cytacie i zachowuje tekst do edycji z menu wiadomości. Nie wysyła go po cichu bez cytatu ani nie zgłasza utraty połączenia. Wysyłki o niepewnym wyniku nadal nie są automatycznie ponawiane. + - Synchronizacja: ponownie otrzymane starsze wiadomości zachowują kolejność chronologiczną, zamiast pojawiać się jako nowe. Tymczasowe błędy sprawdzania alternatywnego adresu czatu pozostają w kolejce do ponowienia. - Przekazywanie dalej nie otwiera już okna, gdy się uda. „Wiadomość przekazana.” (albo liczbę przekazanych wiadomości) mówi teraz czytnik ekranu, bez okna do zamknięcia i bez przerywania tego, co jest czytane; działa też, gdy WinZapp jest w zasobniku. diff --git a/client/changelog_pt-BR.txt b/client/changelog_pt-BR.txt index 28cd4c38d..7a15c15e5 100644 --- a/client/changelog_pt-BR.txt +++ b/client/changelog_pt-BR.txt @@ -88,6 +88,8 @@ CORREÇÕES - Atualizações: "Verificar atualizações", no menu Ajuda, agora avisa quando o WinZapp não consegue acessar o servidor de atualizações, em vez de não dizer nada. +- Respostas: quando a mensagem original não é encontrada antes do envio, o WinZapp informa a citação ausente e mantém seu texto para edição pelo menu da mensagem. Não envia silenciosamente sem a citação nem informa perda de conexão. Envios incertos continuam sem repetição automática. + - Sincronização: mensagens antigas recebidas novamente mantêm a ordem cronológica, em vez de aparecerem como novas. Falhas temporárias ao consultar um endereço alternativo da conversa continuam na fila para nova tentativa. - Encaminhar não abre mais uma caixa quando dá certo. "Mensagem encaminhada." (ou quantas mensagens foram encaminhadas) agora é falado pelo leitor de tela, sem janela para fechar e sem interromper o que está sendo lido; funciona também com o WinZapp na bandeja. diff --git a/client/changelog_pt-PT.txt b/client/changelog_pt-PT.txt index 38624ea63..d7f533b04 100644 --- a/client/changelog_pt-PT.txt +++ b/client/changelog_pt-PT.txt @@ -88,6 +88,8 @@ CORREÇÕES - Atualizações: "Verificar atualizações", no menu Ajuda, passa a avisar quando o WinZapp não consegue aceder ao servidor de atualizações, em vez de não dizer nada. +- Respostas: quando a mensagem original não é encontrada antes do envio, o WinZapp informa a citação em falta e mantém o texto para edição no menu da mensagem. Não envia silenciosamente sem a citação nem anuncia perda de ligação. Os envios incertos continuam sem repetição automática. + - Sincronização: mensagens antigas recebidas novamente mantêm a ordem cronológica, em vez de aparecerem como novas. Falhas temporárias ao consultar um endereço alternativo da conversa continuam na fila para nova tentativa. - Reencaminhar deixa de abrir uma caixa quando corre bem. "Mensagem reencaminhada." (ou quantas mensagens foram reencaminhadas) passa a ser dito pelo leitor de ecrã, sem janela para fechar e sem interromper o que está a ser lido; funciona também com o WinZapp na área de notificação. diff --git a/client/changelog_ro.txt b/client/changelog_ro.txt index 3fb6b030d..41c2eb3d4 100644 --- a/client/changelog_ro.txt +++ b/client/changelog_ro.txt @@ -88,6 +88,8 @@ CORECTĂRI - Actualizări: „Verificați actualizările” din meniul Ajutor vă anunță acum când WinZapp nu poate contacta serverul de actualizări, în loc să nu spună nimic. +- Răspunsuri: dacă mesajul original nu este găsit înainte de trimitere, WinZapp anunță citatul lipsă și păstrează textul pentru editare din meniul mesajului. Nu trimite în tăcere fără citat și nu anunță pierderea conexiunii. Trimiterile cu rezultat incert nu sunt reîncercate automat. + - Sincronizare: mesajele vechi primite din nou își păstrează ordinea cronologică, în loc să apară ca mesaje noi. Erorile temporare la verificarea unei adrese alternative a conversației rămân în coadă pentru o nouă încercare. - Redirecționarea nu mai deschide o casetă când a reușit. „Mesajul a fost redirecționat.” (sau câte mesaje au fost redirecționate) este acum rostit de cititorul de ecran, fără o fereastră de închis și fără a întrerupe ce se citește; funcționează și cu WinZapp în bara de sistem. diff --git a/client/changelog_tr-TR.txt b/client/changelog_tr-TR.txt index 1c950f652..b18015b8c 100644 --- a/client/changelog_tr-TR.txt +++ b/client/changelog_tr-TR.txt @@ -88,6 +88,8 @@ DÜZELTMELER - Güncellemeler: Yardım menüsündeki "Güncellemeleri denetle", WinZapp güncelleme sunucusuna ulaşamadığında artık sessiz kalmak yerine bunu bildirir. +- Yanıtlar: gönderimden önce özgün mesaj bulunamazsa WinZapp eksik alıntıyı bildirir ve metninizi mesaj menüsünden düzenleyebilmeniz için korur. Alıntıyı sessizce kaldırıp göndermez veya bağlantı kesildiğini söylemez. Sonucu belirsiz gönderimler otomatik olarak tekrar denenmez. + - Eşitleme: Tekrar gelen geçmiş mesajlar yeni gelmiş gibi sona eklenmez; tarih sırası korunur. Sohbetin alternatif adresini sorgularken oluşan geçici hatalar yeniden denemek üzere kuyrukta tutulur. - İletme başarılı olduğunda artık bir kutu açmıyor. "Mesaj iletildi." (ya da kaç mesajın iletildiği) artık ekran okuyucu tarafından söyleniyor; kapatılacak bir pencere yok ve okunan şey kesilmiyor. WinZapp sistem tepsisindeyken de çalışır. diff --git a/client/core/message_edit.py b/client/core/message_edit.py index 5a86274e0..29c6c7d23 100644 --- a/client/core/message_edit.py +++ b/client/core/message_edit.py @@ -48,6 +48,7 @@ from urllib3.exceptions import NewConnectionError +from core.reply_failure import is_missing_quote_failure from core.utils import is_message_forwarded #: How the normaliser tags an edit inside message.protocolMessage.type. @@ -270,6 +271,8 @@ def edit_kind(msg): """ if not isinstance(msg, dict) or not (msg.get("key") or {}).get("fromMe"): return None + if is_missing_quote_failure(msg): + return None # A definite pre-send refusal has no WhatsApp ID to edit. mtype = msg.get("messageType") if mtype in _TEXT_TYPES: return "text" diff --git a/client/core/message_queue.py b/client/core/message_queue.py index 41d2df940..afd6e07e1 100644 --- a/client/core/message_queue.py +++ b/client/core/message_queue.py @@ -429,6 +429,7 @@ def _media_progress(progress, pending=msg): ) retryable_failure = False disconnected = False + failure_reason = "" if isinstance(real_id, dict): if real_id.get("ok"): quote_lost = bool(real_id.get("quote_lost", False)) @@ -438,6 +439,7 @@ def _media_progress(progress, pending=msg): retryable_failure = bool(real_id.get("retry", True)) disconnected = bool(real_id.get("disconnected")) ambiguous = bool(real_id.get("ambiguous")) + failure_reason = real_id.get("reason") or "" real_id = False if msg.cancel_event.is_set(): @@ -535,12 +537,15 @@ def _media_progress(progress, pending=msg): msg.local_id, msg.jid, msg.fail_count, msg.last_error) with self._lock: self._pending.pop(msg.local_id, None) - wx.CallAfter( - self.main_window._on_message_failed, - msg.local_id, - msg.last_error, - bool(msg.media_path), # show dialog for media failures - ) + if failure_reason == "quoted_message_not_found": + wx.CallAfter(self.main_window._on_reply_quote_missing, msg.local_id) + else: + wx.CallAfter( + self.main_window._on_message_failed, + msg.local_id, + msg.last_error, + bool(msg.media_path), # show dialog for media failures + ) # Same as the ambiguous branch: _on_message_failed() # is cancel-aware, so this is this message's report. reported = True diff --git a/client/core/reply_failure.py b/client/core/reply_failure.py new file mode 100644 index 000000000..9750f50f6 --- /dev/null +++ b/client/core/reply_failure.py @@ -0,0 +1,55 @@ +"""A missing quote is definite only with an explicit pre-send API verdict.""" + + +def is_missing_quote_failure(message) -> bool: + return isinstance(message, dict) and bool( + message.get("_reply_quote_missing") and message.get("_send_failed") + ) + + +def quote_missing_before_send(response) -> bool: + if response.status_code != 500: + return False + try: + body = response.json() + except (ValueError, TypeError): + return False + return ( + isinstance(body, dict) + and body.get("status") == "error" + and body.get("code") == "quoted_message_not_found" + and body.get("stage") == "quote_resolution" + and body.get("sendAttempted") is False + ) + + +def mark_missing_reply_quote(chats, local_id): + """Keep the failed text in its own chat even after the panel moved away.""" + if not local_id: + return None + for jid, chat in chats.items(): + records = chat.get("messages", {}).get("messages", {}).get("records", []) + for record in records: + if record.get("_local_id") == local_id: + record["_local_pending"] = False + record["_send_failed"] = True + record["_reply_quote_missing"] = True + return jid, record + return None + + +#: What the composer held when a quoted reply was queued, kept on the row only +#: so a pre-send "quote missing" refusal can give the text back. +REPLY_DRAFT_KEYS = ("_reply_draft_text", "_reply_draft_mentions", "_reply_draft_mention_names") + + +def clear_reply_draft(record) -> None: + """Drop the recovery copy once the reply was sent. + + Nothing reads it after that (the restore needs a missing-quote failure), + and left on the row it is saved to messages.db with every sent reply: a + second copy of the text and a JID-to-name map, forever. + """ + if isinstance(record, dict): + for key in REPLY_DRAFT_KEYS: + record.pop(key, None) diff --git a/client/main_window/message_events.py b/client/main_window/message_events.py index d68a7ca80..68d216fb6 100644 --- a/client/main_window/message_events.py +++ b/client/main_window/message_events.py @@ -30,6 +30,7 @@ apply_caption_edit, is_edit_event, ) +from core.reply_failure import clear_reply_draft from core.call_log import ( call_log_supersedes, is_call_log, @@ -905,6 +906,7 @@ def on_new_message(self, msg: dict): if pending_msg: # Found the corresponding pending message: update it and skip appending a duplicate pending_msg["_local_pending"] = False + clear_reply_draft(pending_msg) # delivered: before the DB write below local_id = pending_msg.get("_local_id") pending_msg["key"]["id"] = msg_id pending_msg["messageTimestamp"] = msg.get("messageTimestamp", pending_msg["messageTimestamp"]) diff --git a/client/main_window/sending.py b/client/main_window/sending.py index 7761b48f2..15ef76854 100644 --- a/client/main_window/sending.py +++ b/client/main_window/sending.py @@ -18,6 +18,7 @@ import time import wx from core.message_queue import MessageCancelled +from core.reply_failure import mark_missing_reply_quote, quote_missing_before_send from core.connection_lifecycle import schedule_connection_check from core.meta_ai import ( STATE_ACCEPTED, @@ -556,6 +557,9 @@ def send_text_message(self, remote_jid, text, quoted=None, mentioned_jids=None, # reduces how often that false-timeout/duplicate-send scenario happens. response = api_post(url, json=payload, headers=headers, timeout=25) active_dest = phone_net + if quoted_id and not is_status_reply and quote_missing_before_send(response): + logging.warning("[send] Reply quote lookup refused before any send; keeping text") + return {"ok": False, "retry": False, "reason": "quoted_message_not_found"} if response.status_code not in (200, 201): # 1. The @lid destination was refused: fall back to the legacy # @c.us address. Historically this fired only on the 400 @@ -609,6 +613,8 @@ def send_text_message(self, remote_jid, text, quoted=None, mentioned_jids=None, } active_dest = fb_phone response = api_post(retry_url, json=retry_payload, headers=headers, timeout=25) + if quoted_id and not is_status_reply and quote_missing_before_send(response): + return {"ok": False, "retry": False, "reason": "quoted_message_not_found"} if response.status_code in (200, 201): logging.info("[send_text_message] legacy retry with %s succeeded", fb_phone) @@ -1294,6 +1300,29 @@ def _on_message_unconfirmed(self, local_id: str): if not self.background_mode: self.output(self.i18n.t("message_send_unconfirmed"), interrupt=False) + def _on_reply_quote_missing(self, local_id: str): + """Report a pre-send quote refusal without replacing the current draft.""" + if self._is_cancelled_send(local_id): + self._on_cancelled_message_dropped(local_id) + return + marked = mark_missing_reply_quote(self.chats, local_id) + if marked is None: + return # Chat/account was removed while the request was in flight. + jid, record = marked + saved = True + try: + # _schedule_save writes chat metadata, never the message records. + self.db.insert_message(jid, record) + except Exception as exc: + saved = False + logging.warning("[send] Could not save refused reply (%s)", type(exc).__name__) + self._schedule_save(dirty_jid=jid) + self._on_message_failed(local_id) + if not self.background_mode: + self.output(self.i18n.t("reply_quote_missing_error"), interrupt=False) + if not saved: + self.output(self.i18n.t("reply_quote_save_failed"), interrupt=False) + def _on_message_failed(self, local_id: str, error: str = "", show_dialog: bool = False): """ Called on the main thread after a queued message exhausts all retries. diff --git a/client/ui/conversation_panel/bulk_messages.py b/client/ui/conversation_panel/bulk_messages.py index 900d96e39..53ca404c0 100644 --- a/client/ui/conversation_panel/bulk_messages.py +++ b/client/ui/conversation_panel/bulk_messages.py @@ -13,6 +13,7 @@ from ui.conversation_panel.media_paths import _SAVEABLE_MESSAGE_TYPES from core.message_copy_format import format_copied_message from core.call_log import is_call_log +from core.reply_failure import is_missing_quote_failure from core.save_location import resolve_save_dialog_folder @@ -311,7 +312,7 @@ def _on_mass_delete_messages(self, event): admin_override = self._group_admin_delete_override() def _can_delete_for_all(msg): - if self._is_system_event(msg): + if self._is_system_event(msg) or is_missing_quote_failure(msg): return False return admin_override or msg.get("key", {}).get("fromMe", False) @@ -381,6 +382,8 @@ def _can_delete_for_all(msg): def _delete_bg(): failed = 0 for msg in msgs_to_delete: + if is_missing_quote_failure(msg): + continue # Local UUID only: there is no WhatsApp message to delete. msg_key = dict(msg.get("key", {})) jid = self._delete_target_jid(msg_key) if not jid: diff --git a/client/ui/conversation_panel/message_actions.py b/client/ui/conversation_panel/message_actions.py index fe09e0af8..a962676d1 100644 --- a/client/ui/conversation_panel/message_actions.py +++ b/client/ui/conversation_panel/message_actions.py @@ -17,6 +17,7 @@ edit_window_open, ) from core.message_queue import PendingMessage +from core.reply_failure import is_missing_quote_failure from ui.conversation_panel.typing_row import append_message_row, message_row_count from app_paths import data_path from ui.conversation_panel.media_paths import discard_local_media_cache @@ -208,7 +209,10 @@ def _on_menu_delete_message(self, index: int): # would be misleading anyway (see the comment above). if is_self_chat and not is_system: if self._confirm_local_only_delete(1): - self._delete_message_for_me_only(msg, msg_id, index) + if is_missing_quote_failure(msg): + self._cancel_pending_message(msg, str(msg.get("_local_id") or msg_id), hold_for_echo=False) + else: + self._delete_message_for_me_only(msg, msg_id, index) return if not can_delete_for_all and not is_system and self.conversation: @@ -296,7 +300,10 @@ def _phone_part(j: str) -> str: # path below (which would build a revoke request around the local # UUID this row's key.id still holds and could only fail). cancelled_pending = bool( - pending_local_id and (msg.get("_local_pending") or msg.get("_send_unconfirmed")) + pending_local_id and ( + msg.get("_local_pending") or msg.get("_send_unconfirmed") + or is_missing_quote_failure(msg) + ) ) if cancelled_pending: # An unconfirmed send shares the "nothing to revoke, local delete @@ -671,6 +678,9 @@ def _on_accel_edit_message(self, event): return if not msg.get("key", {}).get("fromMe", False): return + if is_missing_quote_failure(msg): + self._on_restore_unsent_reply(msg) + return if edit_kind(msg) is None: return if not edit_window_open(msg.get("messageTimestamp")): @@ -687,6 +697,9 @@ def _on_accel_edit_message(self, event): def _on_menu_edit_message(self, index: int, msg: dict): """Enter edit mode: pre-fill message field with message text.""" + if is_missing_quote_failure(msg): + self._on_restore_unsent_reply(msg) + return if edit_kind(msg) == "caption": # The raw caption, not _get_message_content(): for media that is # the row as the list reads it (type, file name, size), none of diff --git a/client/ui/conversation_panel/message_menu.py b/client/ui/conversation_panel/message_menu.py index 3180c667f..712016710 100644 --- a/client/ui/conversation_panel/message_menu.py +++ b/client/ui/conversation_panel/message_menu.py @@ -14,6 +14,7 @@ import threading import wx from core.quote_recovery import RECOVERED_FROM_QUOTE +from core.reply_failure import is_missing_quote_failure from ui.conversation_panel.media_paths import ( _SAVEABLE_MESSAGE_TYPES, cached_media_path, @@ -497,6 +498,14 @@ def on_messages_context_menu(self, event): resend_item, ) + if _is_text and is_missing_quote_failure(msg): + restore_item = menu.Append(wx.ID_ANY, f"{i18n.t('restore_unsent_reply')}\tAlt+E") + self.Bind( + wx.EVT_MENU, + lambda e, m=msg: self._on_restore_unsent_reply(m), + restore_item, + ) + menu.AppendSeparator() # Select / Unselect message (Ctrl+Space) — mirrors the label to diff --git a/client/ui/conversation_panel/text_sending.py b/client/ui/conversation_panel/text_sending.py index de8fbfe2e..18125cded 100644 --- a/client/ui/conversation_panel/text_sending.py +++ b/client/ui/conversation_panel/text_sending.py @@ -12,6 +12,7 @@ import uuid import wx from core.message_queue import PendingMessage +from core.reply_failure import clear_reply_draft, is_missing_quote_failure from ui.conversation_panel.typing_row import append_message_row, message_row_count from app_paths import data_path from ui.conversation_panel.media_paths import ( @@ -24,7 +25,7 @@ restore_edit_state, snapshot_edit_state, ) -from core.utils import normalize_line_separators +from core.utils import normalize_line_separators, to_editor_line_endings class TextSendingMixin: @@ -304,6 +305,11 @@ def _send_new_text_message(self, text: str, remote_jid: str): "pushName": "", } if self._quoted_message: + # Keep the composed text (including display names), not a rendered + # quote/preview or a wire body with mention phone numbers. + virtual_msg["_reply_draft_text"] = text + virtual_msg["_reply_draft_mentions"] = list(self._pending_mentions) + virtual_msg["_reply_draft_mention_names"] = dict(self._pending_mention_display_names) _qk = self._quoted_message.get("key", {}) virtual_msg["contextInfo"] = { "stanzaId": _qk.get("id", ""), @@ -490,6 +496,7 @@ def _mark_message_sent(self, local_id: str, real_id: str = None, quote_lost: boo tracked = self._outgoing_virtual_messages.pop(local_id, None) if tracked is not None: tracked["_local_pending"] = False + clear_reply_draft(tracked) if real_id and isinstance(real_id, str): tracked.setdefault("key", {})["id"] = real_id self._media_upload_progress.pop(local_id, None) @@ -512,6 +519,7 @@ def _mark_message_sent(self, local_id: str, real_id: str = None, quote_lost: boo return # Already marked sent on the UI, ignore to prevent duplicate sound and actions msg["_ui_sent"] = True msg["_local_pending"] = False + clear_reply_draft(msg) # The quoted send failed and the message went out as a plain # send: drop the reply contextInfo so the row no longer reads # as "respondendo a …". The quote never reached the recipient. @@ -571,6 +579,31 @@ def _mark_message_sent(self, local_id: str, real_id: str = None, quote_lost: boo self.main_window._schedule_set_chats() + def _on_restore_unsent_reply(self, msg: dict): + """Explicit recovery only: never overwrite another draft or send here.""" + if not is_missing_quote_failure(msg): + return + # The record keeps the address it was sent to; after an @lid chat is + # re-keyed to its phone JID the two differ but are the same chat. + conv_jid = self.conversation.get("remoteJid", "") if self.conversation else "" + if not conv_jid or not self.main_window._chat_jids_equivalent( + (msg.get("key") or {}).get("remoteJid", ""), conv_jid): + return + if (self.message_field.GetValue() or self._quoted_message + or self._editing_message_id is not None or self._pending_mentions): + self.main_window.output(self.main_window.i18n.t("reply_restore_draft_busy"), interrupt=False) + return + text = msg.get("_reply_draft_text") + if not isinstance(text, str) or not text: + return + self._pending_mention_display_names = dict(msg.get("_reply_draft_mention_names") or {}) + self._pending_mentions = list(msg.get("_reply_draft_mentions") or []) + self._last_sent_signature = None # This explicit retry is a new user action. + self._rebuild_mention_pills() + self.message_field.SetValue(to_editor_line_endings(text)) + self.message_field.SetInsertionPointEnd() + self.message_field.SetFocus() + def _mark_message_failed(self, local_id: str): """Mark a virtual pending message as permanently failed (exhausted retries).""" self._hide_media_transfer_gauge() @@ -780,6 +813,7 @@ def _restore_cancelled_message(self, local_id: str, real_id: str, if real_id: msg["_local_pending"] = False msg.setdefault("key", {})["id"] = real_id + clear_reply_draft(msg) # delivered, as in _mark_message_sent() if quote_lost: # The quoted send failed server-side and it went out as a plain # message: the row must stop reading as a reply, exactly as diff --git a/client/ui/dialogs/api_setup.py b/client/ui/dialogs/api_setup.py index 85fa4f24e..864908f3a 100644 --- a/client/ui/dialogs/api_setup.py +++ b/client/ui/dialogs/api_setup.py @@ -224,6 +224,7 @@ def fetch_latest_wpp_tag(timeout: float = 15) -> str: "src/tests/controller/pinnedMessagesController.test.ts", "src/util/forwardRuntime.ts", "src/util/statusReactionRuntime.ts", + "src/util/quotedMessagePreflight.ts", "src/util/contactPresenceRuntime.ts", "src/util/listChatsDiag.ts", "src/util/createSessionUtil.ts", diff --git a/docs/traps/send-contract.md b/docs/traps/send-contract.md index a6471d2d6..a3766225a 100644 --- a/docs/traps/send-contract.md +++ b/docs/traps/send-contract.md @@ -32,3 +32,16 @@ - **Forwarding goes through `forwardRuntime.ts`, not `forwardMessagesV2` (2026-10-02)**: `deviceController.forwardMessages` evaluates one in-page function that loads the unloaded Bootloader resources of the forward components and calls WhatsApp's `forwardMessages({chat, msgs, ...})` directly, because wa-js's own binding fails with `forward_messages_not_available` on builds with unresolved dependencies. The response keeps its shape (`201 {status:'success', response}`; any failure is a 500, now with a `detail` field). A rejected forward is never retried on the Node side; Python's own single retry in `forward_message` stays. Cause, rule and diagnosis ladder: `docs/traps/whatsapp-web-version-pin.md`. - **Retry overlap: the forward is deduplicated in the page (2026-10-02)**: `forward_message` posts again after its 20 s timeout while the first request's `page.evaluate` is still running (a slow heal, or a slow WhatsApp). Without a guard the retry forwards the same messages a second time. `forwardRuntime.ts` therefore keeps `window.__winzappForwardInflight[chatId + '|' + ids]`: a request whose key is already in flight awaits the first one's result and never sends; the key is released in a `finally` (also on rejection), so a deliberate later forward is not blocked. The heal budget is 10 s so it ends well inside Python's timeout. `response` is passed through `JSON.parse(JSON.stringify())` because a result that cannot be serialized would fail the evaluate AFTER the send, a 500, and the very retry this rule exists to avoid. The same rule applies to any new send route that Python retries. - **`keepVoice`: a forwarded voice message stays a voice message (2026-10-04)**: WhatsApp Web converts a forwarded `ptt` into a plain `audio` on the client, in `WAWebMediaForwardMediaMsg.forwardMediaMsg`: `q = msg.mediaData.toJSON()`, then `q.type = AUDIO` unless the message came from a channel — so a forwarded `ptt` is something WhatsApp itself sends and its apps display. With `keepVoice: true` in the POST (Settings > User Interface, `forward_voice_as_voice`, off by default; Python sends it only for a voice message), `forwardRuntime.ts` wraps `toJSON` of that one message for the length of that one forward so the write to `audio` is refused; everything else is still WhatsApp's own forward, and the copy keeps `isForwarded`/`forwardingScore`. Measured over CDP: the copy arrived as `type ptt`, `isForwarded true`, `forwardingScore 1`, duration and waveform intact. Three rules: the guard is **shared per message** (a `WeakMap`, removed by the last overlapping forward — two independent wrappers restore each other's function and leave one behind for good); it is taken off in a `finally`, also on a rejected forward; and it **cannot fail a send** — if WhatsApp stops converting through `toJSON`, the guard never fires, the message goes out as audio as before, and the handler logs `voice kept for N of M` (the outcome carries `voice: {asked, kept}`). `keepVoice` is part of the in-flight key, so a retry still dedupes but a keep and a plain forward of the same message are two sends. +- **A missing ordinary reply quote has a pre-send verdict (2026-10-13).** + `replyMessage` and quoted `sendMentioned` resolve the quote before the first + recipient's send. Only the in-page `msg_not_found` code emits + `{status:'error', code:'quoted_message_not_found', stage:'quote_resolution', + sendAttempted:false}`. It deliberately stays HTTP 500: old Python clients + strip quotes and resend on 4xx. New clients recognize the complete verdict + before any fallback, keep the failed row/text in its original chat and offer + an explicit edit action. No automatic plain resend or offline transition. + Unknown preflight errors and EVERY exception during/after a send still use + the old ambiguous path. Never infer this verdict from an error message or add + it to a catch around a send. Status replies retain their separate model path. + `quotedMessagePreflight.ts` is shipped through all patch lists; without a + rebuilt API an older Node still returns the existing ambiguous response. diff --git a/setup_api.py b/setup_api.py index 02acdfaac..361186597 100644 --- a/setup_api.py +++ b/setup_api.py @@ -131,6 +131,7 @@ def _homologated_major() -> str: "src/tests/controller/pinnedMessagesController.test.ts", "src/util/forwardRuntime.ts", "src/util/statusReactionRuntime.ts", + "src/util/quotedMessagePreflight.ts", "src/util/contactPresenceRuntime.ts", "src/util/listChatsDiag.ts", "src/util/createSessionUtil.ts", diff --git a/tests/test_api_patches_in_sync.py b/tests/test_api_patches_in_sync.py index d497967ff..a1ab17166 100644 --- a/tests/test_api_patches_in_sync.py +++ b/tests/test_api_patches_in_sync.py @@ -74,6 +74,7 @@ def _setup_api_module(): "src/tests/controller/pinnedMessagesController.test.ts", "src/util/forwardRuntime.ts", "src/util/statusReactionRuntime.ts", + "src/util/quotedMessagePreflight.ts", "src/util/contactPresenceRuntime.ts", "src/util/listChatsDiag.ts", "src/util/createSessionUtil.ts", diff --git a/tests/test_message_cancel_race.py b/tests/test_message_cancel_race.py index b4bd9b095..0b16ab9d1 100644 --- a/tests/test_message_cancel_race.py +++ b/tests/test_message_cancel_race.py @@ -1173,6 +1173,26 @@ def test_a_failed_revoke_puts_the_row_back_under_the_real_id(self, monkeypatch, assert main_window.records() == [msg] assert main_window.spoken == ["cancelled_message_still_sent"] + def test_a_restored_reply_is_stored_without_its_recovery_copy(self, monkeypatch, tmp_path): + """Delivered after all: the reply's draft copy is not saved to the DB.""" + from core.reply_failure import REPLY_DRAFT_KEYS + patch_conversations_global(monkeypatch, "data_path", lambda name: str(tmp_path)) + msg = _pending_text("loc-1", "oi") + msg.update({"_reply_draft_text": "oi", "_reply_draft_mentions": [], + "_reply_draft_mention_names": {}}) + main_window = _EchoMainWindow(records=[msg]) + stored = [] + main_window.db.insert_message = lambda jid, m: stored.append(dict(m)) + main_window.message_queue = _FakeQueue(result=False) + panel = _make_panel(main_window, msg) + ConversationsPanel._cancel_pending_message(panel, msg, "loc-1") + + ConversationsPanel._finish_cancelled_message_delivery( + panel, "loc-1", "REAL_A", False + ) + + assert stored and not any(key in stored[-1] for key in REPLY_DRAFT_KEYS) + def test_a_restored_row_drops_a_quote_that_never_went_out(self, monkeypatch, tmp_path): patch_conversations_global(monkeypatch, "data_path", lambda name: str(tmp_path)) msg = _pending_text("loc-1", "oi") diff --git a/tests/test_missing_reply_quote.py b/tests/test_missing_reply_quote.py new file mode 100644 index 000000000..efad72e74 --- /dev/null +++ b/tests/test_missing_reply_quote.py @@ -0,0 +1,465 @@ +"""A missing quote before sending is a refusal, never an offline/unknown send.""" + +import copy +import json +import threading +import types + +import pytest + +from core.message_queue import MessageQueue, PendingMessage +from core.reply_failure import (REPLY_DRAFT_KEYS, clear_reply_draft, is_missing_quote_failure, + mark_missing_reply_quote, quote_missing_before_send) +from main import MainWindow +from ui.conversations import ConversationsPanel +from tests.god_modules import patch_main_global +from tests.test_reply_with_mention_keeps_quote import _Stub as SendStub, QUOTED, GROUP, LID, MENTION +from tests import test_duplicate_message_echo_matching as echo + + +VERDICT = {"status": "error", "code": "quoted_message_not_found", + "stage": "quote_resolution", "sendAttempted": False} + + +class Response: + def __init__(self, body=VERDICT, status=500): + self.body, self.status_code = body, status + self.text = json.dumps(body) + + def json(self): + return self.body + + +def test_complete_pre_send_verdict_is_recognized(): + assert quote_missing_before_send(Response()) + + +@pytest.mark.parametrize("body", [None, [], "msg_not_found", {}, + {**VERDICT, "sendAttempted": True}, {**VERDICT, "sendAttempted": 0}, + {**VERDICT, "sendAttempted": "false"}, {**VERDICT, "stage": "send"}, + {**VERDICT, "status": "success"}, {**VERDICT, "code": "msg_not_found"}, + {"error": {"message": "Message false_chat@c.us_ID not found"}}, + *[{k: v for k, v in VERDICT.items() if k != absent} for absent in VERDICT], +]) +def test_incomplete_or_legacy_error_does_not_claim_nothing_was_sent(body): + assert not quote_missing_before_send(Response(body)) + + +@pytest.mark.parametrize("status", [200, 201, 400, 404, 409, 502, 503]) +def test_unrelated_status_does_not_take_the_new_contract(status): + assert not quote_missing_before_send(Response(status=status)) + + +def test_invalid_json_does_not_override_ambiguity(): + response = Response() + response.json = lambda: json.loads("not json") + assert not quote_missing_before_send(response) + + +@pytest.mark.parametrize("destination", [GROUP, LID, "5511999999999@s.whatsapp.net"]) +@pytest.mark.parametrize("mentions", [None, [MENTION]]) +def test_missing_quote_is_not_resent_plain_or_to_another_address(monkeypatch, destination, mentions): + calls, flags = [], [] + stub = SendStub() + stub._set_wa_connected = lambda *args: flags.append(args) + patch_main_global(monkeypatch, "api_post", lambda url, **kw: calls.append(url) or Response()) + result = stub.send_text_message(destination, "hello", quoted=QUOTED, mentioned_jids=mentions) + assert len(calls) == 1 + assert result == {"ok": False, "retry": False, "reason": "quoted_message_not_found"} + assert flags == [] + + +def test_a_refusal_on_the_legacy_retry_does_not_strip_the_quote(monkeypatch): + replies = iter([Response({}, 400), Response()]) + calls = [] + patch_main_global(monkeypatch, "api_post", lambda url, **kw: calls.append(url) or next(replies)) + result = SendStub().send_text_message(LID, "hello", quoted=QUOTED) + assert len(calls) == 2 + assert all(url.endswith('/send-reply') for url in calls) + assert result['reason'] == 'quoted_message_not_found' + + +@pytest.mark.parametrize("quoted", [None, {"key": {"remoteJid": "status@broadcast", "id": "X"}}]) +def test_plain_and_status_sends_do_not_use_the_ordinary_reply_contract(monkeypatch, quoted): + patch_main_global(monkeypatch, "api_post", lambda *a, **kw: Response()) + result = SendStub().send_text_message(GROUP, "hello", quoted=quoted) + assert result.get("ambiguous") is True + assert result.get("reason") is None + + +def message(): + return {"_local_id": "local", "_local_pending": True, + "key": {"id": "local", "remoteJid": GROUP, "fromMe": True}, + "messageType": "conversation", "message": {"conversation": "draft"}, + "_reply_draft_text": "> keep this\n@Name https://example.com", + "_reply_draft_mentions": [MENTION], "_reply_draft_mention_names": {MENTION: "Name"}} + + +def chats(record): + return {GROUP: {"messages": {"messages": {"records": [record]}}}} + + +def test_failure_marks_the_origin_record_and_preserves_all_text(): + record = message() + original = copy.deepcopy(record) + assert mark_missing_reply_quote(chats(record), 'local') == (GROUP, record) + assert record['_send_failed'] and record['_reply_quote_missing'] + assert not record['_local_pending'] + assert record['message'] == original['message'] + assert record['_reply_draft_text'] == original['_reply_draft_text'] + assert mark_missing_reply_quote(chats(record), 'deleted') is None + + +def test_delayed_callback_updates_original_chat_without_touching_a_new_draft(): + record = message() + events = [] + stub = types.SimpleNamespace(chats=chats(record), background_mode=False, + db=types.SimpleNamespace(insert_message=lambda jid, msg: events.append(('message', jid, msg['_local_id']))), + _is_cancelled_send=lambda local: False, + _schedule_save=lambda **kw: events.append(('save', kw)), + _on_message_failed=lambda local: events.append(('failed', local)), + output=lambda *a, **kw: events.append(('speech', a)), + i18n=types.SimpleNamespace(t=lambda key: key)) + MainWindow._on_reply_quote_missing(stub, 'local') + assert events == [('message', GROUP, 'local'), ('save', {'dirty_jid': GROUP}), ('failed', 'local'), + ('speech', ('reply_quote_missing_error',))] + + +@pytest.mark.parametrize('cancelled', [True, False]) +def test_deleted_or_cancelled_record_is_not_restored_or_announced(cancelled): + dropped = [] + stub = types.SimpleNamespace(chats={}, + _is_cancelled_send=lambda local: cancelled, + _on_cancelled_message_dropped=lambda local: dropped.append(local)) + MainWindow._on_reply_quote_missing(stub, 'local') + assert dropped == (['local'] if cancelled else []) + + +class Field: + def __init__(self, text=''): + self.text, self.focused = text, False + def GetValue(self): + return self.text + def SetValue(self, text): + self.text = text + def SetInsertionPointEnd(self): + pass + def SetFocus(self): + self.focused = True + + +PHONE = "5511988887777@s.whatsapp.net" + + +class _Identity: + """The real JID equivalence, with LID bridged to PHONE.""" + _normalize_jid = staticmethod(MainWindow._normalize_jid) + _jid_address_forms = MainWindow._jid_address_forms + _chat_jids_equivalent = MainWindow._chat_jids_equivalent + + def __init__(self, output): + self.output = lambda *a, **kw: output.append(a) + self.i18n = types.SimpleNamespace(t=lambda key: key) + self._lid_to_phone = {LID: PHONE} + self._phone_to_lid = {PHONE: LID} + + +def panel(): + output = [] + return types.SimpleNamespace(conversation={'remoteJid': GROUP}, + message_field=Field(), _quoted_message=None, _editing_message_id=None, + _pending_mentions=[], _pending_mention_display_names={}, + _rebuild_mention_pills=lambda: None, + main_window=_Identity(output), output=output) + + +def test_explicit_recovery_restores_composed_text_and_mentions_without_sending(): + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = panel() + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text.replace('\r\n', '\n') == record['_reply_draft_text'] + assert stub._pending_mentions == [MENTION] + assert stub._pending_mention_display_names == {MENTION: 'Name'} + assert stub._quoted_message is None + assert stub._last_sent_signature is None + assert stub.message_field.focused + assert stub.output == [] + + +@pytest.mark.parametrize('busy', ['text', 'quote', 'edit', 'mentions']) +def test_recovery_never_overwrites_other_composition_state(busy): + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = panel() + if busy == 'text': stub.message_field.text = 'new draft' + if busy == 'quote': stub._quoted_message = {'key': {'id': 'another'}} + if busy == 'edit': stub._editing_message_id = 'another' + if busy == 'mentions': stub._pending_mentions = [LID] + original = stub.message_field.text + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text == original + assert not stub.message_field.focused + assert stub.output == [('reply_restore_draft_busy',)] + + +def test_recovery_does_not_insert_text_into_another_chat(): + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = panel() + stub.conversation = {'remoteJid': LID} + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text == '' and stub.output == [] + + +@pytest.mark.parametrize('sent_to, open_chat', [(LID, PHONE), (PHONE, LID), + (PHONE.replace('@s.whatsapp.net', '@c.us'), PHONE)]) +def test_recovery_survives_the_chat_being_rekeyed(sent_to, open_chat): + """Sent while the chat was keyed by its @lid, restored after + deduplicate_chats() re-keyed it to the phone JID (or the reverse): the + record keeps the old address, and the recovery must still work.""" + record = message() + record['key']['remoteJid'] = sent_to + mark_missing_reply_quote({sent_to: {"messages": {"messages": {"records": [record]}}}}, 'local') + stub = panel() + stub.conversation = {'remoteJid': open_chat} + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text.replace('\r\n', '\n') == record['_reply_draft_text'] + + +def test_recovery_needs_an_open_conversation(): + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = panel() + stub.conversation = None + ConversationsPanel._on_restore_unsent_reply(stub, record) + assert stub.message_field.text == '' + + +def test_composition_snapshot_keeps_display_text_and_wire_mentions(monkeypatch): + import ui.conversation_panel.text_sending as module + monkeypatch.setattr(module, 'append_message_row', lambda *a: None) + monkeypatch.setattr(module, 'message_row_count', lambda *a: 0) + saved, queued = [], [] + stub = panel() + stub._pending_mentions = [MENTION] + stub._pending_mention_display_names = {MENTION: 'Name'} + stub._quoted_message = QUOTED + stub._pending_link_preview = {'title': 'do not inject this', 'description': '', 'canonicalUrl': 'https://example.com'} + stub._build_mention_payload = lambda text: ('@5511888888888 hello', [MENTION]) + stub._sorted_messages = [] + stub._clear_empty_placeholder = lambda: None + stub._render_message_line = lambda msg: 'rendered' + stub._hide_mention_suggestions = lambda: None + stub._on_cancel_reply = lambda: None + stub._clear_link_preview = lambda: None + stub._register_virtual_msg = lambda msg: saved.append(msg) + stub.main_window.message_queue = types.SimpleNamespace(enqueue=queued.append) + stub.main_window.mark_conversation_as_read = lambda jid: None + stub.main_window._schedule_set_chats = lambda: None + ConversationsPanel._send_new_text_message(stub, '@Name hello', GROUP) + assert saved[0]['_reply_draft_text'] == '@Name hello' + assert saved[0]['_reply_draft_mentions'] == [MENTION] + assert saved[0]['_reply_draft_mention_names'] == {MENTION: 'Name'} + assert queued[0].text == '@5511888888888 hello' + assert queued[0].mentioned_jids == [MENTION] + assert saved[0]['_reply_draft_mention_names'] is not stub._pending_mention_display_names + + +def test_empty_local_id_cannot_mark_a_real_message_failed(): + record = {'key': {'id': 'REAL'}, 'message': {'conversation': 'real'}} + assert mark_missing_reply_quote(chats(record), None) is None + assert '_send_failed' not in record + + +def test_queue_reports_the_special_failure_once_and_drops_it(monkeypatch): + import core.message_queue as module + monkeypatch.setattr(module.wx, 'CallAfter', lambda fn, *a: fn(*a)) + complete = threading.Event() + calls = [] + stub = types.SimpleNamespace(offline_mode=False, _wa_connected=True, + send_text_message=lambda *a, **kw: calls.append('send') or + {'ok': False, 'retry': False, 'reason': 'quoted_message_not_found'}, + _on_reply_quote_missing=lambda local: calls.append(local) or complete.set()) + queue = MessageQueue(stub) + try: + queue.enqueue(PendingMessage('local', GROUP, text='draft', quoted=QUOTED)) + assert complete.wait(timeout=2) + finally: + queue.stop() + assert calls == ['send', 'local'] + assert queue._pending == {} + + +@pytest.mark.parametrize('for_everyone', [False, True]) +def test_deleting_a_missing_quote_failure_is_local_only(monkeypatch, tmp_path, for_everyone): + from tests.test_unconfirmed_send_recovery import _DeleteStub, _patch_delete_dialog + import wx + record = message() + mark_missing_reply_quote(chats(record), 'local') + _patch_delete_dialog(monkeypatch, result=wx.ID_OK, everyone_selected=for_everyone, tmp_path=tmp_path) + stub = _DeleteStub(record) + stub._on_menu_delete_message(0) + assert stub.removed_ids == [{'local'}] + assert stub.main_window.everyone_calls == [] + assert stub.main_window.for_me_calls == [] + + +def test_failed_reply_and_mentions_survive_database_close_and_reopen(tmp_path, fernet_key): + from core.database_bridge import DatabaseBridge + path = str(tmp_path / 'reply.db') + record = message() + record['messageTimestamp'] = 1234 + db = DatabaseBridge(path, fernet_key) + try: + db.upsert_chat(GROUP, {'remoteJid': GROUP}) + stub = types.SimpleNamespace(chats=chats(record), background_mode=True, db=db, + _is_cancelled_send=lambda local: False, _schedule_save=lambda **kw: None, + _on_message_failed=lambda local: None) + MainWindow._on_reply_quote_missing(stub, 'local') + finally: + db.close() + db = DatabaseBridge(path, fernet_key) + try: + [restored] = db.get_messages(GROUP) + finally: + db.close() + for field in ('_local_id', '_send_failed', '_reply_quote_missing', '_local_pending', + '_reply_draft_text', '_reply_draft_mentions', '_reply_draft_mention_names', 'message'): + assert restored[field] == record[field] + # The persisted record itself is sufficient for recovery after restart. + stub = panel() + ConversationsPanel._on_restore_unsent_reply(stub, restored) + assert stub.message_field.text.replace('\r\n', '\n') == record['_reply_draft_text'] + + +def test_database_failure_keeps_memory_text_and_reports_that_it_was_not_saved(): + record = message() + spoken = [] + def fail(*a): + raise OSError('simulated write failure') + stub = types.SimpleNamespace(chats=chats(record), background_mode=False, + db=types.SimpleNamespace(insert_message=fail), + _is_cancelled_send=lambda local: False, _schedule_save=lambda **kw: None, + _on_message_failed=lambda local: None, output=lambda text, **kw: spoken.append(text), + i18n=types.SimpleNamespace(t=lambda key: key)) + MainWindow._on_reply_quote_missing(stub, 'local') + assert record['_reply_draft_text'] + assert spoken == ['reply_quote_missing_error', 'reply_quote_save_failed'] + + +@pytest.mark.parametrize('entry', ['shortcut', 'stale_menu']) +def test_remote_edit_entry_points_use_guarded_recovery(monkeypatch, entry): + from core.message_edit import edit_kind + record = message() + mark_missing_reply_quote(chats(record), 'local') + assert edit_kind(record) is None + stub = panel() + stub.message_field.text = 'another draft' + stub._sorted_messages = [record] + stub.messages_list = types.SimpleNamespace(GetFirstSelected=lambda: 0) + stub._is_separator = lambda msg: False + stub._on_restore_unsent_reply = lambda msg: ConversationsPanel._on_restore_unsent_reply(stub, msg) + if entry == 'shortcut': + ConversationsPanel._on_accel_edit_message(stub, None) + else: + ConversationsPanel._on_menu_edit_message(stub, 0, record) + assert stub.message_field.text == 'another draft' + assert stub._editing_message_id is None + assert stub.output == [('reply_restore_draft_busy',)] + + +@pytest.mark.parametrize('record, expected', [(None, False), ({}, False), + ({'_reply_quote_missing': True}, False), ({'_send_failed': True}, False), + ({'_reply_quote_missing': True, '_send_failed': True}, True)]) +def test_only_the_definite_missing_quote_state_uses_local_recovery(record, expected): + assert is_missing_quote_failure(record) is expected + + +def test_self_chat_quote_failure_deletes_without_a_remote_uuid_request(monkeypatch, tmp_path): + from tests.test_unconfirmed_send_recovery import _DeleteStub, _patch_delete_dialog + record = message() + mark_missing_reply_quote(chats(record), 'local') + _patch_delete_dialog(monkeypatch, tmp_path=tmp_path) + stub = _DeleteStub(record) + stub.main_window._is_self_jid = lambda jid: True + stub._confirm_local_only_delete = lambda count: True + stub._on_menu_delete_message(0) + assert stub.removed_ids == [{'local'}] + assert stub.main_window.everyone_calls == [] + assert stub.main_window.for_me_calls == [] + + +@pytest.mark.parametrize('for_everyone', [False, True]) +@pytest.mark.parametrize('mixed', [False, True]) +def test_bulk_delete_removes_failed_reply_locally_and_keeps_real_message_scope(monkeypatch, tmp_path, for_everyone, mixed): + from tests.test_unconfirmed_send_recovery import _DeleteStub, _patch_delete_dialog + import ui.conversation_panel.bulk_messages as module + _patch_delete_dialog(monkeypatch, everyone_selected=for_everyone, tmp_path=tmp_path) + monkeypatch.setattr(module.wx, 'CallAfter', lambda fn, *a: fn(*a)) + monkeypatch.setattr(module.threading, 'Thread', lambda target, **kw: types.SimpleNamespace(start=target)) + record = message() + mark_missing_reply_quote(chats(record), 'local') + stub = _DeleteStub(record) + real = {'key': {'id': 'REAL', 'remoteJid': GROUP, 'fromMe': True}, 'messageType': 'conversation'} + stub._sorted_messages = [record, real] if mixed else [record] + stub.selected_messages = {m['key']['id'] for m in stub._sorted_messages} + stub._group_admin_delete_override = lambda: False + stub._refresh_message_rows_by_ids = lambda ids: None + stub._on_bulk_delete_for_everyone_done = lambda failed: None + stub._apply_confirmed_revoke = lambda *a: None + stub._delete_target_jid = lambda key: key['remoteJid'] + ConversationsPanel._on_mass_delete_messages(stub, None) + expected = {'local', 'REAL'} if mixed and not for_everyone else {'local'} + assert stub.removed_ids == [expected] + every = stub.main_window.everyone_calls + local = stub.main_window.for_me_calls + assert len(every) == int(mixed and for_everyone) + assert len(local) == int(mixed and not for_everyone) + assert all(key['id'] == 'REAL' for jid, key in every + local) + assert not stub.selected_messages + + +# ── the recovery copy goes once the reply was delivered ────────────────────── + +def _draft(**record): + return {**record, "_reply_draft_text": "text", "_reply_draft_mentions": [MENTION], + "_reply_draft_mention_names": {MENTION: "Name"}} + + +def test_clear_reply_draft_drops_only_the_recovery_copy(): + record = _draft(message={"conversation": "text"}) + clear_reply_draft(record) + assert record == {"message": {"conversation": "text"}} + clear_reply_draft(record) # idempotent + clear_reply_draft(None) # and tolerant + + +def test_a_reply_marked_sent_no_longer_carries_its_draft(): + msg = echo._make_msg(**_draft()) + panel = echo._make_panel(msg) + # The tracked copy and the row are separate dicts after a list rebuild. + tracked = copy.deepcopy(msg) + panel._outgoing_virtual_messages = {"loc-1": tracked} + ConversationsPanel._mark_message_sent(panel, "loc-1", real_id="REAL") + for record in (msg, tracked): + assert not any(key in record for key in REPLY_DRAFT_KEYS) + + +def test_a_reply_whose_send_is_still_unconfirmed_keeps_its_draft(): + """No real id yet: the row stays pending for the echo, draft included.""" + msg = echo._make_msg(**_draft()) + panel = echo._make_panel(msg) + ConversationsPanel._mark_message_sent(panel, "loc-1", real_id=None) + assert all(key in msg for key in REPLY_DRAFT_KEYS) + + +def test_the_echo_of_a_reply_drops_its_draft_before_it_is_saved(): + saved = [] + record = _draft(**echo._pending_record("locB")) + stub = echo._Stub(echo._chat_with([record])) + stub._msg_bg_executor = types.SimpleNamespace(submit=lambda fn: fn()) + stub.db = types.SimpleNamespace(insert_message=lambda jid, msg: saved.append(dict(msg))) + stub.on_new_message(echo._echo("REAL_B")) + assert record["_local_pending"] is False + assert saved and not any(key in saved[0] for key in REPLY_DRAFT_KEYS) diff --git a/tests/test_reply_quote_preflight_runtime.py b/tests/test_reply_quote_preflight_runtime.py new file mode 100644 index 000000000..4990567be --- /dev/null +++ b/tests/test_reply_quote_preflight_runtime.py @@ -0,0 +1,64 @@ +"""Execute the actual preflight and response builder with a fake browser page.""" + +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +RUNTIME = ROOT / 'client/api_patches/src/util/quotedMessagePreflight.ts' + + +def node(): + bundled = ROOT / 'client/node' / ('node.exe' if os.name == 'nt' else 'node') + return os.environ.get('WINZAPP_TEST_NODE') or (str(bundled) if bundled.exists() else shutil.which('node')) + + +HARNESS = r""" +const fs = require('fs'), vm = require('vm'), assert = require('assert'); +const source = fs.readFileSync(process.argv[1], 'utf8').replace(/^export /gm, ''); +const world = vm.createContext({window: {WPP: {chat: {}}}}); +vm.runInContext(source, world); +let reads = 0; +const client = {page: {evaluate: expression => vm.runInContext(expression, world)}}; +(async () => { + world.window.WPP.chat.getMessageById = async id => { reads++; assert.equal(id, 'false_chat@c.us_ID'); return {id}; }; + assert.equal(await world.replyQuoteIsMissing(client, 'false_chat@c.us_ID'), false); + world.window.WPP.chat.getMessageById = async () => { throw {code: 'msg_not_found'}; }; + assert.equal(await world.replyQuoteIsMissing(client, 'false_chat@c.us_ID'), true); + for (const error of [new Error('Message X not found'), {code: 'TargetCloseError'}, {code: 'other'}]) { + world.window.WPP.chat.getMessageById = async () => {throw error;}; + await assert.rejects(() => world.replyQuoteIsMissing(client, 'X'), e => e === error); + } + world.window.WPP.chat.getMessageById = async () => null; + await assert.rejects(() => world.replyQuoteIsMissing(client, 'X')); + let code, body; + const res = {status: n => {code = n; return res;}, json: b => {body = b; return res;}}; + world.missingReplyQuoteResponse(res); + assert.equal(code, 500); // old clients must NEVER strip the quote on a 4xx + assert.equal(JSON.stringify(body), JSON.stringify({status:'error',code:'quoted_message_not_found',stage:'quote_resolution',sendAttempted:false})); + assert.equal(reads, 1); + console.log('preflight behavior OK'); +})().catch(e => { console.error(e); process.exitCode = 1; }); +""" + + +@pytest.mark.skipif(node() is None, reason='Node is not available') +def test_runtime_distinguishes_only_a_proven_missing_quote(): + done = subprocess.run([node(), '-e', HARNESS, str(RUNTIME)], + capture_output=True, text=True, encoding='utf-8', timeout=10) + assert done.returncode == 0, done.stderr + assert 'preflight behavior OK' in done.stdout + + +@pytest.mark.parametrize('name', ['replyMessage', 'sendMentioned']) +def test_preflight_is_wired_before_the_recipient_loop(name): + # Structural guard: the behavioral tests above cannot detect a controller + # moving this safe read after a send to an earlier recipient. + source = (ROOT / 'client/api_patches/src/controller/messageController.ts').read_text(encoding='utf-8') + part = source.split('export async function ' + name + '(', 1)[1].split('export async function ', 1)[0] + assert part.index('replyQuoteIsMissing(') < part.index('for (const contato of phone)') + assert part.index('missingReplyQuoteResponse(res)') < part.index('for (const contato of phone)') + assert "!messageId.includes('status@broadcast')" in part diff --git a/translations/en-US/LC_MESSAGES/winzapp.po b/translations/en-US/LC_MESSAGES/winzapp.po index 88fa96573..8b1355d0c 100644 --- a/translations/en-US/LC_MESSAGES/winzapp.po +++ b/translations/en-US/LC_MESSAGES/winzapp.po @@ -7128,6 +7128,32 @@ msgid "" msgstr "" "Could not quote the original message; sending as a regular message instead." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "Finish or clear your current message before restoring this reply." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7161,6 +7187,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Some changes will only take effect after restarting WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Edit unsent reply without its quote" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/es-ES/LC_MESSAGES/winzapp.po b/translations/es-ES/LC_MESSAGES/winzapp.po index 5e71910d8..6df63cae2 100644 --- a/translations/es-ES/LC_MESSAGES/winzapp.po +++ b/translations/es-ES/LC_MESSAGES/winzapp.po @@ -7209,6 +7209,32 @@ msgid "" msgstr "" "No se pudo citar el mensaje original; se enviará como un mensaje normal." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"No se encontró el mensaje citado, por lo que tu respuesta no se envió. El " +"texto se conserva en la lista de mensajes. Elige 'Editar respuesta no " +"enviada sin la cita' en su menú para continuar." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"No se pudo guardar la respuesta en el disco. El texto sigue en la lista de " +"mensajes; cópialo antes de cerrar WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "Termina o borra el mensaje actual antes de recuperar esta respuesta." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7242,6 +7268,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Algunos cambios solo tendrán efecto después de reiniciar WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Editar respuesta no enviada sin la cita" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/pl/LC_MESSAGES/winzapp.po b/translations/pl/LC_MESSAGES/winzapp.po index 6cd411b65..f03e6e764 100644 --- a/translations/pl/LC_MESSAGES/winzapp.po +++ b/translations/pl/LC_MESSAGES/winzapp.po @@ -7185,6 +7185,33 @@ msgstr "" "Nie udało się zacytować oryginalnej wiadomości; zostanie wysłana jako zwykła" " wiadomość." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"Nie znaleziono cytowanej wiadomości, więc odpowiedź nie została wysłana. Jej" +" tekst zachowano na liście wiadomości. Aby kontynuować, wybierz w jej menu " +"'Edytuj niewysłaną odpowiedź bez cytatu'." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Nie udało się zapisać odpowiedzi na dysku. Jej tekst nadal jest na liście " +"wiadomości; skopiuj go przed zamknięciem WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "" +"Zakończ lub wyczyść bieżącą wiadomość przed przywróceniem tej odpowiedzi." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7219,6 +7246,12 @@ msgid "Some changes will only take effect after restarting WinZapp." msgstr "" "Niektóre zmiany zaczną obowiązywać dopiero po ponownym uruchomieniu WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Edytuj niewysłaną odpowiedź bez cytatu" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/pt-BR/LC_MESSAGES/winzapp.po b/translations/pt-BR/LC_MESSAGES/winzapp.po index ef491556e..3a6b337d9 100644 --- a/translations/pt-BR/LC_MESSAGES/winzapp.po +++ b/translations/pt-BR/LC_MESSAGES/winzapp.po @@ -7193,6 +7193,32 @@ msgid "" msgstr "" "Não foi possível citar a mensagem original; enviando como mensagem normal." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"A mensagem citada não foi encontrada, por isso sua resposta não foi enviada." +" Seu texto foi mantido na lista de mensagens. Escolha 'Editar resposta não " +"enviada sem a citação' no menu dela para continuar." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Não foi possível salvar a resposta no disco. O texto ainda está na lista de " +"mensagens; copie-o antes de fechar o WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "Conclua ou limpe a mensagem atual antes de recuperar esta resposta." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7226,6 +7252,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Algumas alterações só terão efeito após reiniciar o WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Editar resposta não enviada sem a citação" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/pt-PT/LC_MESSAGES/winzapp.po b/translations/pt-PT/LC_MESSAGES/winzapp.po index 29651a29c..d18e14f3f 100644 --- a/translations/pt-PT/LC_MESSAGES/winzapp.po +++ b/translations/pt-PT/LC_MESSAGES/winzapp.po @@ -7229,6 +7229,32 @@ msgid "" msgstr "" "Não foi possível citar a mensagem original; a enviar como mensagem normal." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"A mensagem citada não foi encontrada, pelo que a sua resposta não foi " +"enviada. O texto foi mantido na lista de mensagens. Escolha 'Editar resposta" +" não enviada sem a citação' no respetivo menu para continuar." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Não foi possível guardar a resposta no disco. O texto ainda está na lista de" +" mensagens; copie-o antes de fechar o WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "Conclua ou limpe a mensagem atual antes de recuperar esta resposta." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7262,6 +7288,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Algumas alterações só terão efeito depois de reiniciar o WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Editar resposta não enviada sem a citação" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/ro/LC_MESSAGES/winzapp.po b/translations/ro/LC_MESSAGES/winzapp.po index 0c59c1e27..e0ee695bb 100644 --- a/translations/ro/LC_MESSAGES/winzapp.po +++ b/translations/ro/LC_MESSAGES/winzapp.po @@ -7209,6 +7209,33 @@ msgid "" msgstr "" "Mesajul original nu a putut fi citat; mesajul va fi trimis ca unul obișnuit." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"Mesajul citat nu a fost găsit, deci răspunsul nu a fost trimis. Textul a " +"fost păstrat în lista de mesaje. Alegeți „Editați răspunsul netrimis fără " +"citat” din meniul acestuia pentru a continua." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Răspunsul nu a putut fi salvat pe disc. Textul este încă în lista de mesaje;" +" copiați-l înainte de a închide WinZapp." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "" +"Finalizați sau ștergeți mesajul curent înainte de a restaura acest răspuns." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7242,6 +7269,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "Unele modificări vor intra în vigoare numai după repornirea WinZapp." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Editați răspunsul netrimis fără citat" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/tr-TR/LC_MESSAGES/winzapp.po b/translations/tr-TR/LC_MESSAGES/winzapp.po index bd2f1b73d..4850f53ff 100644 --- a/translations/tr-TR/LC_MESSAGES/winzapp.po +++ b/translations/tr-TR/LC_MESSAGES/winzapp.po @@ -7138,6 +7138,34 @@ msgstr "" "Özgün mesaj alıntılanamadı; bunun yerine normal bir mesaj olarak " "gönderiliyor." +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" +"Alıntılanan mesaj bulunamadığından yanıtınız gönderilmedi. Metniniz mesaj " +"listesinde korundu. Devam etmek için menüsünden 'Gönderilmemiş yanıtı " +"alıntısız düzenle' seçeneğini seçin." + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" +"Yanıt diske kaydedilemedi. Metni hâlâ mesaj listesinde; WinZapp’ı kapatmadan" +" önce kopyalayın." + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "" +"Bu yanıtı geri almadan önce yazmakta olduğunuz mesajı tamamlayın veya " +"temizleyin." + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -7173,6 +7201,12 @@ msgstr "" "Bazı değişiklikler yalnızca WinZapp yeniden başlatıldıktan sonra geçerli " "olacaktır." +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "Gönderilmemiş yanıtı alıntısız düzenle" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording" diff --git a/translations/winzapp.pot b/translations/winzapp.pot index 5c1e1f489..14f503c02 100644 --- a/translations/winzapp.pot +++ b/translations/winzapp.pot @@ -6737,6 +6737,27 @@ msgid "" "Could not quote the original message; sending as a regular message instead." msgstr "" +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_missing_error" +msgid "" +"The quoted message was not found, so your reply was not sent. Your text is " +"kept in the message list. Choose 'Edit unsent reply without its quote' in " +"its menu to continue." +msgstr "" + +#: translations/en-US/LC_MESSAGES/winzapp.po client/main_window/sending.py +msgctxt "reply_quote_save_failed" +msgid "" +"The reply could not be saved to disk. Its text is still in the message list;" +" copy it before closing WinZapp." +msgstr "" + +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/text_sending.py +msgctxt "reply_restore_draft_busy" +msgid "Finish or clear your current message before restoring this reply." +msgstr "" + #: translations/en-US/LC_MESSAGES/winzapp.po #: client/ui/conversation_panel/message_menu.py #, python-brace-format @@ -6770,6 +6791,12 @@ msgctxt "restart_required_message" msgid "Some changes will only take effect after restarting WinZapp." msgstr "" +#: translations/en-US/LC_MESSAGES/winzapp.po +#: client/ui/conversation_panel/message_menu.py +msgctxt "restore_unsent_reply" +msgid "Edit unsent reply without its quote" +msgstr "" + #: translations/en-US/LC_MESSAGES/winzapp.po client/status_tab/status_voice.py #: client/ui/conversations.py msgctxt "resume_recording"