diff --git a/src/renderer/components/MessageStatusBadge.test.tsx b/src/renderer/components/MessageStatusBadge.test.tsx new file mode 100644 index 000000000..268a25afb --- /dev/null +++ b/src/renderer/components/MessageStatusBadge.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { axe } from 'vitest-axe'; + +import { MessageStatusBadge } from '@/renderer/components/MessageStatusBadge'; +import { hydrateAxeThemeColors } from '@/renderer/lib/a11yTestHelpers'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +async function renderAndAssertAxe(ui: ReactElement): Promise> { + const view = render(ui); + hydrateAxeThemeColors(view.container); + expect(await axe(view.container)).toHaveNoViolations(); + return view; +} + +describe('MessageStatusBadge', () => { + it.each(['tcp', 'http'] as const)( + 'uses WiFi mesh-ACK tooltip for failed device sends over %s', + async (connectionType) => { + await renderAndAssertAxe( + , + ); + expect( + screen.getByLabelText( + 'messageStatusBadge.tooltipPrefixDevice: messageStatusBadge.noAckTooltipWifi', + ), + ).toBeTruthy(); + expect(screen.getByText(/messageStatusBadge.transportWifi/)).toBeTruthy(); + expect(screen.getByText(/messageStatusBadge.noAck/)).toBeTruthy(); + }, + ); + + it('keeps the generic no-ACK tooltip for failed serial device sends', async () => { + await renderAndAssertAxe( + , + ); + expect( + screen.getByLabelText( + 'messageStatusBadge.tooltipPrefixDevice: messageStatusBadge.noAckTooltip', + ), + ).toBeTruthy(); + expect(screen.queryByLabelText(/noAckTooltipWifi/)).toBeNull(); + }); +}); diff --git a/src/renderer/components/MessageStatusBadge.tsx b/src/renderer/components/MessageStatusBadge.tsx index 5ce1fc664..7dd361e74 100644 --- a/src/renderer/components/MessageStatusBadge.tsx +++ b/src/renderer/components/MessageStatusBadge.tsx @@ -70,7 +70,9 @@ export function MessageStatusBadge({ status === 'failed' && context === 'room' ? (displayError ?? t('messageStatusBadge.failedPost')) : status === 'failed' && transport === 'device' - ? t('messageStatusBadge.noAckTooltip') + ? connectionType === 'http' || connectionType === 'tcp' + ? t('messageStatusBadge.noAckTooltipWifi') + : t('messageStatusBadge.noAckTooltip') : displayError || t('messageStatusBadge.failed'); const tooltipPrefix = transport === 'mqtt' diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts index 09d160ee1..e5d051310 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.test.ts @@ -186,6 +186,51 @@ describe('meshtasticSdkRoutingErrorLog', () => { expect(updateMessageStatus).toHaveBeenCalled(); }); + it('does not fall back unmatched TIMEOUT onto a sole sending outbound', () => { + seedOutbound([{ packetId: 55, timestamp: Date.now() }]); + const applied = applyMeshtasticOutboundRoutingErrorFromLog( + 'Packet 41841545 of type packet timed out', + { myNodeNum: 42, identityId: IDENTITY }, + ); + expect(applied).toBe(false); + expect(updateMessageStatus).not.toHaveBeenCalled(); + }); + + it('does not fall back unmatched MAX_RETRANSMIT onto a sole sending outbound', () => { + seedOutbound([{ packetId: 55, timestamp: Date.now() }]); + const applied = applyMeshtasticOutboundRoutingErrorFromLog( + 'Error received for packet 985918657: MAX_RETRANSMIT', + { myNodeNum: 42, identityId: IDENTITY }, + ); + expect(applied).toBe(false); + expect(updateMessageStatus).not.toHaveBeenCalled(); + }); + + it('does not fall back unmatched NO_RESPONSE onto a sole sending outbound', () => { + seedOutbound([{ packetId: 55, timestamp: Date.now() }]); + const applied = applyMeshtasticOutboundRoutingErrorFromLog( + 'Error received for packet 424242: NO_RESPONSE', + { myNodeNum: 42, identityId: IDENTITY }, + ); + expect(applied).toBe(false); + expect(updateMessageStatus).not.toHaveBeenCalled(); + }); + + it('still exact-matches MAX_RETRANSMIT when the wire id matches the outbound row', () => { + seedOutbound([{ packetId: 985918657, timestamp: Date.now() }]); + const applied = applyMeshtasticOutboundRoutingErrorFromLog( + 'Error received for packet 985918657: MAX_RETRANSMIT', + { myNodeNum: 42, identityId: IDENTITY }, + ); + expect(applied).toBe(true); + expect(updateMessageStatus).toHaveBeenCalledWith( + IDENTITY, + '985918657', + 'failed', + 'chatPanel.routingErrors.timeout', + ); + }); + it('does not apply when no outbound rows exist', () => { seedOutbound([]); const applied = applyMeshtasticOutboundRoutingErrorFromLog( diff --git a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts index 17bce8a3c..71b240fb9 100644 --- a/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts +++ b/src/renderer/lib/meshtastic/meshtasticSdkRoutingErrorLog.ts @@ -154,9 +154,15 @@ function storeOutboundMessagesAsChat(identityId: string, myNodeNum: number): Cha return messageRecordsToChatMessages(records); } +/** Errors that are unsafe to attribute via the single-sending fallback. */ +function shouldAvoidSendingFallback(errorName: string): boolean { + return errorName === 'TIMEOUT' || errorName === 'NO_RESPONSE' || errorName === 'MAX_RETRANSMIT'; +} + function findOutboundTargetForWirePacketId( wirePacketId: number, ctx: ApplyMeshtasticOutboundRoutingErrorContext, + errorName: string, ): ChatMessage | undefined { const { myNodeNum, identityId, tempIdToWirePacketId } = ctx; if (!identityId) return undefined; @@ -171,6 +177,14 @@ function findOutboundTargetForWirePacketId( // share-location Waypoint) — do not misattribute its NAK to a pending chat row. if (hasMeshtasticNonChatOutboundInFlight()) return undefined; + // Unmatched TIMEOUT / MAX_RETRANSMIT / NO_RESPONSE must not steal the sole + // in-flight chat row (leftover queue TIMEOUTs were marking unrelated broadcasts + // failed). TransportManager still fails the matching sendText by tempId when + // the NAK belongs to that send. + if (shouldAvoidSendingFallback(errorName)) { + return undefined; + } + return findFallbackSendingOutbound(messages, myNodeNum); } @@ -196,7 +210,7 @@ export function applyMeshtasticOutboundRoutingError( if (isMeshtasticNonChatWirePacketId(parsed.packetId)) { return false; } - const target = findOutboundTargetForWirePacketId(parsed.packetId, ctx); + const target = findOutboundTargetForWirePacketId(parsed.packetId, ctx, parsed.errorName); if (!target || !identityId) { return false; } diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 1981236d3..a02e14f14 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -2215,7 +2215,8 @@ "noAckTooltip": "Žádný ACK (zpráva mohla být stále vysílána; žádný jiný uzel v dosahu k potvrzení)", "failed": "Nezdařilo se", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Zařízení" + "tooltipPrefixDevice": "Zařízení", + "noAckTooltipWifi": "No mesh ACK — vaše WiFi/TCP spojení s rádiem je v pořádku. Zpráva mohla být stále odeslána; žádný jiný uzel LoRa nebyl potvrzen." }, "modulePanel": { "sectionSent": "{{name}} odesláno. Zařízení se může krátce restartovat.", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 294afc575..216cbdf8b 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "Kein ACK (Nachricht darf noch gesendet worden sein; kein anderer Knoten in Reichweite zu quittieren)", "failed": "Nicht bestanden", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Vorrichtung" + "tooltipPrefixDevice": "Vorrichtung", + "noAckTooltipWifi": "Kein Mesh-ACK — Ihre WiFi/TCP-Verbindung zum Radio ist in Ordnung. Die Nachricht wurde möglicherweise noch gesendet; kein anderer LoRa-Knoten wurde bestätigt." }, "modulePanel": { "sectionSent": "{{name}} gesendet. Das Gerät kann kurzzeitig neu gestartet werden.", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 646653625..396e4d726 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -2382,6 +2382,7 @@ "posted": "Posted", "failedPost": "Failed to post", "noAckTooltip": "No ACK (message may still have been broadcast; no other node in range to acknowledge)", + "noAckTooltipWifi": "No mesh ACK — your WiFi/TCP link to the radio is fine. Message may still have been sent; no other LoRa node acknowledged.", "failed": "Failed", "tooltipPrefixMqtt": "MQTT", "tooltipPrefixDevice": "Device" diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 65b366321..c356f2827 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "Sin ACK (es posible que el mensaje aún se haya transmitido; no hay otro nodo en el rango para reconocer)", "failed": "Error", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Dispositivo" + "tooltipPrefixDevice": "Dispositivo", + "noAckTooltipWifi": "Sin ACK de malla: su enlace WiFi/TCP a la radio está bien. Es posible que aún se haya enviado el mensaje; no se ha reconocido ningún otro nodo LoRa." }, "modulePanel": { "sectionSent": "{{name}} enviado. El dispositivo puede reiniciarse brevemente.", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 220a1a3dd..52311a836 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "Aucun ACK (le message peut encore avoir été diffusé ; aucun autre nœud à portée pour accuser réception)", "failed": "Échec", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Dispositif" + "tooltipPrefixDevice": "Dispositif", + "noAckTooltipWifi": "Pas d'ACK maillé — votre connexion WiFi/TCP à la radio est correcte. Le message peut encore avoir été envoyé ; aucun autre nœud LoRa n'a été accusé de réception." }, "modulePanel": { "sectionSent": "{{name}} envoyé. L'appareil peut redémarrer brièvement.", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index a25cfe8dd..6bb292c78 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "Tidak ada ack (pesan mungkin masih disiarkan; tidak ada simpul lain dalam jangkauan untuk dikenali)", "failed": "Gagal", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Perangkat" + "tooltipPrefixDevice": "Perangkat", + "noAckTooltipWifi": "Tidak ada ack mesh — tautan WiFi/TCP Anda ke radio baik - baik saja. Pesan mungkin masih telah dikirim; tidak ada simpul LoRa lain yang diakui." }, "modulePanel": { "sectionSent": "{{name}} terkirim. Perangkat mungkin akan reboot sebentar.", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 074b120d6..a47bdee2b 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "Nessun ACK (il messaggio potrebbe essere ancora stato trasmesso; nessun altro nodo nel raggio d'azione da riconoscere)", "failed": "Non riuscito", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Dispositivo" + "tooltipPrefixDevice": "Dispositivo", + "noAckTooltipWifi": "Nessun ACK mesh: il tuo collegamento WiFi/TCP alla radio va bene. Il messaggio potrebbe essere stato ancora inviato; nessun altro nodo LoRa riconosciuto." }, "modulePanel": { "sectionSent": "{{name}} inviato. Il dispositivo potrebbe riavviarsi brevemente.", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index addd0afaf..387dcf288 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "ACKなし(メッセージはまだブロードキャストされている可能性があります。確認応答する範囲内の他のノードはありません)", "failed": "失敗しました", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "デバイス" + "tooltipPrefixDevice": "デバイス", + "noAckTooltipWifi": "メッシュACKなし—無線へのWi - Fi/TCPリンクは問題ありません。メッセージはまだ送信されている可能性があります。他のLoRaノードは確認応答していません。" }, "modulePanel": { "sectionSent": "{{name}} が送信されました。デバイスが短時間再起動する場合があります。", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 87bc5683d..639eaba16 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "ACK 없음 (메시지는 여전히 브로드캐스트되었을 수 있으며, 확인할 수 있는 범위에 있는 다른 노드는 없음)", "failed": "실패함", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "장치" + "tooltipPrefixDevice": "장치", + "noAckTooltipWifi": "메시 ACK 없음 — 라디오에 대한 WiFi/TCP 링크는 괜찮습니다. 메시지가 여전히 전송되었을 수 있습니다. 다른 LoRa 노드가 확인되지 않았습니다." }, "modulePanel": { "sectionSent": "{{name}} 보냈습니다. 장치가 잠시 재부팅될 수 있습니다.", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 51457d7a2..21d488325 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "Geen ACK (bericht kan nog steeds zijn uitgezonden; geen ander knooppunt binnen bereik om te bevestigen)", "failed": "Mislukt", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Toestel" + "tooltipPrefixDevice": "Toestel", + "noAckTooltipWifi": "Geen mesh ACK — uw WiFi/TCP-link naar de radio is prima. Mogelijk is er nog steeds een bericht verzonden; er is geen ander LoRa-knooppunt bevestigd." }, "modulePanel": { "sectionSent": "{{name}} verzonden. Het apparaat kan kortstondig opnieuw opstarten.", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 2ae63ec88..854b0e704 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -2217,7 +2217,8 @@ "noAckTooltip": "Brak ACK (komunikat mógł nadal zostać wyemitowany; brak innego węzła w zasięgu do potwierdzenia)", "failed": "Zakończone niepowodzeniem", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Urządzenie" + "tooltipPrefixDevice": "Urządzenie", + "noAckTooltipWifi": "Brak ACK siatki — Twoje łącze WiFi/TCP do radia jest w porządku. Wiadomość mogła nadal zostać wysłana; żaden inny węzeł LoRa nie został potwierdzony." }, "modulePanel": { "sectionSent": "Wysłano: {{name}}. Urządzenie może się na chwilę zrestartować.", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index e02c4a7ce..3b6a2edc4 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "Sem ACK (a mensagem ainda pode ter sido transmitida; nenhum outro nó no intervalo para confirmar)", "failed": "Falha", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Dispositivo" + "tooltipPrefixDevice": "Dispositivo", + "noAckTooltipWifi": "Sem mesh ACK — seu link WiFi/TCP para o rádio está bom. A mensagem ainda pode ter sido enviada; nenhum outro nó LoRa foi reconhecido." }, "modulePanel": { "sectionSent": "{{name}} enviado. O dispositivo pode reiniciar brevemente.", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 8018a0c8c..f8505f502 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -2215,7 +2215,8 @@ "noAckTooltip": "Нет ACK (сообщение, возможно, все еще было передано; нет другого узла в диапазоне для подтверждения)", "failed": "Не удалось", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Устройство" + "tooltipPrefixDevice": "Устройство", + "noAckTooltipWifi": "Нет Mesh ACK — ваша связь WiFi/TCP с радиостанцией в порядке. Сообщение, возможно, все еще было отправлено; ни один другой узел LoRa не подтвержден." }, "modulePanel": { "sectionSent": "{{name}} отправлено. Устройство может ненадолго перезагрузиться.", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index a190d4dd1..bd13c047f 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "ACK yok (mesaj hala yayınlanmış olabilir; onaylanacak başka bir düğüm yok)", "failed": "Başarısız", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Cihaz" + "tooltipPrefixDevice": "Cihaz", + "noAckTooltipWifi": "Ağ ACK'si yok; telsize WiFi/TCP bağlantınız iyi durumda. Mesaj hala gönderilmiş olabilir; başka hiçbir LoRa düğümü onaylanmadı." }, "modulePanel": { "sectionSent": "{{name}} gönderildi. Cihaz kısa süreliğine yeniden başlatılabilir.", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 24ffd8875..d5d540ed9 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -2215,7 +2215,8 @@ "noAckTooltip": "Немає ACK (повідомлення, можливо, все ще транслювалося; немає іншого вузла в діапазоні для підтвердження)", "failed": "Помилка", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "Пристрій" + "tooltipPrefixDevice": "Пристрій", + "noAckTooltipWifi": "Без Mesh ACK — ваше посилання WiFi/TCP на радіо в порядку. Можливо, повідомлення все ще було надіслано; жоден інший вузол LoRa не підтверджено." }, "modulePanel": { "sectionSent": "{{name}} надіслано. Пристрій може ненадовго перезавантажитися.", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 746f27799..8a7347e72 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -2213,7 +2213,8 @@ "noAckTooltip": "无确认(消息可能仍已广播;范围内没有其他节点需要确认)", "failed": "失败", "tooltipPrefixMqtt": "MQTT", - "tooltipPrefixDevice": "设备" + "tooltipPrefixDevice": "设备", + "noAckTooltipWifi": "无网状确认—您可以使用无线网络/TCP连接到收音机。消息可能仍已发送;未确认其他LoRa节点。" }, "modulePanel": { "sectionSent": "{{name}}已发送。设备可能会短暂重新启动。",