diff --git a/apps/web/public/i18n/locales/be-BY/messages.json b/apps/web/public/i18n/locales/be-BY/messages.json index 3b7dbeb48..cd0993953 100644 --- a/apps/web/public/i18n/locales/be-BY/messages.json +++ b/apps/web/public/i18n/locales/be-BY/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Reply" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/bg-BG/messages.json b/apps/web/public/i18n/locales/bg-BG/messages.json index e9189926c..4c1570da7 100644 --- a/apps/web/public/i18n/locales/bg-BG/messages.json +++ b/apps/web/public/i18n/locales/bg-BG/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Отговор" }, "deliveryStatus": { - "delivered": { - "label": "Съобщението е доставено", - "displayText": "Съобщението е доставено" + "retry": "Повторен опит", + "retryAriaLabel": "Повторен опит за съобщение: {{status}}", + "deliveredToMesh": { + "displayText": "Доставено до mesh мрежата", + "detailText": "Поне един възел получи това съобщение в канала." }, - "failed": { - "label": "Доставката на съобщението не е успешна", - "displayText": "Неуспешна доставка" + "deliveredToRecipient": { + "displayText": "Доставено до получателя", + "detailText": "Получателят потвърди това директно съобщение." }, - "unknown": { - "label": "Статусът на съобщението е неизвестен", - "displayText": "Неизвестно състояние" + "encryptedSendFailed": { + "displayText": "Шифрованото съобщение не можа да бъде изпратено", + "detailText": "Радиото не можа да създаде шифрован път за това съобщение. Изчакайте информацията за възлите да се синхронизира, след което опитайте отново." + }, + "adminKeyNotAuthorized": { + "displayText": "Администраторският ключ не е упълномощен", + "detailText": "Публичният администраторски ключ не е упълномощен на получателя." + }, + "adminSessionExpired": { + "displayText": "Администраторската сесия е изтекла", + "detailText": "Ключът за администраторска сесия липсва, невалиден е или е изтекъл. Започнете нова администраторска сесия и опитайте отново." + }, + "dutyCycleLimit": { + "displayText": "Ограничение на работния цикъл", + "detailText": "Радиото е временно блокирано от регионалното ограничение за ефирно време. Опитайте отново по-късно." + }, + "failedToMesh": { + "displayText": "Неуспешна доставка до mesh мрежата", + "detailText": "Нито един възел не потвърди това съобщение. Опитайте отново при по-добър сигнал или по-добро mesh покритие." + }, + "invalidRequest": { + "displayText": "Невалидна заявка", + "detailText": "Получаващото приложение отхвърли заявката. Проверете съобщението или командата и опитайте отново." + }, + "messageTooLarge": { + "displayText": "Съобщението е твърде голямо за изпращане", + "detailText": "Съкратете съобщението и го изпратете отново." + }, + "noAppResponse": { + "displayText": "Няма отговор от приложението", + "detailText": "Получателят получи заявката, но нито едно приложение или модул не отговори." + }, + "noChannel": { + "displayText": "Несъответствие на канал/ключ", + "detailText": "Това съобщение не можа да бъде кодирано или декодирано със съвпадащ канал/ключ. Проверете канала и ключа, след което опитайте отново." }, - "waiting": { - "label": "Изпращане на съобщение", - "displayText": "Чака доставка" + "noRadioInterface": { + "displayText": "Няма радио интерфейс", + "detailText": "Радиото няма използваем интерфейс за това изпращане." + }, + "notAuthorized": { + "displayText": "Не е упълномощено", + "detailText": "Получателят отказа разрешение за тази заявка." + }, + "rateLimited": { + "displayText": "Ограничена честота", + "detailText": "Съобщенията се изпращат твърде бързо. Изчакайте малко и опитайте отново." + }, + "recipientKeyUnavailable": { + "displayText": "Ключът на получателя не е наличен", + "detailText": "Вашият възел все още няма публичния ключ на получателя. Изчакайте информацията за възлите да се синхронизира, след което опитайте отново." + }, + "recipientNeedsYourKey": { + "displayText": "Получателят се нуждае от вашия ключ", + "detailText": "Получателят все още не знае вашия публичен ключ. Вашият възел може да сподели информацията си автоматично; опитайте отново след синхронизиране." + }, + "relayed": { + "displayText": "Препредадено, но не е потвърдено от получателя", + "detailText": "Възел препредаде това съобщение, но получателят не го е потвърдил." + }, + "sending": { + "displayText": "Изпращане...", + "detailText": "Радиото все още се опитва да изпрати това съобщение." + }, + "unknown": { + "displayText": "Статусът на съобщението е неизвестен", + "detailText": "Статусът на съобщението не е наличен." } } } diff --git a/apps/web/public/i18n/locales/cs-CZ/messages.json b/apps/web/public/i18n/locales/cs-CZ/messages.json index e4ddb17d0..93bd8fc80 100644 --- a/apps/web/public/i18n/locales/cs-CZ/messages.json +++ b/apps/web/public/i18n/locales/cs-CZ/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Reply" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/de-DE/messages.json b/apps/web/public/i18n/locales/de-DE/messages.json index 02f1435cc..53b195d92 100644 --- a/apps/web/public/i18n/locales/de-DE/messages.json +++ b/apps/web/public/i18n/locales/de-DE/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Antworten" }, "deliveryStatus": { - "delivered": { - "label": "Nachricht zugestellt", - "displayText": "Nachricht zugestellt" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Nachrichtenübermittlung fehlgeschlagen", - "displayText": "Zustellung fehlgeschlagen" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Nachrichtenstatus unbekannt", - "displayText": "Unbekannter Status" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Nachricht wird gesendet", - "displayText": "Warte auf Zustellung" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/en/messages.json b/apps/web/public/i18n/locales/en/messages.json index 07d60e570..503463ac5 100644 --- a/apps/web/public/i18n/locales/en/messages.json +++ b/apps/web/public/i18n/locales/en/messages.json @@ -20,21 +20,83 @@ }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/es-ES/messages.json b/apps/web/public/i18n/locales/es-ES/messages.json index c4c622f18..a8c669dbc 100644 --- a/apps/web/public/i18n/locales/es-ES/messages.json +++ b/apps/web/public/i18n/locales/es-ES/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Respuesta" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/fi-FI/messages.json b/apps/web/public/i18n/locales/fi-FI/messages.json index 7fc4247fb..8640753da 100644 --- a/apps/web/public/i18n/locales/fi-FI/messages.json +++ b/apps/web/public/i18n/locales/fi-FI/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Vastaa" }, "deliveryStatus": { - "delivered": { - "label": "Viesti toimitettu", - "displayText": "Viesti toimitettu" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Viestin toimitus epäonnistui", - "displayText": "Toimitus epäonnistui" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Viestin tila tuntematon", - "displayText": "Tuntematon tila" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Lähetetään viestiä", - "displayText": "Odottaa toimitusta" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/fr-FR/messages.json b/apps/web/public/i18n/locales/fr-FR/messages.json index 4071ee4e1..7f521e356 100644 --- a/apps/web/public/i18n/locales/fr-FR/messages.json +++ b/apps/web/public/i18n/locales/fr-FR/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Répondre" }, "deliveryStatus": { - "delivered": { - "label": "Message délivré", - "displayText": "Message délivré" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "La transmission du message a échoué", - "displayText": "Échec de l'envoi" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Statut du message inconnu", - "displayText": "Statut inconnu" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Envoi du message", - "displayText": "En attente de réception" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/hu-HU/messages.json b/apps/web/public/i18n/locales/hu-HU/messages.json index f1d65c73e..a952ac9f2 100644 --- a/apps/web/public/i18n/locales/hu-HU/messages.json +++ b/apps/web/public/i18n/locales/hu-HU/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Válasz" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/it-IT/messages.json b/apps/web/public/i18n/locales/it-IT/messages.json index 7a8cd6bd4..012ee682c 100644 --- a/apps/web/public/i18n/locales/it-IT/messages.json +++ b/apps/web/public/i18n/locales/it-IT/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Rispondi" }, "deliveryStatus": { - "delivered": { - "label": "Messaggio inviato", - "displayText": "Messaggio inviato" + "retry": "Riprova", + "retryAriaLabel": "Riprova messaggio: {{status}}", + "deliveredToMesh": { + "displayText": "Consegnato alla rete mesh", + "detailText": "Almeno un nodo ha ricevuto questo messaggio del canale." }, - "failed": { - "label": "Invio messaggio non riuscito", - "displayText": "Invio fallito" + "deliveredToRecipient": { + "displayText": "Consegnato al destinatario", + "detailText": "Il destinatario ha confermato questo messaggio diretto." }, - "unknown": { - "label": "Stato messaggio sconosciuto", - "displayText": "Stato sconosciuto" + "encryptedSendFailed": { + "displayText": "Impossibile inviare il messaggio crittografato", + "detailText": "La radio non è riuscita a creare il percorso crittografato per questo messaggio. Attendi la sincronizzazione delle informazioni dei nodi, poi riprova." + }, + "adminKeyNotAuthorized": { + "displayText": "Chiave admin non autorizzata", + "detailText": "La chiave pubblica admin non è autorizzata sulla destinazione." + }, + "adminSessionExpired": { + "displayText": "Sessione admin scaduta", + "detailText": "La chiave della sessione admin è mancante, non valida o scaduta. Avvia una nuova sessione admin, poi riprova." + }, + "dutyCycleLimit": { + "displayText": "Limite del duty cycle", + "detailText": "La radio è temporaneamente bloccata dal limite regionale di tempo in trasmissione. Riprova più tardi." + }, + "failedToMesh": { + "displayText": "Consegna alla rete mesh non riuscita", + "detailText": "Nessun nodo ha confermato questo messaggio. Riprova quando hai un segnale migliore o una copertura mesh più ampia." + }, + "invalidRequest": { + "displayText": "Richiesta non valida", + "detailText": "L'app ricevente ha rifiutato la richiesta. Controlla il messaggio o il comando e riprova." + }, + "messageTooLarge": { + "displayText": "Messaggio troppo grande da inviare", + "detailText": "Accorcia il messaggio e invialo di nuovo." + }, + "noAppResponse": { + "displayText": "Nessuna risposta dall'app", + "detailText": "La destinazione ha ricevuto la richiesta, ma nessuna app o modulo ha risposto." + }, + "noChannel": { + "displayText": "Mancata corrispondenza canale/chiave", + "detailText": "Questo messaggio non può essere codificato o decodificato con un canale/chiave corrispondente. Controlla il canale e la chiave, poi riprova." }, - "waiting": { - "label": "Inviando il messaggio...", - "displayText": "In attesa della consegna" + "noRadioInterface": { + "displayText": "Nessuna interfaccia radio", + "detailText": "La radio non ha un'interfaccia utilizzabile per questo invio." + }, + "notAuthorized": { + "displayText": "Non autorizzato", + "detailText": "La destinazione ha rifiutato il permesso per questa richiesta." + }, + "rateLimited": { + "displayText": "Limite di frequenza raggiunto", + "detailText": "I messaggi vengono inviati troppo rapidamente. Attendi un momento, poi riprova." + }, + "recipientKeyUnavailable": { + "displayText": "Chiave del destinatario non disponibile", + "detailText": "Il tuo nodo non ha ancora la chiave pubblica del destinatario. Attendi la sincronizzazione delle informazioni dei nodi, poi riprova." + }, + "recipientNeedsYourKey": { + "displayText": "Il destinatario ha bisogno della tua chiave", + "detailText": "Il destinatario non conosce ancora la tua chiave pubblica. Il tuo nodo potrebbe condividere automaticamente le sue informazioni; riprova dopo la sincronizzazione." + }, + "relayed": { + "displayText": "Inoltrato, non confermato dal destinatario", + "detailText": "Un nodo ha inoltrato questo messaggio, ma il destinatario non lo ha confermato." + }, + "sending": { + "displayText": "Invio in corso...", + "detailText": "La radio sta ancora tentando di inviare questo messaggio." + }, + "unknown": { + "displayText": "Stato del messaggio sconosciuto", + "detailText": "Lo stato del messaggio non è disponibile." } } } diff --git a/apps/web/public/i18n/locales/ja-JP/messages.json b/apps/web/public/i18n/locales/ja-JP/messages.json index 62ae8df70..739a93947 100644 --- a/apps/web/public/i18n/locales/ja-JP/messages.json +++ b/apps/web/public/i18n/locales/ja-JP/messages.json @@ -19,21 +19,83 @@ "replyLabel": "返信" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/ko-KR/messages.json b/apps/web/public/i18n/locales/ko-KR/messages.json index 1d220b74e..bd4c8f755 100644 --- a/apps/web/public/i18n/locales/ko-KR/messages.json +++ b/apps/web/public/i18n/locales/ko-KR/messages.json @@ -19,21 +19,83 @@ "replyLabel": "답장" }, "deliveryStatus": { - "delivered": { - "label": "메시지 전송", - "displayText": "메시지 전송됨" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "메시지 전송 실패", - "displayText": "전송 실패" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "메시지 상태 알 수 없음", - "displayText": "알 수 없는 상태" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "메시지 전송 중", - "displayText": "전송 대기 중" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/nl-NL/messages.json b/apps/web/public/i18n/locales/nl-NL/messages.json index 2572ee9ec..6aaf43be1 100644 --- a/apps/web/public/i18n/locales/nl-NL/messages.json +++ b/apps/web/public/i18n/locales/nl-NL/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Reply" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Opnieuw proberen", + "retryAriaLabel": "Bericht opnieuw proberen: {{status}}", + "deliveredToMesh": { + "displayText": "Afgeleverd aan mesh-netwerk", + "detailText": "Minstens één node heeft dit kanaalbericht ontvangen." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Afgeleverd bij ontvanger", + "detailText": "De ontvanger heeft dit directe bericht bevestigd." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Versleuteld bericht kon niet worden verzonden", + "detailText": "De radio kon het versleutelde pad voor dit bericht niet maken. Laat node-informatie synchroniseren en probeer het daarna opnieuw." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin-sleutel niet geautoriseerd", + "detailText": "De openbare admin-sleutel is niet geautoriseerd op de bestemming." + }, + "adminSessionExpired": { + "displayText": "Admin-sessie verlopen", + "detailText": "De admin-sessiesleutel ontbreekt, is ongeldig of is verlopen. Start een nieuwe admin-sessie en probeer het opnieuw." + }, + "dutyCycleLimit": { + "displayText": "Duty-cyclelimiet", + "detailText": "De radio is tijdelijk geblokkeerd door de regionale zendtijdlimiet. Probeer het later opnieuw." + }, + "failedToMesh": { + "displayText": "Afleveren aan mesh-netwerk mislukt", + "detailText": "Geen enkele node heeft dit bericht bevestigd. Probeer het opnieuw bij beter signaal of betere mesh-dekking." + }, + "invalidRequest": { + "displayText": "Ongeldig verzoek", + "detailText": "De ontvangende app heeft het verzoek geweigerd. Controleer het bericht of commando en probeer het opnieuw." + }, + "messageTooLarge": { + "displayText": "Bericht is te groot om te verzenden", + "detailText": "Maak het bericht korter en verzend het opnieuw." + }, + "noAppResponse": { + "displayText": "Geen app-reactie", + "detailText": "De bestemming heeft het verzoek ontvangen, maar er heeft geen app of module gereageerd." + }, + "noChannel": { + "displayText": "Kanaal/sleutel komt niet overeen", + "detailText": "Dit bericht kon niet worden gecodeerd of gedecodeerd met een overeenkomend kanaal/sleutel. Controleer het kanaal en de sleutel en probeer het opnieuw." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "Geen radio-interface", + "detailText": "De radio heeft geen bruikbare interface voor deze verzending." + }, + "notAuthorized": { + "displayText": "Niet geautoriseerd", + "detailText": "De bestemming heeft toestemming voor dit verzoek geweigerd." + }, + "rateLimited": { + "displayText": "Snelheidslimiet bereikt", + "detailText": "Berichten worden te snel verzonden. Wacht even en probeer het opnieuw." + }, + "recipientKeyUnavailable": { + "displayText": "Ontvangerssleutel niet beschikbaar", + "detailText": "Je node heeft de openbare sleutel van de ontvanger nog niet. Laat node-informatie synchroniseren en probeer het daarna opnieuw." + }, + "recipientNeedsYourKey": { + "displayText": "Ontvanger heeft jouw sleutel nodig", + "detailText": "De ontvanger kent jouw openbare sleutel nog niet. Je node kan zijn informatie automatisch delen; probeer het opnieuw na synchronisatie." + }, + "relayed": { + "displayText": "Doorgestuurd, niet bevestigd door ontvanger", + "detailText": "Een node heeft dit bericht doorgestuurd, maar de ontvanger heeft het niet bevestigd." + }, + "sending": { + "displayText": "Verzenden...", + "detailText": "De radio probeert dit bericht nog steeds te verzenden." + }, + "unknown": { + "displayText": "Berichtstatus onbekend", + "detailText": "De berichtstatus is niet beschikbaar." } } } diff --git a/apps/web/public/i18n/locales/pl-PL/messages.json b/apps/web/public/i18n/locales/pl-PL/messages.json index 1bc5c2fbc..4730d9374 100644 --- a/apps/web/public/i18n/locales/pl-PL/messages.json +++ b/apps/web/public/i18n/locales/pl-PL/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Odpowiedz" }, "deliveryStatus": { - "delivered": { - "label": "Wiadomość doręczona", - "displayText": "Wiadomość doręczona" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Nie udało się dostarczyć wiadomości", - "displayText": "Dostawa nie powiodła się" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Status wiadomości nieznany", - "displayText": "Nieznany stan" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Wysyłanie wiadomości", - "displayText": "Oczekiwanie na dostawę" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/pt-BR/messages.json b/apps/web/public/i18n/locales/pt-BR/messages.json index cd7260a8d..3b870a96a 100644 --- a/apps/web/public/i18n/locales/pt-BR/messages.json +++ b/apps/web/public/i18n/locales/pt-BR/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Responder" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/pt-PT/messages.json b/apps/web/public/i18n/locales/pt-PT/messages.json index cd7260a8d..3b870a96a 100644 --- a/apps/web/public/i18n/locales/pt-PT/messages.json +++ b/apps/web/public/i18n/locales/pt-PT/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Responder" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/ru-RU/messages.json b/apps/web/public/i18n/locales/ru-RU/messages.json index 6a7f08eb5..d954711be 100644 --- a/apps/web/public/i18n/locales/ru-RU/messages.json +++ b/apps/web/public/i18n/locales/ru-RU/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Ответить" }, "deliveryStatus": { - "delivered": { - "label": "Сообщение доставлено", - "displayText": "Сообщение доставлено" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Не удалось доставить сообщение", - "displayText": "Доставка не удалась" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Статус сообщения неизвестен", - "displayText": "Неизвестный статус" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Отправка сообщения", - "displayText": "Ожидание доставки" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/sv-SE/messages.json b/apps/web/public/i18n/locales/sv-SE/messages.json index 203b600f6..80b7a41e0 100644 --- a/apps/web/public/i18n/locales/sv-SE/messages.json +++ b/apps/web/public/i18n/locales/sv-SE/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Svara" }, "deliveryStatus": { - "delivered": { - "label": "Meddelandet har levererats", - "displayText": "Meddelandet har levererats" + "retry": "Försök igen", + "retryAriaLabel": "Försök skicka meddelandet igen: {{status}}", + "deliveredToMesh": { + "displayText": "Levererat till meshnätet", + "detailText": "Minst en nod hörde detta kanalmeddelande." }, - "failed": { - "label": "Meddelandeleverans misslyckades", - "displayText": "Leverans misslyckades" + "deliveredToRecipient": { + "displayText": "Levererat till mottagaren", + "detailText": "Mottagaren bekräftade detta direktmeddelande." }, - "unknown": { - "label": "Okänd meddelandestatus", - "displayText": "Okänd status" + "encryptedSendFailed": { + "displayText": "Kunde inte skicka krypterat meddelande", + "detailText": "Radion kunde inte skapa den krypterade vägen för detta meddelande. Låt nodinformationen synkroniseras och försök sedan igen." + }, + "adminKeyNotAuthorized": { + "displayText": "Administratörsnyckeln är inte auktoriserad", + "detailText": "Den offentliga administratörsnyckeln är inte auktoriserad på destinationen." + }, + "adminSessionExpired": { + "displayText": "Administratörssessionen har gått ut", + "detailText": "Administratörssessionsnyckeln saknas, är ogiltig eller har gått ut. Starta en ny administratörssession och försök igen." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle-gräns", + "detailText": "Radion är tillfälligt blockerad av den regionala sändningstidsgränsen. Försök igen senare." + }, + "failedToMesh": { + "displayText": "Misslyckades att leverera till meshnätet", + "detailText": "Ingen nod bekräftade detta meddelande. Försök igen när du har bättre signal eller bättre meshtäckning." + }, + "invalidRequest": { + "displayText": "Ogiltig begäran", + "detailText": "Den mottagande appen avvisade begäran. Kontrollera meddelandet eller kommandot och försök igen." + }, + "messageTooLarge": { + "displayText": "Meddelandet är för stort för att skickas", + "detailText": "Korta meddelandet och skicka det igen." + }, + "noAppResponse": { + "displayText": "Inget appsvar", + "detailText": "Destinationen tog emot begäran, men ingen app eller modul svarade." + }, + "noChannel": { + "displayText": "Kanal/nyckel matchar inte", + "detailText": "Detta meddelande kunde inte kodas eller avkodas med en matchande kanal/nyckel. Kontrollera kanalen och nyckeln och försök igen." }, - "waiting": { - "label": "Skickar meddelande", - "displayText": "Väntar på leverans" + "noRadioInterface": { + "displayText": "Inget radiogränssnitt", + "detailText": "Radion har inget användbart gränssnitt för denna sändning." + }, + "notAuthorized": { + "displayText": "Inte auktoriserad", + "detailText": "Destinationen nekade behörighet för denna begäran." + }, + "rateLimited": { + "displayText": "Hastighetsbegränsad", + "detailText": "Meddelanden skickas för snabbt. Vänta en stund och försök igen." + }, + "recipientKeyUnavailable": { + "displayText": "Mottagarens nyckel är inte tillgänglig", + "detailText": "Din nod har inte mottagarens offentliga nyckel ännu. Låt nodinformationen synkroniseras och försök sedan igen." + }, + "recipientNeedsYourKey": { + "displayText": "Mottagaren behöver din nyckel", + "detailText": "Mottagaren känner inte till din offentliga nyckel ännu. Din nod kan dela sin information automatiskt; försök igen efter synkronisering." + }, + "relayed": { + "displayText": "Vidarebefordrat, inte bekräftat av mottagaren", + "detailText": "En nod vidarebefordrade detta meddelande, men mottagaren har inte bekräftat det." + }, + "sending": { + "displayText": "Skickar...", + "detailText": "Radion försöker fortfarande skicka detta meddelande." + }, + "unknown": { + "displayText": "Meddelandestatus okänd", + "detailText": "Meddelandestatusen är inte tillgänglig." } } } diff --git a/apps/web/public/i18n/locales/tr-TR/messages.json b/apps/web/public/i18n/locales/tr-TR/messages.json index e2420ea55..9b22d0e77 100644 --- a/apps/web/public/i18n/locales/tr-TR/messages.json +++ b/apps/web/public/i18n/locales/tr-TR/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Yanıtla" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/uk-UA/messages.json b/apps/web/public/i18n/locales/uk-UA/messages.json index 0500adb8b..71f6a4994 100644 --- a/apps/web/public/i18n/locales/uk-UA/messages.json +++ b/apps/web/public/i18n/locales/uk-UA/messages.json @@ -19,21 +19,83 @@ "replyLabel": "Відповісти" }, "deliveryStatus": { - "delivered": { - "label": "Повідомлення доставлено", - "displayText": "Повідомлення доставлено" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Помилка доставлення повідомлення", - "displayText": "Надсилання не відбулося" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Статус повідомлення невідомий", - "displayText": "Невідомий стан" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Надсилання повідомлення", - "displayText": "Очікування доставки" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/zh-CN/messages.json b/apps/web/public/i18n/locales/zh-CN/messages.json index 35f3380bb..5bba17fe1 100644 --- a/apps/web/public/i18n/locales/zh-CN/messages.json +++ b/apps/web/public/i18n/locales/zh-CN/messages.json @@ -19,21 +19,83 @@ "replyLabel": "回复" }, "deliveryStatus": { - "delivered": { - "label": "Message delivered", - "displayText": "Message delivered" + "retry": "Retry", + "retryAriaLabel": "Retry message: {{status}}", + "deliveredToMesh": { + "displayText": "Delivered to mesh", + "detailText": "At least one node heard this channel message." }, - "failed": { - "label": "Message delivery failed", - "displayText": "Delivery failed" + "deliveredToRecipient": { + "displayText": "Delivered to recipient", + "detailText": "The recipient confirmed this direct message." }, - "unknown": { - "label": "Message status unknown", - "displayText": "Unknown state" + "encryptedSendFailed": { + "displayText": "Could not send encrypted message", + "detailText": "The radio could not create the encrypted path for this message. Let node info sync, then try again." + }, + "adminKeyNotAuthorized": { + "displayText": "Admin key not authorized", + "detailText": "The admin public key is not authorized on the destination." + }, + "adminSessionExpired": { + "displayText": "Admin session expired", + "detailText": "The admin session key is missing, invalid, or expired. Start a new admin session, then try again." + }, + "dutyCycleLimit": { + "displayText": "Duty cycle limit", + "detailText": "The radio is temporarily blocked by the regional airtime limit. Try again later." + }, + "failedToMesh": { + "displayText": "Failed to deliver to mesh", + "detailText": "No node confirmed this message. Try again when you have better signal or more mesh coverage." + }, + "invalidRequest": { + "displayText": "Invalid request", + "detailText": "The receiving app rejected the request. Check the message or command and try again." + }, + "messageTooLarge": { + "displayText": "Message is too large to send", + "detailText": "Shorten the message and send it again." + }, + "noAppResponse": { + "displayText": "No app response", + "detailText": "The destination received the request, but no app or module responded." + }, + "noChannel": { + "displayText": "Channel/key mismatch", + "detailText": "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again." }, - "waiting": { - "label": "Sending message", - "displayText": "Waiting for delivery" + "noRadioInterface": { + "displayText": "No radio interface", + "detailText": "The radio does not have a usable interface for this send." + }, + "notAuthorized": { + "displayText": "Not authorized", + "detailText": "The destination refused permission for this request." + }, + "rateLimited": { + "displayText": "Rate limited", + "detailText": "Messages are being sent too quickly. Wait a moment, then try again." + }, + "recipientKeyUnavailable": { + "displayText": "Recipient key unavailable", + "detailText": "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again." + }, + "recipientNeedsYourKey": { + "displayText": "Recipient needs your key", + "detailText": "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs." + }, + "relayed": { + "displayText": "Relayed, not confirmed by recipient", + "detailText": "A node relayed this message, but the recipient has not confirmed it." + }, + "sending": { + "displayText": "Sending...", + "detailText": "The radio is still trying to send this message." + }, + "unknown": { + "displayText": "Message status unknown", + "detailText": "The message status is not available." } } } diff --git a/apps/web/public/i18n/locales/zh-TW/messages.json b/apps/web/public/i18n/locales/zh-TW/messages.json index a0e430159..6b7c50939 100644 --- a/apps/web/public/i18n/locales/zh-TW/messages.json +++ b/apps/web/public/i18n/locales/zh-TW/messages.json @@ -19,21 +19,83 @@ "replyLabel": "回覆" }, "deliveryStatus": { - "delivered": { - "label": "訊息已送達", - "displayText": "訊息已送達" + "retry": "重試", + "retryAriaLabel": "重試訊息:{{status}}", + "deliveredToMesh": { + "displayText": "已傳遞至 mesh 網路", + "detailText": "至少一個節點已收到此頻道訊息。" }, - "failed": { - "label": "訊息傳送失敗", - "displayText": "傳送失敗" + "deliveredToRecipient": { + "displayText": "已傳遞給收件者", + "detailText": "收件者已確認此直接訊息。" }, - "unknown": { - "label": "訊息狀態未知", - "displayText": "不明狀態" + "encryptedSendFailed": { + "displayText": "無法傳送加密訊息", + "detailText": "無線電無法為此訊息建立加密路徑。請等待節點資訊同步後再試一次。" + }, + "adminKeyNotAuthorized": { + "displayText": "管理員金鑰未授權", + "detailText": "目的端未授權此管理員公開金鑰。" + }, + "adminSessionExpired": { + "displayText": "管理員工作階段已過期", + "detailText": "管理員工作階段金鑰遺失、無效或已過期。請開始新的管理員工作階段後再試一次。" + }, + "dutyCycleLimit": { + "displayText": "工作週期限制", + "detailText": "無線電暫時受到區域空中時間限制阻擋。請稍後再試。" + }, + "failedToMesh": { + "displayText": "無法傳遞至 mesh 網路", + "detailText": "沒有節點確認此訊息。請在訊號較好或 mesh 覆蓋較佳時再試一次。" + }, + "invalidRequest": { + "displayText": "無效的請求", + "detailText": "接收端應用程式拒絕了請求。請檢查訊息或指令後再試一次。" + }, + "messageTooLarge": { + "displayText": "訊息太大,無法傳送", + "detailText": "請縮短訊息後重新傳送。" + }, + "noAppResponse": { + "displayText": "沒有應用程式回應", + "detailText": "目的端已收到請求,但沒有應用程式或模組回應。" + }, + "noChannel": { + "displayText": "頻道/金鑰不相符", + "detailText": "此訊息無法使用相符的頻道/金鑰進行編碼或解碼。請檢查頻道和金鑰後再試一次。" }, - "waiting": { - "label": "訊息傳送中", - "displayText": "等待送達" + "noRadioInterface": { + "displayText": "沒有無線電介面", + "detailText": "無線電沒有可用於此次傳送的介面。" + }, + "notAuthorized": { + "displayText": "未授權", + "detailText": "目的端拒絕了此請求的權限。" + }, + "rateLimited": { + "displayText": "已達速率限制", + "detailText": "訊息傳送得太快。請稍候再試。" + }, + "recipientKeyUnavailable": { + "displayText": "收件者金鑰無法使用", + "detailText": "你的節點尚未取得收件者的公開金鑰。請等待節點資訊同步後再試一次。" + }, + "recipientNeedsYourKey": { + "displayText": "收件者需要你的金鑰", + "detailText": "收件者尚不知道你的公開金鑰。你的節點可能會自動分享其資訊;同步後請再試一次。" + }, + "relayed": { + "displayText": "已中繼,收件者未確認", + "detailText": "某個節點已中繼此訊息,但收件者尚未確認。" + }, + "sending": { + "displayText": "傳送中...", + "detailText": "無線電仍在嘗試傳送此訊息。" + }, + "unknown": { + "displayText": "訊息狀態未知", + "detailText": "訊息狀態無法使用。" } } } diff --git a/apps/web/src/components/PageComponents/Messages/ChannelChat.tsx b/apps/web/src/components/PageComponents/Messages/ChannelChat.tsx index 61161a501..810f08842 100644 --- a/apps/web/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/apps/web/src/components/PageComponents/Messages/ChannelChat.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; export interface ChannelChatProps { messages?: Message[]; + onRetryMessage?: (message: Message) => void; } function toTs(d: Message["date"]): number { @@ -104,7 +105,10 @@ const EmptyState = () => { ); }; -export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { +export const ChannelChat = ({ + messages = [], + onRetryMessage, +}: ChannelChatProps) => { const { i18n, t } = useTranslation(); const locale = useMemo( @@ -153,7 +157,7 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => { key={message.messageId ?? `${message.from}-${message.date}`} fallback={} > - + ))} diff --git a/apps/web/src/components/PageComponents/Messages/MessageItem.tsx b/apps/web/src/components/PageComponents/Messages/MessageItem.tsx index 7309b23fb..ac843f234 100644 --- a/apps/web/src/components/PageComponents/Messages/MessageItem.tsx +++ b/apps/web/src/components/PageComponents/Messages/MessageItem.tsx @@ -6,18 +6,22 @@ import { TooltipProvider, TooltipTrigger, } from "@components/UI/Tooltip.tsx"; +import { Button } from "@components/UI/Button.tsx"; import { useMyNodeAsProto, useNodeAsProto, } from "@core/hooks/useNodesAsProto.ts"; import { useAppStore, useDevice } from "@core/stores"; -import { type Message, MessageState } from "@core/stores/messageStore"; +import { type Message } from "@core/stores/messageStore"; import { cn } from "@core/utils/cn.ts"; -import { type Protobuf, Types } from "@meshtastic/sdk"; -import type { LucideIcon } from "lucide-react"; -import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; +import { type Protobuf } from "@meshtastic/sdk"; +import { RotateCcw } from "lucide-react"; import { type ReactNode, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { + getMessageDeliveryStatusInfo, + type MessageDeliveryStatusInfo, +} from "./messageDeliveryStatus.ts"; // Cache for pending promises const myNodePromises = new Map>(); @@ -65,26 +69,19 @@ function useSuspendingMyNode() { // import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // TODO: Uncomment when actions menu is implemented -interface MessageStatusInfo { - displayText: string; - icon: LucideIcon; - ariaLabel: string; - iconClassName?: string; -} - const StatusTooltip = ({ statusInfo, children, }: { - statusInfo: MessageStatusInfo; + statusInfo: MessageDeliveryStatusInfo; children: ReactNode; }) => ( {children} - {statusInfo.displayText} - + {statusInfo.detailText} + @@ -92,9 +89,10 @@ const StatusTooltip = ({ interface MessageItemProps { message: Message; + onRetry?: (message: Message) => void; } -export const MessageItem = ({ message }: MessageItemProps) => { +export const MessageItem = ({ message, onRetry }: MessageItemProps) => { const { config } = useDevice(); const { t, i18n } = useTranslation("messages"); const messageUserNode = useNodeAsProto(message.from ?? 0); @@ -103,47 +101,6 @@ export const MessageItem = ({ message }: MessageItemProps) => { const myNode = useSuspendingMyNode(); const myNodeNum = myNode.num; - const MESSAGE_STATUS_MAP = useMemo( - (): Record => ({ - [MessageState.Ack]: { - displayText: t("deliveryStatus.delivered.displayText"), - icon: CheckCircle2, - ariaLabel: t("deliveryStatus.delivered.label"), - iconClassName: "text-green-500", - }, - [MessageState.Waiting]: { - displayText: t("deliveryStatus.waiting.displayText"), - icon: CircleEllipsis, - ariaLabel: t("deliveryStatus.waiting.label"), - iconClassName: "text-slate-400", - }, - [MessageState.Failed]: { - displayText: t("deliveryStatus.failed.displayText"), - icon: AlertCircle, - ariaLabel: t("deliveryStatus.failed.label"), - iconClassName: "text-red-500 dark:text-red-400", - }, - }), - [t], - ); - - const UNKNOWN_STATUS = useMemo( - (): MessageStatusInfo => ({ - displayText: t("deliveryStatus.unknown.displayText"), - icon: AlertCircle, - ariaLabel: t("deliveryStatus.unknown.label"), - iconClassName: "text-red-500 dark:text-red-400", - }), - [t], - ); - - const getMessageStatusInfo = useMemo( - () => - (state: MessageState): MessageStatusInfo => - MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS, - [MESSAGE_STATUS_MAP, UNKNOWN_STATUS], - ); - const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = message.from != null ? (messageUserNode ?? null) : null; @@ -164,7 +121,10 @@ export const MessageItem = ({ message }: MessageItemProps) => { }; }, [messageUser, message.from, t, myNodeNum]); - const messageStatusInfo = getMessageStatusInfo(message.state); + const messageStatusInfo = useMemo( + () => getMessageDeliveryStatusInfo(message, t), + [message, t], + ); const StatusIconComponent = messageStatusInfo.icon; const messageDate = useMemo( @@ -193,8 +153,9 @@ export const MessageItem = ({ message }: MessageItemProps) => { ); const isSender = myNodeNum !== undefined && message.from === myNodeNum; - const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum - const shouldShowStatusIcon = isSender && isOnPrimaryChannel; + const shouldShowStatus = isSender; + const canRetry = + shouldShowStatus && messageStatusInfo.canRetry && Boolean(onRetry); const messageItemWrapperClass = cn( "group w-full py-2 relative list-none", @@ -228,24 +189,49 @@ export const MessageItem = ({ message }: MessageItemProps) => { {fullDateTime} )} - {shouldShowStatusIcon && ( + + + {message?.message && ( +
+ {message.message} +
+ )} + + {shouldShowStatus && ( +
- + - )} -
- - {message?.message && ( -
- {message.message} + {canRetry && ( + + )}
)} diff --git a/apps/web/src/components/PageComponents/Messages/messageDeliveryStatus.test.ts b/apps/web/src/components/PageComponents/Messages/messageDeliveryStatus.test.ts new file mode 100644 index 000000000..655baaed2 --- /dev/null +++ b/apps/web/src/components/PageComponents/Messages/messageDeliveryStatus.test.ts @@ -0,0 +1,227 @@ +import { MessageState, MessageType } from "@core/stores/messageStore"; +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; +import { getMessageDeliveryStatusInfo } from "./messageDeliveryStatus.ts"; + +const strings: Record = { + "deliveryStatus.deliveredToMesh.displayText": "Delivered to mesh", + "deliveryStatus.deliveredToMesh.detailText": + "At least one node heard this channel message.", + "deliveryStatus.deliveredToRecipient.displayText": "Delivered to recipient", + "deliveryStatus.deliveredToRecipient.detailText": + "The recipient confirmed this direct message.", + "deliveryStatus.encryptedSendFailed.displayText": + "Could not send encrypted message", + "deliveryStatus.encryptedSendFailed.detailText": + "The radio could not create the encrypted path for this message. Let node info sync, then try again.", + "deliveryStatus.adminKeyNotAuthorized.displayText": + "Admin key not authorized", + "deliveryStatus.adminKeyNotAuthorized.detailText": + "The admin public key is not authorized on the destination.", + "deliveryStatus.adminSessionExpired.displayText": "Admin session expired", + "deliveryStatus.adminSessionExpired.detailText": + "The admin session key is missing, invalid, or expired. Start a new admin session, then try again.", + "deliveryStatus.dutyCycleLimit.displayText": "Duty cycle limit", + "deliveryStatus.dutyCycleLimit.detailText": + "The radio is temporarily blocked by the regional airtime limit. Try again later.", + "deliveryStatus.failedToMesh.displayText": "Failed to deliver to mesh", + "deliveryStatus.failedToMesh.detailText": + "No node confirmed this message. Try again when you have better signal or more mesh coverage.", + "deliveryStatus.invalidRequest.displayText": "Invalid request", + "deliveryStatus.invalidRequest.detailText": + "The receiving app rejected the request. Check the message or command and try again.", + "deliveryStatus.messageTooLarge.displayText": "Message is too large to send", + "deliveryStatus.messageTooLarge.detailText": + "Shorten the message and send it again.", + "deliveryStatus.noAppResponse.displayText": "No app response", + "deliveryStatus.noAppResponse.detailText": + "The destination received the request, but no app or module responded.", + "deliveryStatus.noChannel.displayText": "Channel/key mismatch", + "deliveryStatus.noChannel.detailText": + "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again.", + "deliveryStatus.noRadioInterface.displayText": "No radio interface", + "deliveryStatus.noRadioInterface.detailText": + "The radio does not have a usable interface for this send.", + "deliveryStatus.notAuthorized.displayText": "Not authorized", + "deliveryStatus.notAuthorized.detailText": + "The destination refused permission for this request.", + "deliveryStatus.rateLimited.displayText": "Rate limited", + "deliveryStatus.rateLimited.detailText": + "Messages are being sent too quickly. Wait a moment, then try again.", + "deliveryStatus.recipientKeyUnavailable.displayText": + "Recipient key unavailable", + "deliveryStatus.recipientKeyUnavailable.detailText": + "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again.", + "deliveryStatus.recipientNeedsYourKey.displayText": + "Recipient needs your key", + "deliveryStatus.recipientNeedsYourKey.detailText": + "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs.", + "deliveryStatus.relayed.displayText": "Relayed, not confirmed by recipient", + "deliveryStatus.relayed.detailText": + "A node relayed this message, but the recipient has not confirmed it.", + "deliveryStatus.sending.displayText": "Sending...", + "deliveryStatus.sending.detailText": + "The radio is still trying to send this message.", + "deliveryStatus.unknown.displayText": "Message status unknown", + "deliveryStatus.unknown.detailText": "The message status is not available.", +}; + +const t = ((key: string) => strings[key] ?? key) as never; + +describe("getMessageDeliveryStatusInfo", () => { + it.each([ + [ + MessageState.Waiting, + MessageType.Broadcast, + undefined, + "Sending...", + false, + ], + [ + MessageState.Ack, + MessageType.Broadcast, + undefined, + "Delivered to mesh", + false, + ], + [ + MessageState.Ack, + MessageType.Direct, + undefined, + "Delivered to recipient", + false, + ], + [ + MessageState.Relayed, + MessageType.Direct, + undefined, + "Relayed, not confirmed by recipient", + true, + ], + [ + MessageState.Failed, + MessageType.Broadcast, + Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + "Failed to deliver to mesh", + true, + ], + [ + MessageState.Failed, + MessageType.Broadcast, + Protobuf.Mesh.Routing_Error.NO_CHANNEL, + "Channel/key mismatch", + true, + ], + [ + MessageState.Failed, + MessageType.Broadcast, + Protobuf.Mesh.Routing_Error.NO_INTERFACE, + "No radio interface", + true, + ], + [ + MessageState.Failed, + MessageType.Broadcast, + Protobuf.Mesh.Routing_Error.DUTY_CYCLE_LIMIT, + "Duty cycle limit", + true, + ], + [ + MessageState.Failed, + MessageType.Broadcast, + Protobuf.Mesh.Routing_Error.RATE_LIMIT_EXCEEDED, + "Rate limited", + true, + ], + [ + MessageState.Failed, + MessageType.Direct, + Protobuf.Mesh.Routing_Error.PKI_FAILED, + "Could not send encrypted message", + true, + ], + [ + MessageState.Failed, + MessageType.Direct, + Protobuf.Mesh.Routing_Error.PKI_SEND_FAIL_PUBLIC_KEY, + "Recipient key unavailable", + true, + ], + [ + MessageState.Failed, + MessageType.Direct, + Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY, + "Recipient needs your key", + true, + ], + [ + MessageState.Failed, + MessageType.Broadcast, + Protobuf.Mesh.Routing_Error.TOO_LARGE, + "Message is too large to send", + false, + ], + [ + MessageState.Failed, + MessageType.Direct, + Protobuf.Mesh.Routing_Error.NO_RESPONSE, + "No app response", + true, + ], + [ + MessageState.Failed, + MessageType.Direct, + Protobuf.Mesh.Routing_Error.BAD_REQUEST, + "Invalid request", + true, + ], + [ + MessageState.Failed, + MessageType.Direct, + Protobuf.Mesh.Routing_Error.NOT_AUTHORIZED, + "Not authorized", + true, + ], + [ + MessageState.Failed, + MessageType.Direct, + Protobuf.Mesh.Routing_Error.ADMIN_BAD_SESSION_KEY, + "Admin session expired", + true, + ], + [ + MessageState.Failed, + MessageType.Direct, + Protobuf.Mesh.Routing_Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED, + "Admin key not authorized", + true, + ], + ])( + "maps %s/%s/%s to %s", + (state, type, routingError, displayText, canRetry) => { + const info = getMessageDeliveryStatusInfo( + { state, type, routingError }, + t, + ); + + expect(info.displayText).toBe(displayText); + expect(info.ariaLabel).toBe(displayText); + expect(info.canRetry).toBe(canRetry); + }, + ); + + it("maps NO_CHANNEL to channel/key mismatch detail", () => { + const info = getMessageDeliveryStatusInfo( + { + state: MessageState.Failed, + type: MessageType.Broadcast, + routingError: Protobuf.Mesh.Routing_Error.NO_CHANNEL, + }, + t, + ); + + expect(info.detailText).toBe( + "This message could not be encoded or decoded with a matching channel/key. Check the channel and key, then try again.", + ); + }); +}); diff --git a/apps/web/src/components/PageComponents/Messages/messageDeliveryStatus.ts b/apps/web/src/components/PageComponents/Messages/messageDeliveryStatus.ts new file mode 100644 index 000000000..697bb86a3 --- /dev/null +++ b/apps/web/src/components/PageComponents/Messages/messageDeliveryStatus.ts @@ -0,0 +1,145 @@ +import { MessageState, MessageType } from "@core/stores/messageStore"; +import { Protobuf } from "@meshtastic/sdk"; +import type { TFunction } from "i18next"; +import type { LucideIcon } from "lucide-react"; +import { + AlertCircle, + CheckCircle2, + CircleEllipsis, + RefreshCcw, +} from "lucide-react"; + +export interface MessageDeliveryInput { + state: MessageState; + type: MessageType; + routingError?: Protobuf.Mesh.Routing_Error; +} + +export interface MessageDeliveryStatusInfo { + displayText: string; + detailText: string; + icon: LucideIcon; + ariaLabel: string; + iconClassName: string; + textClassName: string; + canRetry: boolean; +} + +export function getMessageDeliveryStatusInfo( + message: MessageDeliveryInput, + t: TFunction<"messages", undefined>, +): MessageDeliveryStatusInfo { + switch (message.state) { + case MessageState.Waiting: + return { + displayText: t("deliveryStatus.sending.displayText"), + detailText: t("deliveryStatus.sending.detailText"), + icon: CircleEllipsis, + ariaLabel: t("deliveryStatus.sending.displayText"), + iconClassName: "text-amber-500 dark:text-amber-400", + textClassName: "text-amber-700 dark:text-amber-300", + canRetry: false, + }; + case MessageState.Ack: + return message.type === MessageType.Direct + ? { + displayText: t("deliveryStatus.deliveredToRecipient.displayText"), + detailText: t("deliveryStatus.deliveredToRecipient.detailText"), + icon: CheckCircle2, + ariaLabel: t("deliveryStatus.deliveredToRecipient.displayText"), + iconClassName: "text-green-600 dark:text-green-400", + textClassName: "text-green-700 dark:text-green-300", + canRetry: false, + } + : { + displayText: t("deliveryStatus.deliveredToMesh.displayText"), + detailText: t("deliveryStatus.deliveredToMesh.detailText"), + icon: CheckCircle2, + ariaLabel: t("deliveryStatus.deliveredToMesh.displayText"), + iconClassName: "text-green-600 dark:text-green-400", + textClassName: "text-green-700 dark:text-green-300", + canRetry: false, + }; + case MessageState.Relayed: + return { + displayText: t("deliveryStatus.relayed.displayText"), + detailText: t("deliveryStatus.relayed.detailText"), + icon: RefreshCcw, + ariaLabel: t("deliveryStatus.relayed.displayText"), + iconClassName: "text-amber-500 dark:text-amber-400", + textClassName: "text-amber-700 dark:text-amber-300", + canRetry: true, + }; + case MessageState.Failed: + return getFailureStatusInfo(message.routingError, t); + default: + return { + displayText: t("deliveryStatus.unknown.displayText"), + detailText: t("deliveryStatus.unknown.detailText"), + icon: AlertCircle, + ariaLabel: t("deliveryStatus.unknown.displayText"), + iconClassName: "text-red-500 dark:text-red-400", + textClassName: "text-red-700 dark:text-red-300", + canRetry: true, + }; + } +} + +function getFailureStatusInfo( + routingError: Protobuf.Mesh.Routing_Error | undefined, + t: TFunction<"messages", undefined>, +): MessageDeliveryStatusInfo { + const isPermanent = routingError === Protobuf.Mesh.Routing_Error.TOO_LARGE; + const failureKey = failureTranslationKey(routingError); + + return { + displayText: t(`deliveryStatus.${failureKey}.displayText`), + detailText: t(`deliveryStatus.${failureKey}.detailText`), + icon: AlertCircle, + ariaLabel: t(`deliveryStatus.${failureKey}.displayText`), + iconClassName: isPermanent + ? "text-red-500 dark:text-red-400" + : "text-amber-500 dark:text-amber-400", + textClassName: isPermanent + ? "text-red-700 dark:text-red-300" + : "text-amber-700 dark:text-amber-300", + canRetry: !isPermanent, + }; +} + +function failureTranslationKey( + routingError: Protobuf.Mesh.Routing_Error | undefined, +): string { + switch (routingError) { + case Protobuf.Mesh.Routing_Error.NO_CHANNEL: + return "noChannel"; + case Protobuf.Mesh.Routing_Error.NO_INTERFACE: + return "noRadioInterface"; + case Protobuf.Mesh.Routing_Error.DUTY_CYCLE_LIMIT: + return "dutyCycleLimit"; + case Protobuf.Mesh.Routing_Error.RATE_LIMIT_EXCEEDED: + return "rateLimited"; + case Protobuf.Mesh.Routing_Error.PKI_FAILED: + return "encryptedSendFailed"; + case Protobuf.Mesh.Routing_Error.PKI_SEND_FAIL_PUBLIC_KEY: + return "recipientKeyUnavailable"; + case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: + return "recipientNeedsYourKey"; + case Protobuf.Mesh.Routing_Error.TOO_LARGE: + return "messageTooLarge"; + case Protobuf.Mesh.Routing_Error.NO_RESPONSE: + return "noAppResponse"; + case Protobuf.Mesh.Routing_Error.BAD_REQUEST: + return "invalidRequest"; + case Protobuf.Mesh.Routing_Error.NOT_AUTHORIZED: + return "notAuthorized"; + case Protobuf.Mesh.Routing_Error.ADMIN_BAD_SESSION_KEY: + return "adminSessionExpired"; + case Protobuf.Mesh.Routing_Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED: + return "adminKeyNotAuthorized"; + case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT: + case Protobuf.Mesh.Routing_Error.TIMEOUT: + default: + return "failedToMesh"; + } +} diff --git a/apps/web/src/core/hooks/useChatAsLegacyMessages.ts b/apps/web/src/core/hooks/useChatAsLegacyMessages.ts index f8bb64c89..9241527cd 100644 --- a/apps/web/src/core/hooks/useChatAsLegacyMessages.ts +++ b/apps/web/src/core/hooks/useChatAsLegacyMessages.ts @@ -64,6 +64,7 @@ function toLegacy( date: message.rxTime.getTime(), messageId: message.id, state: mapState(message.state), + routingError: message.routingError, message: message.text, } as LegacyMessage; } @@ -72,6 +73,8 @@ function mapState(state: SdkMessageState): LegacyMessageState { switch (state) { case SdkMessageState.Ack: return LegacyMessageState.Ack; + case SdkMessageState.Relayed: + return LegacyMessageState.Relayed; case SdkMessageState.Failed: return LegacyMessageState.Failed; case SdkMessageState.Pending: diff --git a/apps/web/src/core/hooks/useChatLegacy.ts b/apps/web/src/core/hooks/useChatLegacy.ts index f8bb64c89..9241527cd 100644 --- a/apps/web/src/core/hooks/useChatLegacy.ts +++ b/apps/web/src/core/hooks/useChatLegacy.ts @@ -64,6 +64,7 @@ function toLegacy( date: message.rxTime.getTime(), messageId: message.id, state: mapState(message.state), + routingError: message.routingError, message: message.text, } as LegacyMessage; } @@ -72,6 +73,8 @@ function mapState(state: SdkMessageState): LegacyMessageState { switch (state) { case SdkMessageState.Ack: return LegacyMessageState.Ack; + case SdkMessageState.Relayed: + return LegacyMessageState.Relayed; case SdkMessageState.Failed: return LegacyMessageState.Failed; case SdkMessageState.Pending: diff --git a/apps/web/src/core/stores/messageStore/index.ts b/apps/web/src/core/stores/messageStore/index.ts index 89df8125c..56265df02 100644 --- a/apps/web/src/core/stores/messageStore/index.ts +++ b/apps/web/src/core/stores/messageStore/index.ts @@ -5,10 +5,11 @@ * persistence is owned by the SDK ChatClient + SqlocalMessageRepository. */ -import type { Types } from "@meshtastic/sdk"; +import type { Protobuf, Types } from "@meshtastic/sdk"; export enum MessageState { Ack = "ack", + Relayed = "relayed", Waiting = "waiting", Failed = "failed", } @@ -30,6 +31,7 @@ interface MessageBase { date: number; messageId: number; state: MessageState; + routingError?: Protobuf.Mesh.Routing_Error; message: string; } diff --git a/apps/web/src/core/subscriptions.test.ts b/apps/web/src/core/subscriptions.test.ts new file mode 100644 index 000000000..0612799f7 --- /dev/null +++ b/apps/web/src/core/subscriptions.test.ts @@ -0,0 +1,49 @@ +import { + EventBus, + type MeshDevice, + type PacketMetadata, + Protobuf, +} from "@meshtastic/sdk"; +import { describe, expect, it, vi } from "vitest"; +import { mockDeviceStore } from "./stores/deviceStore/deviceStore.mock.ts"; +import { subscribeAll } from "./subscriptions.ts"; + +function routingPacket( + value: Protobuf.Mesh.Routing_Error, +): PacketMetadata { + return { + id: 1, + from: 99, + to: 0xffffffff, + channel: 0, + type: "broadcast", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value, + }, + } as Protobuf.Mesh.Routing, + }; +} + +describe("subscribeAll routing errors", () => { + it.each([ + Protobuf.Mesh.Routing_Error.NO_CHANNEL, + Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY, + ])("does not open refreshKeys for %s", (reason) => { + vi.spyOn(console, "info").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const events = new EventBus(); + const device = { + ...mockDeviceStore, + setDialogOpen: vi.fn(), + }; + + subscribeAll(device, { events } as MeshDevice); + + events.onRoutingPacket.dispatch(routingPacket(reason)); + + expect(device.setDialogOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/core/subscriptions.ts b/apps/web/src/core/subscriptions.ts index c7b6701d1..d981c1802 100644 --- a/apps/web/src/core/subscriptions.ts +++ b/apps/web/src/core/subscriptions.ts @@ -99,14 +99,6 @@ export const subscribeAll = (device: Device, connection: MeshDevice) => { case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT: console.error(`Routing Error: ${routingPacket.data.variant.value}`); break; - case Protobuf.Mesh.Routing_Error.NO_CHANNEL: - case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: - console.error(`Routing Error: ${routingPacket.data.variant.value}`); - // Per-node error tracking lives on the SDK NodesClient - // (client.nodes.errors); the dialog open trigger stays here so the - // legacy device-store-driven dialog manager keeps working. - device.setDialogOpen("refreshKeys", true); - break; default: break; } diff --git a/apps/web/src/pages/Messages.tsx b/apps/web/src/pages/Messages.tsx index 67caf3afd..1251be97e 100644 --- a/apps/web/src/pages/Messages.tsx +++ b/apps/web/src/pages/Messages.tsx @@ -11,6 +11,7 @@ import { useChatAsLegacyMessages } from "@core/hooks/useChatAsLegacyMessages.ts" import { useNodesAsProto } from "@core/hooks/useNodesAsProto.ts"; import { useToast } from "@core/hooks/useToast.ts"; import { MessageType, useSidebar } from "@core/stores"; +import type { Message } from "@core/stores/messageStore"; import { cn } from "@core/utils/cn.ts"; import { Protobuf, Types } from "@meshtastic/sdk"; import { @@ -166,6 +167,20 @@ export const MessagesPage = () => { [meshClient, numericChatId, isDirect], ); + const retryMessage = useCallback( + async (message: Message) => { + if (!meshClient) { + console.warn("[MessagesPage] no active mesh client; retry dropped"); + return; + } + const result = await meshClient.chat.retry(message.messageId); + if (result.status === "error") { + console.error("Failed to retry message:", result.error); + } + }, + [meshClient], + ); + const broadcastMessages = useChatAsLegacyMessages({ type: MessageType.Broadcast, channelId: numericChatId, @@ -178,9 +193,19 @@ export const MessagesPage = () => { const renderChatContent = () => { switch (chatType) { case MessageType.Broadcast: - return ; + return ( + + ); case MessageType.Direct: - return ; + return ( + + ); default: return ; } diff --git a/packages/sdk-storage-sqlocal/src/chat/SqlocalMessageRepository.test.ts b/packages/sdk-storage-sqlocal/src/chat/SqlocalMessageRepository.test.ts index 10335d1a6..1ab32b519 100644 --- a/packages/sdk-storage-sqlocal/src/chat/SqlocalMessageRepository.test.ts +++ b/packages/sdk-storage-sqlocal/src/chat/SqlocalMessageRepository.test.ts @@ -1,10 +1,23 @@ -import { ChannelNumber, type Message, MessageState } from "@meshtastic/sdk"; +import { + ChannelNumber, + type Message, + MessageState, + Protobuf, +} from "@meshtastic/sdk"; import { beforeEach, describe, expect, it } from "vitest"; import type { SqlocalDb } from "../db.ts"; import { createMemoryDb } from "../testing/createMemoryDb.ts"; import { SqlocalMessageRepository } from "./SqlocalMessageRepository.ts"; -function msg(id: number, ms: number, text = "t"): Message { +const LOCAL_NODE = 100; +const PEER_NODE = 200; + +function msg( + id: number, + ms: number, + text = "t", + state = MessageState.Ack, +): Message { return { id, from: 1, @@ -13,7 +26,27 @@ function msg(id: number, ms: number, text = "t"): Message { rxTime: new Date(ms), type: "broadcast", text, - state: MessageState.Ack, + state, + }; +} + +function directMsg( + id: number, + from: number, + to: number, + state = MessageState.Ack, + routingError?: Protobuf.Mesh.Routing_Error, +): Message { + return { + id, + from, + to, + channel: ChannelNumber.Primary, + rxTime: new Date(1000), + type: "direct", + text: "dm", + state, + routingError, }; } @@ -23,7 +56,29 @@ describe("SqlocalMessageRepository (sql.js test driver)", () => { beforeEach(async () => { db = await createMemoryDb(); - repo = new SqlocalMessageRepository(db, { deviceId: 1 }); + repo = new SqlocalMessageRepository(db, { + deviceId: 1, + localNodeNum: LOCAL_NODE, + }); + }); + + it("deletes only the matching message for the scoped device", async () => { + const repoB = new SqlocalMessageRepository(db, { deviceId: 2 }); + await repo.appendBatch([msg(1, 1000), msg(2, 2000)]); + await repoB.append(msg(1, 1000)); + + await repo.delete(1); + + const remaining = await repo.loadRecent( + { kind: "channel", channel: ChannelNumber.Primary }, + 10, + ); + const otherDevice = await repoB.loadRecent( + { kind: "channel", channel: ChannelNumber.Primary }, + 10, + ); + expect(remaining.map((message) => message.id)).toEqual([2]); + expect(otherDevice.map((message) => message.id)).toEqual([1]); }); it("loadRecent returns the tail in chronological order", async () => { @@ -51,13 +106,91 @@ describe("SqlocalMessageRepository (sql.js test driver)", () => { }); it("updateState mutates the matching row", async () => { - await repo.append(msg(42, 1000)); - await repo.updateState(42, MessageState.Failed); + await repo.append(msg(42, 1000, "t", MessageState.Pending)); + await repo.updateState( + 42, + MessageState.Failed, + Protobuf.Mesh.Routing_Error.NO_CHANNEL, + ); const [found] = await repo.loadRecent( { kind: "channel", channel: ChannelNumber.Primary }, 1, ); expect(found?.state).toBe(MessageState.Failed); + expect(found?.routingError).toBe(Protobuf.Mesh.Routing_Error.NO_CHANNEL); + }); + + it("does not downgrade an ack state", async () => { + await repo.append(msg(43, 1000)); + await repo.updateState(43, MessageState.Relayed); + + const [found] = await repo.loadRecent( + { kind: "channel", channel: ChannelNumber.Primary }, + 1, + ); + expect(found?.state).toBe(MessageState.Ack); + }); + + it("allows actionable failures to replace a relayed state", async () => { + await repo.append(msg(44, 1000, "t", MessageState.Relayed)); + await repo.updateState( + 44, + MessageState.Failed, + Protobuf.Mesh.Routing_Error.PKI_SEND_FAIL_PUBLIC_KEY, + ); + + const [found] = await repo.loadRecent( + { kind: "channel", channel: ChannelNumber.Primary }, + 1, + ); + expect(found?.state).toBe(MessageState.Failed); + expect(found?.routingError).toBe( + Protobuf.Mesh.Routing_Error.PKI_SEND_FAIL_PUBLIC_KEY, + ); + }); + + it("reloads outbound direct messages from the recipient conversation", async () => { + await repo.append( + directMsg(7, LOCAL_NODE, PEER_NODE, MessageState.Pending), + ); + await repo.updateState( + 7, + MessageState.Failed, + Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + ); + + const out = await repo.loadRecent({ kind: "direct", peer: PEER_NODE }, 10); + expect(out).toHaveLength(1); + expect(out[0]?.state).toBe(MessageState.Failed); + expect(out[0]?.routingError).toBe( + Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + ); + }); + + it("reloads inbound direct messages from the sender conversation", async () => { + await repo.append(directMsg(8, PEER_NODE, LOCAL_NODE)); + + const out = await repo.loadRecent({ kind: "direct", peer: PEER_NODE }, 10); + expect(out.map((m) => m.id)).toEqual([8]); + }); + + it("keeps final status when a stale duplicate append arrives later", async () => { + const pending = msg(9, 1000, "stale", MessageState.Pending); + await repo.append(pending); + await repo.updateState( + pending.id, + MessageState.Failed, + Protobuf.Mesh.Routing_Error.NO_CHANNEL, + ); + await repo.append(pending); + + const out = await repo.loadRecent( + { kind: "channel", channel: ChannelNumber.Primary }, + 10, + ); + expect(out).toHaveLength(1); + expect(out[0]?.state).toBe(MessageState.Failed); + expect(out[0]?.routingError).toBe(Protobuf.Mesh.Routing_Error.NO_CHANNEL); }); it("prune enforces maxPerBucket", async () => { diff --git a/packages/sdk-storage-sqlocal/src/chat/SqlocalMessageRepository.ts b/packages/sdk-storage-sqlocal/src/chat/SqlocalMessageRepository.ts index adabf056d..d22d6c546 100644 --- a/packages/sdk-storage-sqlocal/src/chat/SqlocalMessageRepository.ts +++ b/packages/sdk-storage-sqlocal/src/chat/SqlocalMessageRepository.ts @@ -1,10 +1,15 @@ +import type * as Protobuf from "@meshtastic/protobufs"; import type { ConversationKey, Message, MessageRepository, RetentionPolicy, } from "@meshtastic/sdk"; -import { conversationKeyString, MessageState } from "@meshtastic/sdk"; +import { + conversationKeyString, + getMessageStatePrecedence, + MessageState, +} from "@meshtastic/sdk"; import { and, desc, eq, lt, sql } from "drizzle-orm"; import { type MultiTabCoordinator } from "../coordination/MultiTabCoordinator.ts"; import type { SqlocalDb } from "../db.ts"; @@ -13,6 +18,8 @@ import { messages } from "../schema/chat.ts"; export interface SqlocalMessageRepositoryOptions { /** Identifies the device (matches MeshRegistry ConnectionId). */ deviceId: number; + /** Local node number, used when appending direct messages without a key. */ + localNodeNum?: number | (() => number | undefined); /** Optional cross-tab coordinator. If omitted, mutations are silent. */ coordinator?: MultiTabCoordinator; } @@ -20,11 +27,13 @@ export interface SqlocalMessageRepositoryOptions { export class SqlocalMessageRepository implements MessageRepository { private readonly db: SqlocalDb; private readonly deviceId: number; + private readonly localNodeNum: SqlocalMessageRepositoryOptions["localNodeNum"]; private readonly coordinator: MultiTabCoordinator | undefined; constructor(db: SqlocalDb, options: SqlocalMessageRepositoryOptions) { this.db = db; this.deviceId = options.deviceId; + this.localNodeNum = options.localNodeNum; this.coordinator = options.coordinator; } @@ -52,21 +61,43 @@ export class SqlocalMessageRepository implements MessageRepository { return rows.reverse().map(rowToMessage); } - async append(message: Message): Promise { - await this.appendBatch([message]); + async append(message: Message, key?: ConversationKey): Promise { + const conversation = key ?? this.inferConversationKey(message); + await this.insertEntries([{ message, conversation }]); } async appendBatch(input: ReadonlyArray): Promise { if (input.length === 0) return; - const rows = input.map((m) => messageToRow(this.deviceId, m)); - await this.db.insert(messages).values(rows).onConflictDoNothing(); - this.notify(input); + await this.insertEntries( + input.map((message) => ({ + message, + conversation: this.inferConversationKey(message), + })), + ); } - async updateState(id: number, state: MessageState): Promise { + async updateState( + id: number, + state: MessageState, + routingError?: Protobuf.Mesh.Routing_Error, + ): Promise { await this.db .update(messages) - .set({ state }) + .set({ state, routingError: routingError ?? null }) + .where( + and( + eq(messages.deviceId, this.deviceId), + eq(messages.id, id), + sql`${storedMessageStatePrecedence()} <= ${getMessageStatePrecedence( + state, + )}`, + )!, + ); + } + + async delete(id: number): Promise { + await this.db + .delete(messages) .where(and(eq(messages.deviceId, this.deviceId), eq(messages.id, id))!); } @@ -119,15 +150,53 @@ export class SqlocalMessageRepository implements MessageRepository { )!; } - private notify(input: ReadonlyArray): void { + private async insertEntries( + entries: ReadonlyArray<{ + message: Message; + conversation: ConversationKey; + }>, + ): Promise { + if (entries.length === 0) return; + const rows = entries.map(({ message, conversation }) => + messageToRow(this.deviceId, message, conversation), + ); + await this.db + .insert(messages) + .values(rows) + .onConflictDoNothing({ target: [messages.deviceId, messages.id] }); + this.notify(entries); + } + + private inferConversationKey(message: Message): ConversationKey { + if (message.type !== "direct") { + return { kind: "channel", channel: message.channel }; + } + + const localNodeNum = this.resolveLocalNodeNum(); + return { + kind: "direct", + peer: + localNodeNum !== undefined && message.from === localNodeNum + ? message.to + : message.from, + }; + } + + private resolveLocalNodeNum(): number | undefined { + return typeof this.localNodeNum === "function" + ? this.localNodeNum() + : this.localNodeNum; + } + + private notify( + entries: ReadonlyArray<{ + conversation: ConversationKey; + }>, + ): void { if (!this.coordinator) return; const seen = new Set(); - for (const m of input) { - const conv: ConversationKey = - m.type === "direct" - ? { kind: "direct", peer: m.from } - : { kind: "channel", channel: m.channel }; - const key = conversationKeyString(conv); + for (const entry of entries) { + const key = conversationKeyString(entry.conversation); if (seen.has(key)) continue; seen.add(key); this.coordinator.broadcast({ @@ -149,7 +218,8 @@ interface MessageRow { rxTime: number; type: "broadcast" | "direct"; text: string; - state: "pending" | "ack" | "failed"; + state: "pending" | "ack" | "relayed" | "failed"; + routingError: number | null; } function rowToMessage(row: MessageRow): Message { @@ -162,14 +232,15 @@ function rowToMessage(row: MessageRow): Message { type: row.type, text: row.text, state: row.state as MessageState, + routingError: row.routingError ?? undefined, }; } -function messageToRow(deviceId: number, message: Message): MessageRow { - const conv: ConversationKey = - message.type === "direct" - ? { kind: "direct", peer: message.from } - : { kind: "channel", channel: message.channel }; +function messageToRow( + deviceId: number, + message: Message, + conv: ConversationKey, +): MessageRow { return { id: message.id, deviceId, @@ -181,5 +252,19 @@ function messageToRow(deviceId: number, message: Message): MessageRow { type: message.type, text: message.text, state: message.state, + routingError: message.routingError ?? null, }; } + +function storedMessageStatePrecedence() { + return sql`CASE ${messages.state} + WHEN ${MessageState.Ack} THEN ${getMessageStatePrecedence(MessageState.Ack)} + WHEN ${MessageState.Relayed} THEN ${getMessageStatePrecedence( + MessageState.Relayed, + )} + WHEN ${MessageState.Failed} THEN ${getMessageStatePrecedence( + MessageState.Failed, + )} + ELSE ${getMessageStatePrecedence(MessageState.Pending)} + END`; +} diff --git a/packages/sdk-storage-sqlocal/src/schema/chat.ts b/packages/sdk-storage-sqlocal/src/schema/chat.ts index 2783d7e05..2d5553b97 100644 --- a/packages/sdk-storage-sqlocal/src/schema/chat.ts +++ b/packages/sdk-storage-sqlocal/src/schema/chat.ts @@ -1,4 +1,10 @@ -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; export const messages = sqliteTable( "messages", @@ -18,10 +24,17 @@ export const messages = sqliteTable( rxTime: integer("rx_time").notNull(), type: text("type", { enum: ["broadcast", "direct"] }).notNull(), text: text("text").notNull(), - state: text("state", { enum: ["pending", "ack", "failed"] }).notNull(), + state: text("state", { + enum: ["pending", "ack", "relayed", "failed"], + }).notNull(), + routingError: integer("routing_error"), }, (t) => ({ pk: index("messages_pk").on(t.deviceId, t.id), + deviceMessageUnique: uniqueIndex("idx_messages_device_id_id_unique").on( + t.deviceId, + t.id, + ), convRxTime: index("idx_messages_conv_rxtime").on( t.deviceId, t.conversationKey, diff --git a/packages/sdk-storage-sqlocal/src/schema/migrations.test.ts b/packages/sdk-storage-sqlocal/src/schema/migrations.test.ts index b69f212b8..8bd3a65cb 100644 --- a/packages/sdk-storage-sqlocal/src/schema/migrations.test.ts +++ b/packages/sdk-storage-sqlocal/src/schema/migrations.test.ts @@ -46,6 +46,203 @@ describe("MIGRATIONS", () => { ); }); + it("latest migration adds a unique message id index", async () => { + const db = await freshSqlite(); + for (const migration of MIGRATIONS) { + for (const stmt of migration.sql) db.run(stmt); + } + const indexes = db + .exec( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='messages' ORDER BY name", + )[0] + ?.values.flat() as string[]; + expect(indexes).toContain("idx_messages_device_id_id_unique"); + }); + + it("latest migration rekeys legacy outbound direct rows to recipient conversations", async () => { + const db = await freshSqlite(); + for (const migration of MIGRATIONS.filter((m) => m.version < 5)) { + for (const stmt of migration.sql) db.run(stmt); + } + + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [1, 1, "direct:100", 100, 200, 0, 1000, "direct", "out", "relayed", null], + ); + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [2, 1, "direct:100", 100, 300, 0, 2000, "direct", "out-2", "ack", null], + ); + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [3, 1, "direct:200", 200, 100, 0, 3000, "direct", "in", "ack", null], + ); + + const latest = MIGRATIONS.at(-1)!; + expect(latest.version).toBe(5); + for (const stmt of latest.sql) db.run(stmt); + + const rows = db.exec( + "SELECT id, conversation_key FROM messages ORDER BY id", + )[0]?.values; + expect(rows).toEqual([ + [1, "direct:200"], + [2, "direct:300"], + [3, "direct:200"], + ]); + }); + + it("leaves inbound-only singleton direct rows keyed by peer", async () => { + const db = await freshSqlite(); + for (const migration of MIGRATIONS.filter((m) => m.version < 5)) { + for (const stmt of migration.sql) db.run(stmt); + } + + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [1, 1, "direct:200", 200, 100, 0, 1000, "direct", "in", "ack", null], + ); + + const latest = MIGRATIONS.at(-1)!; + expect(latest.version).toBe(5); + for (const stmt of latest.sql) db.run(stmt); + + const rows = db.exec( + "SELECT id, conversation_key FROM messages ORDER BY id", + )[0]?.values; + expect(rows).toEqual([[1, "direct:200"]]); + }); + + it("leaves ambiguous local-more ack-only reciprocal direct history unchanged", async () => { + const db = await freshSqlite(); + for (const migration of MIGRATIONS.filter((m) => m.version < 5)) { + for (const stmt of migration.sql) db.run(stmt); + } + + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [1, 1, "direct:100", 100, 200, 0, 1000, "direct", "out", "ack", null], + ); + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [2, 1, "direct:100", 100, 200, 0, 2000, "direct", "out-2", "ack", null], + ); + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [3, 1, "direct:200", 200, 100, 0, 3000, "direct", "in", "ack", null], + ); + + const latest = MIGRATIONS.at(-1)!; + expect(latest.version).toBe(5); + for (const stmt of latest.sql) db.run(stmt); + + const rows = db.exec( + "SELECT id, conversation_key FROM messages ORDER BY id", + )[0]?.values; + expect(rows).toEqual([ + [1, "direct:100"], + [2, "direct:100"], + [3, "direct:200"], + ]); + }); + + it("leaves ambiguous peer-more ack-only reciprocal direct history unchanged", async () => { + const db = await freshSqlite(); + for (const migration of MIGRATIONS.filter((m) => m.version < 5)) { + for (const stmt of migration.sql) db.run(stmt); + } + + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [1, 1, "direct:100", 100, 200, 0, 1000, "direct", "out", "ack", null], + ); + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [2, 1, "direct:200", 200, 100, 0, 2000, "direct", "in", "ack", null], + ); + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [3, 1, "direct:200", 200, 100, 0, 3000, "direct", "in-2", "ack", null], + ); + + const latest = MIGRATIONS.at(-1)!; + expect(latest.version).toBe(5); + for (const stmt of latest.sql) db.run(stmt); + + const rows = db.exec( + "SELECT id, conversation_key FROM messages ORDER BY id", + )[0]?.values; + expect(rows).toEqual([ + [1, "direct:100"], + [2, "direct:200"], + [3, "direct:200"], + ]); + }); + + it("leaves ambiguous equal-count ack-only reciprocal direct history unchanged", async () => { + const db = await freshSqlite(); + for (const migration of MIGRATIONS.filter((m) => m.version < 5)) { + for (const stmt of migration.sql) db.run(stmt); + } + + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [1, 1, "direct:100", 100, 200, 0, 1000, "direct", "out", "ack", null], + ); + db.run( + `INSERT INTO messages ( + id, device_id, conversation_key, from_node, to_node, channel, rx_time, + type, text, state, routing_error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [2, 1, "direct:200", 200, 100, 0, 2000, "direct", "in", "ack", null], + ); + + const latest = MIGRATIONS.at(-1)!; + expect(latest.version).toBe(5); + for (const stmt of latest.sql) db.run(stmt); + + const rows = db.exec( + "SELECT id, conversation_key FROM messages ORDER BY id", + )[0]?.values; + expect(rows).toEqual([ + [1, "direct:100"], + [2, "direct:200"], + ]); + }); + it("re-applying v1 statements is idempotent (CREATE IF NOT EXISTS)", async () => { const db = await freshSqlite(); for (const stmt of MIGRATIONS[0]!.sql) db.run(stmt); diff --git a/packages/sdk-storage-sqlocal/src/schema/migrations.ts b/packages/sdk-storage-sqlocal/src/schema/migrations.ts index 7bb6343ea..46512318e 100644 --- a/packages/sdk-storage-sqlocal/src/schema/migrations.ts +++ b/packages/sdk-storage-sqlocal/src/schema/migrations.ts @@ -65,4 +65,90 @@ export const MIGRATIONS: ReadonlyArray<{ version: number; sql: string[] }> = [ )`, ], }, + { + version: 3, + sql: [`ALTER TABLE messages ADD COLUMN routing_error INTEGER`], + }, + { + version: 4, + sql: [ + `DELETE FROM messages + WHERE rowid NOT IN ( + SELECT rowid FROM ( + SELECT + rowid, + row_number() OVER ( + PARTITION BY device_id, id + ORDER BY + CASE state + WHEN 'ack' THEN 4 + WHEN 'failed' THEN 3 + WHEN 'relayed' THEN 2 + ELSE 1 + END DESC, + CASE WHEN routing_error IS NULL THEN 0 ELSE 1 END DESC, + rx_time DESC, + rowid DESC + ) AS rn + FROM messages + ) + WHERE rn = 1 + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_device_id_id_unique ON messages(device_id, id)`, + ], + }, + { + version: 5, + sql: [ + `WITH direct_buckets AS ( + SELECT + device_id, + conversation_key, + from_node, + MIN(to_node) AS peer_node, + COUNT(*) AS message_count, + COUNT(DISTINCT to_node) AS recipient_count, + SUM(CASE + WHEN state <> 'ack' OR routing_error IS NOT NULL THEN 1 + ELSE 0 + END) AS actionable_count + FROM messages + WHERE type = 'direct' + AND conversation_key = 'direct:' || from_node + AND from_node <> to_node + GROUP BY device_id, conversation_key, from_node + ), + inferred_local_nodes AS ( + SELECT DISTINCT + device_id, + from_node + FROM direct_buckets + WHERE recipient_count > 1 + OR actionable_count > 0 + ), + legacy_outbound_buckets AS ( + SELECT direct_buckets.device_id, + direct_buckets.conversation_key, + direct_buckets.from_node + FROM direct_buckets + WHERE EXISTS ( + SELECT 1 + FROM inferred_local_nodes local + WHERE local.device_id = direct_buckets.device_id + AND local.from_node = direct_buckets.from_node + ) + ) + UPDATE messages + SET conversation_key = 'direct:' || to_node + WHERE type = 'direct' + AND from_node <> to_node + AND EXISTS ( + SELECT 1 + FROM legacy_outbound_buckets legacy + WHERE legacy.device_id = messages.device_id + AND legacy.conversation_key = messages.conversation_key + AND legacy.from_node = messages.from_node + )`, + ], + }, ]; diff --git a/packages/sdk/mod.ts b/packages/sdk/mod.ts index 8e934fe0e..9afc7b250 100644 --- a/packages/sdk/mod.ts +++ b/packages/sdk/mod.ts @@ -93,10 +93,12 @@ export type { export { conversationKeyString, EmptyMessageError, + getMessageStatePrecedence, InMemoryDraftRepository, InMemoryMessageRepository, MessageState, MessageTooLongError, + shouldApplyMessageStateUpdate, } from "./src/features/chat/index.ts"; export { NodesClient } from "./src/features/nodes/index.ts"; diff --git a/packages/sdk/src/core/packet-codec/decodePacket.ts b/packages/sdk/src/core/packet-codec/decodePacket.ts index 363e3f4c2..242246b51 100644 --- a/packages/sdk/src/core/packet-codec/decodePacket.ts +++ b/packages/sdk/src/core/packet-codec/decodePacket.ts @@ -295,6 +295,7 @@ function handleDecodedPacket( ) { const packetMetadata: Omit, "data"> = { id: meshPacket.id, + requestId: dataPacket.requestId, rxTime: new Date(meshPacket.rxTime * 1000), type: meshPacket.to === Constants.broadcastNum ? "broadcast" : "direct", from: meshPacket.from, diff --git a/packages/sdk/src/core/types.ts b/packages/sdk/src/core/types.ts index 6b2a1f936..7e21c2572 100644 --- a/packages/sdk/src/core/types.ts +++ b/packages/sdk/src/core/types.ts @@ -21,6 +21,7 @@ export type PacketDestination = "broadcast" | "direct"; export interface PacketMetadata { id: number; + requestId?: number; rxTime: Date; type: PacketDestination; from: number; diff --git a/packages/sdk/src/features/chat/ChatClient.persistence.test.ts b/packages/sdk/src/features/chat/ChatClient.persistence.test.ts index d5821c365..dbc563822 100644 --- a/packages/sdk/src/features/chat/ChatClient.persistence.test.ts +++ b/packages/sdk/src/features/chat/ChatClient.persistence.test.ts @@ -1,9 +1,11 @@ +import * as Protobuf from "@meshtastic/protobufs"; import { describe, expect, it } from "vitest"; import { MeshClient } from "../../core/client/MeshClient.ts"; import { createFakeTransport } from "../../core/testing/createFakeTransport.ts"; import { ChannelNumber } from "../../core/types.ts"; import { InMemoryMessageRepository } from "./infrastructure/repositories/InMemoryMessageRepository.ts"; import type { Message } from "./domain/Message.ts"; +import type { ConversationKey } from "./domain/MessageRepository.ts"; import { MessageState } from "./domain/MessageState.ts"; function seedMessage(id: number, ms: number, text: string): Message { @@ -19,6 +21,81 @@ function seedMessage(id: number, ms: number, text: string): Message { }; } +class DeferredAppendRepository extends InMemoryMessageRepository { + private releaseAppend: (() => void) | undefined; + + override async append( + message: Message, + key?: ConversationKey, + ): Promise { + await new Promise((resolve) => { + this.releaseAppend = resolve; + }); + await super.append(message, key); + } + + releasePendingAppend(): void { + this.releaseAppend?.(); + } +} + +class DelayedFirstUpdateRepository extends InMemoryMessageRepository { + private updateCount = 0; + private firstUpdateStarted: (() => void) | undefined; + private releaseFirstUpdate: (() => void) | undefined; + + readonly firstUpdateStartedPromise = new Promise((resolve) => { + this.firstUpdateStarted = resolve; + }); + + override async updateState( + id: number, + state: MessageState, + routingError?: Protobuf.Mesh.Routing_Error, + ): Promise { + this.updateCount += 1; + if (this.updateCount === 1) { + this.firstUpdateStarted?.(); + await new Promise((resolve) => { + this.releaseFirstUpdate = resolve; + }); + } + await super.updateState(id, state, routingError); + } + + releaseDelayedUpdate(): void { + this.releaseFirstUpdate?.(); + } +} + +async function withAckFlush( + client: MeshClient, + run: () => Promise, +): Promise { + const flush = setInterval(() => { + for (const item of client.queue.getState()) + client.queue.processAck(item.id); + }, 5); + try { + return await run(); + } finally { + clearInterval(flush); + } +} + +async function waitForPersistedState( + repository: InMemoryMessageRepository, + key: ConversationKey, + state: MessageState, +): Promise { + for (let i = 0; i < 20; i++) { + const [persisted] = await repository.loadRecent(key, 1); + if (persisted?.state === state) return persisted; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for persisted state ${state}`); +} + describe("ChatClient persistence", () => { it("hydrates messages from the repository on first subscription", async () => { const repository = new InMemoryMessageRepository(); @@ -95,4 +172,158 @@ describe("ChatClient persistence", () => { ); expect(persisted.map((m) => m.text)).toEqual(["hi"]); }); + + it("persists the final failed state when validation beats async append", async () => { + const repository = new DeferredAppendRepository(); + const { transport } = createFakeTransport(); + const client = new MeshClient({ + transport, + chat: { repository }, + }); + const conv: ConversationKey = { + kind: "channel", + channel: ChannelNumber.Primary, + }; + + const result = await client.chat.send({ + text: "x".repeat(229), + destination: "broadcast", + channel: ChannelNumber.Primary, + }); + + expect(result.status).toBe("error"); + repository.releasePendingAppend(); + + const persisted = await waitForPersistedState( + repository, + conv, + MessageState.Failed, + ); + expect(persisted.routingError).toBe(Protobuf.Mesh.Routing_Error.TOO_LARGE); + }); + + it("persists recipient ack after an earlier relayed write resolves later", async () => { + const repository = new DelayedFirstUpdateRepository(); + const { transport } = createFakeTransport(); + const client = new MeshClient({ + transport, + chat: { repository }, + }); + const peer = 12345; + const conv: ConversationKey = { kind: "direct", peer }; + const direct = client.chat.direct(peer); + + const result = await withAckFlush(client, () => + client.chat.send({ text: "ordered", destination: peer }), + ); + if (result.status !== "ok") throw new Error("send failed"); + + client.events.onRoutingPacket.dispatch({ + id: 1, + requestId: result.value, + from: 999, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + await repository.firstUpdateStartedPromise; + + client.events.onRoutingPacket.dispatch({ + id: 2, + requestId: result.value, + from: peer, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + + expect(direct.value[0]?.state).toBe(MessageState.Ack); + + repository.releaseDelayedUpdate(); + const persisted = await waitForPersistedState( + repository, + conv, + MessageState.Ack, + ); + expect(persisted.routingError).toBeUndefined(); + }); + + it("persists actionable failure after an earlier relayed write resolves later", async () => { + const repository = new DelayedFirstUpdateRepository(); + const { transport } = createFakeTransport(); + const client = new MeshClient({ + transport, + chat: { repository }, + }); + const peer = 12345; + const conv: ConversationKey = { kind: "direct", peer }; + const direct = client.chat.direct(peer); + + const result = await withAckFlush(client, () => + client.chat.send({ text: "needs key", destination: peer }), + ); + if (result.status !== "ok") throw new Error("send failed"); + + client.events.onRoutingPacket.dispatch({ + id: 3, + requestId: result.value, + from: 999, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + await repository.firstUpdateStartedPromise; + + client.events.onRoutingPacket.dispatch({ + id: 4, + requestId: result.value, + from: peer, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY, + }, + }, + } as never); + + expect(direct.value[0]?.state).toBe(MessageState.Failed); + expect(direct.value[0]?.routingError).toBe( + Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY, + ); + + repository.releaseDelayedUpdate(); + const persisted = await waitForPersistedState( + repository, + conv, + MessageState.Failed, + ); + expect(persisted.routingError).toBe( + Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY, + ); + }); }); diff --git a/packages/sdk/src/features/chat/ChatClient.send.test.ts b/packages/sdk/src/features/chat/ChatClient.send.test.ts index dd6564de2..e755236dd 100644 --- a/packages/sdk/src/features/chat/ChatClient.send.test.ts +++ b/packages/sdk/src/features/chat/ChatClient.send.test.ts @@ -1,5 +1,7 @@ +import { create, toBinary } from "@bufbuild/protobuf"; import type { ResultType } from "better-result"; -import { describe, expect, it } from "vitest"; +import * as Protobuf from "@meshtastic/protobufs"; +import { describe, expect, it, vi } from "vitest"; import { MeshClient } from "../../core/client/MeshClient.ts"; import { createFakeTransport } from "../../core/testing/createFakeTransport.ts"; import { ChannelNumber } from "../../core/types.ts"; @@ -40,6 +42,28 @@ function sendBroadcast(client: MeshClient, text: string): Promise { } describe("ChatClient.send optimistic append", () => { + it("replaces the original message with a fresh send when retrying", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const messages = client.chat.messages(ChannelNumber.Primary); + + const original = await sendBroadcast(client, "try again"); + if (original.status !== "ok") throw new Error("send failed"); + + const retried = await withAckFlush(client, () => + client.chat.retry(original.value), + ); + if (retried.status !== "ok") throw new Error("retry failed"); + + expect(retried.value).not.toBe(original.value); + expect(messages.value).toHaveLength(1); + expect(messages.value[0]).toMatchObject({ + id: retried.value, + text: "try again", + state: MessageState.Pending, + }); + }); + it("appends an outbound broadcast to the channel bucket immediately", async () => { const { transport } = createFakeTransport(); const client = new MeshClient({ transport }); @@ -140,7 +164,7 @@ describe("ChatClient.send optimistic append", () => { expect(messages.value[0]!.state).toBe(MessageState.Failed); }); - it("flips outbound state to Ack on a routing-error=NONE packet matching the id", async () => { + it("flips outbound channel state to Ack on a routing-error=NONE packet matching the request id", async () => { const { transport } = createFakeTransport(); const client = new MeshClient({ transport }); const messages = client.chat.messages(ChannelNumber.Primary); @@ -149,15 +173,310 @@ describe("ChatClient.send optimistic append", () => { if (result.status !== "ok") throw new Error("send failed"); client.events.onRoutingPacket.dispatch({ - id: result.value, + id: 987, + requestId: result.value, from: 0, to: 0, channel: 0, type: "broadcast", rxTime: new Date(), - data: { variant: { case: "errorReason", value: 0 } }, + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, } as never); expect(messages.value[0]!.state).toBe(MessageState.Ack); }); + + it("marks direct NONE from a relayer as relayed instead of recipient-delivered", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const peer = 12345; + const direct = client.chat.direct(peer); + + const result: SendResult = await withAckFlush(client, () => + client.chat.send({ text: "hi peer", destination: peer }), + ); + if (result.status !== "ok") throw new Error("send failed"); + + client.events.onRoutingPacket.dispatch({ + id: 456, + requestId: result.value, + from: 999, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + + expect(direct.value[0]!.state).toBe(MessageState.Relayed); + }); + + it("marks direct NONE from the recipient as recipient-delivered", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const peer = 12345; + const direct = client.chat.direct(peer); + + const result: SendResult = await withAckFlush(client, () => + client.chat.send({ text: "hi peer", destination: peer }), + ); + if (result.status !== "ok") throw new Error("send failed"); + + client.events.onRoutingPacket.dispatch({ + id: 789, + requestId: result.value, + from: peer, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + + expect(direct.value[0]!.state).toBe(MessageState.Ack); + }); + + it("does not downgrade recipient-delivered direct messages on later relayer confirmations", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const peer = 12345; + const direct = client.chat.direct(peer); + + const result: SendResult = await withAckFlush(client, () => + client.chat.send({ text: "hi peer", destination: peer }), + ); + if (result.status !== "ok") throw new Error("send failed"); + + client.events.onRoutingPacket.dispatch({ + id: 789, + requestId: result.value, + from: peer, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + client.events.onRoutingPacket.dispatch({ + id: 790, + requestId: result.value, + from: 999, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + + expect(direct.value[0]!.state).toBe(MessageState.Ack); + }); + + it("promotes relayed direct messages to failed on later PKI errors", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const peer = 12345; + const direct = client.chat.direct(peer); + + const result: SendResult = await withAckFlush(client, () => + client.chat.send({ text: "needs key", destination: peer }), + ); + if (result.status !== "ok") throw new Error("send failed"); + + client.events.onRoutingPacket.dispatch({ + id: 791, + requestId: result.value, + from: 999, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + client.events.onRoutingPacket.dispatch({ + id: 792, + requestId: result.value, + from: peer, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY, + }, + }, + } as never); + + expect(direct.value[0]!.state).toBe(MessageState.Failed); + expect(direct.value[0]!.routingError).toBe( + Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY, + ); + }); + + it("preserves recipient-delivered direct messages on later PKI errors", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const peer = 12345; + const direct = client.chat.direct(peer); + + const result: SendResult = await withAckFlush(client, () => + client.chat.send({ text: "already acked", destination: peer }), + ); + if (result.status !== "ok") throw new Error("send failed"); + + client.events.onRoutingPacket.dispatch({ + id: 793, + requestId: result.value, + from: peer, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NONE, + }, + }, + } as never); + client.events.onRoutingPacket.dispatch({ + id: 794, + requestId: result.value, + from: peer, + to: client.myNodeNum, + channel: 0, + type: "direct", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.PKI_SEND_FAIL_PUBLIC_KEY, + }, + }, + } as never); + + expect(direct.value[0]!.state).toBe(MessageState.Ack); + expect(direct.value[0]!.routingError).toBeUndefined(); + }); + + it("preserves routing error reason on failed delivery", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + const messages = client.chat.messages(ChannelNumber.Primary); + + const result = await sendBroadcast(client, "no ack"); + if (result.status !== "ok") throw new Error("send failed"); + + client.events.onRoutingPacket.dispatch({ + id: 321, + requestId: result.value, + from: 0, + to: 0, + channel: 0, + type: "broadcast", + rxTime: new Date(), + data: { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + }, + }, + } as never); + + expect(messages.value[0]!.state).toBe(MessageState.Failed); + expect(messages.value[0]!.routingError).toBe( + Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + ); + }); + + it("preserves routing error when decoder rejects the queued send afterward", async () => { + const { transport, respond } = createFakeTransport(); + const client = new MeshClient({ transport }); + const messages = client.chat.messages(ChannelNumber.Primary); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const sendPromise = client.chat.send({ + text: "missing channel", + destination: "broadcast", + channel: ChannelNumber.Primary, + }); + const packetId = messages.value[0]?.id; + if (packetId === undefined) { + throw new Error("optimistic message was not appended"); + } + const queued = client.queue + .getState() + .find((item) => item.id === packetId); + if (queued === undefined) { + throw new Error("send was not queued"); + } + void queued.promise.catch(() => {}); + + const routingPacket = create(Protobuf.Mesh.RoutingSchema, { + variant: { + case: "errorReason", + value: Protobuf.Mesh.Routing_Error.NO_CHANNEL, + }, + }); + respond.withMeshPacket( + create(Protobuf.Mesh.MeshPacketSchema, { + id: 654, + from: 0, + to: client.myNodeNum, + channel: ChannelNumber.Primary, + rxTime: Math.trunc(Date.now() / 1000), + payloadVariant: { + case: "decoded", + value: { + portnum: Protobuf.Portnums.PortNum.ROUTING_APP, + requestId: packetId, + payload: toBinary(Protobuf.Mesh.RoutingSchema, routingPacket), + }, + }, + }), + ); + + const result = await sendPromise; + expect(result.status).toBe("error"); + expect(messages.value).toHaveLength(1); + expect(messages.value[0]!.state).toBe(MessageState.Failed); + expect(messages.value[0]!.routingError).toBe( + Protobuf.Mesh.Routing_Error.NO_CHANNEL, + ); + } finally { + errorSpy.mockRestore(); + } + }); }); diff --git a/packages/sdk/src/features/chat/ChatClient.ts b/packages/sdk/src/features/chat/ChatClient.ts index 5116cc2ad..6efe5ef2f 100644 --- a/packages/sdk/src/features/chat/ChatClient.ts +++ b/packages/sdk/src/features/chat/ChatClient.ts @@ -1,5 +1,7 @@ +import * as Protobuf from "@meshtastic/protobufs"; import { signal } from "@preact/signals-core"; import type { ResultType } from "better-result"; +import { Result } from "better-result"; import type { MeshClient } from "../../core/client/MeshClient.ts"; import { Constants } from "../../core/constants/index.ts"; import { generatePacketId } from "../../core/identifiers/PacketId.ts"; @@ -22,6 +24,7 @@ import { InMemoryMessageRepository } from "./infrastructure/repositories/InMemor import { type SendTextError, type SendTextInput, + MessageTooLongError, sendText, } from "./application/SendTextUseCase.ts"; import { ChatStore } from "./state/chatStore.ts"; @@ -77,6 +80,8 @@ export class ChatClient { private readonly initialLoadLimit: number; private readonly hydrated = new Set(); private readonly draftsHydrated = new Set(); + private readonly pendingMessagePersistence = new Map>(); + private readonly pendingStatePersistence = new Map>(); private readonly unreadByKey = signal>(new Map()); public readonly drafts: ChatDrafts; @@ -86,7 +91,11 @@ export class ChatClient { this.client = client; this.store = new ChatStore(); this.draftStore = new DraftStore(); - this.repository = options.repository ?? new InMemoryMessageRepository(); + this.repository = + options.repository ?? + new InMemoryMessageRepository({ + localNodeNum: () => this.client.myNodeNum, + }); this.draftRepository = options.draftRepository ?? new InMemoryDraftRepository(); this.retention = options.retention; @@ -121,7 +130,7 @@ export class ChatClient { if (this.store.hasMessage(key, message.id)) return; this.store.append(key, message); - void this.persistAppend(message); + void this.persistAppend(message, conv); // Increment unread count when the message is inbound (not from us). The // outbound echo of our own send doesn't count as unread. @@ -132,12 +141,20 @@ export class ChatClient { client.events.onRoutingPacket.subscribe((packet) => { if (packet.data.variant.case === "errorReason") { + const messageId = packet.requestId ?? packet.id; + const reason = packet.data.variant.value; + const existing = this.store.findMessage(messageId); const state = - packet.data.variant.value === 0 - ? MessageState.Ack + reason === Protobuf.Mesh.Routing_Error.NONE + ? existing?.type === "direct" && packet.from !== existing.to + ? MessageState.Relayed + : MessageState.Ack : MessageState.Failed; - this.store.updateState(packet.id, state); - void this.repository.updateState(packet.id, state).catch(() => {}); + const routingError = + reason === Protobuf.Mesh.Routing_Error.NONE ? undefined : reason; + if (this.store.updateState(messageId, state, routingError)) { + void this.persistStateUpdate(messageId, state, routingError); + } } }); } @@ -229,7 +246,7 @@ export class ChatClient { }; if (!this.store.hasMessage(key, packetId)) { this.store.append(key, message); - void this.persistAppend(message); + void this.persistAppend(message, conv); } this.draftStore.clear(conv); void this.draftRepository.clear(conv).catch(() => {}); @@ -239,14 +256,54 @@ export class ChatClient { // Send pipeline failed (transport closed, validation, etc.). Keep // the optimistic message visible but mark it Failed so the user // sees the error state next to their bubble. - this.store.updateState(packetId, MessageState.Failed); - void this.repository - .updateState(packetId, MessageState.Failed) - .catch(() => {}); + const existing = this.store.findMessage(packetId); + const routingError = + result.error instanceof MessageTooLongError + ? Protobuf.Mesh.Routing_Error.TOO_LARGE + : existing?.routingError; + if (this.store.updateState(packetId, MessageState.Failed, routingError)) { + void this.persistStateUpdate( + packetId, + MessageState.Failed, + routingError, + ); + } } return result; } + /** + * Removes an existing message and sends its contents again with a fresh + * packet id. This matches the retry behavior of the other clients and keeps + * a stale failed message from remaining beside the new attempt. + */ + public async retry( + messageId: number, + ): Promise> { + const message = this.store.findMessage(messageId); + if (!message) { + return Result.err(new Error(`Message ${messageId} was not found`)); + } + + this.store.deleteMessage(messageId); + + const pendingPersistence = + this.pendingStatePersistence.get(messageId) ?? + this.pendingMessagePersistence.get(messageId); + try { + await pendingPersistence; + await this.repository.delete(messageId); + } catch { + // Persistence failure must not prevent the user from retrying. + } + + return this.send({ + text: message.text, + destination: message.type === "direct" ? message.to : "broadcast", + channel: message.channel, + }); + } + private ensureHydrated(conv: ConversationKey): void { const key = this.keyFor(conv); if (this.hydrated.has(key)) return; @@ -288,13 +345,51 @@ export class ChatClient { }); } - private async persistAppend(message: Message): Promise { - try { - await this.repository.append(message); - if (this.retention) await this.repository.prune(this.retention); - } catch { - // persistence failure must not break reactive flow - } + private persistAppend( + message: Message, + conv: ConversationKey, + ): Promise { + const op = (async () => { + try { + await this.repository.append(message, conv); + if (this.retention) await this.repository.prune(this.retention); + } catch { + // persistence failure must not break reactive flow + } + })(); + this.pendingMessagePersistence.set(message.id, op); + void op.finally(() => { + if (this.pendingMessagePersistence.get(message.id) === op) { + this.pendingMessagePersistence.delete(message.id); + } + }); + return op; + } + + private async persistStateUpdate( + messageId: number, + state: MessageState, + routingError?: Protobuf.Mesh.Routing_Error, + ): Promise { + const prior = + this.pendingStatePersistence.get(messageId) ?? + this.pendingMessagePersistence.get(messageId) ?? + Promise.resolve(); + const op = (async () => { + try { + await prior; + await this.repository.updateState(messageId, state, routingError); + } catch { + // persistence failure must not break reactive flow + } + })(); + this.pendingStatePersistence.set(messageId, op); + void op.finally(() => { + if (this.pendingStatePersistence.get(messageId) === op) { + this.pendingStatePersistence.delete(messageId); + } + }); + return op; } private keyFor(conv: ConversationKey): string { diff --git a/packages/sdk/src/features/chat/domain/Message.ts b/packages/sdk/src/features/chat/domain/Message.ts index 356b47563..c050688f8 100644 --- a/packages/sdk/src/features/chat/domain/Message.ts +++ b/packages/sdk/src/features/chat/domain/Message.ts @@ -1,3 +1,4 @@ +import type * as Protobuf from "@meshtastic/protobufs"; import type { ChannelNumber, PacketDestination } from "../../../core/types.ts"; import type { MessageState } from "./MessageState.ts"; @@ -13,4 +14,5 @@ export interface Message { readonly type: PacketDestination; readonly text: string; readonly state: MessageState; + readonly routingError?: Protobuf.Mesh.Routing_Error; } diff --git a/packages/sdk/src/features/chat/domain/MessageRepository.ts b/packages/sdk/src/features/chat/domain/MessageRepository.ts index 4f86fe807..37f09961c 100644 --- a/packages/sdk/src/features/chat/domain/MessageRepository.ts +++ b/packages/sdk/src/features/chat/domain/MessageRepository.ts @@ -1,3 +1,4 @@ +import type * as Protobuf from "@meshtastic/protobufs"; import type { ChannelNumber } from "../../../core/types.ts"; import type { Message } from "./Message.ts"; import type { MessageState } from "./MessageState.ts"; @@ -31,9 +32,15 @@ export interface MessageRepository { cursor: Date, limit: number, ): Promise; - append(message: Message): Promise; + append(message: Message, key?: ConversationKey): Promise; appendBatch(messages: ReadonlyArray): Promise; - updateState(id: number, state: MessageState): Promise; + updateState( + id: number, + state: MessageState, + routingError?: Protobuf.Mesh.Routing_Error, + ): Promise; + /** Deletes a single message by its packet id. */ + delete(id: number): Promise; prune(policy: RetentionPolicy): Promise; /** Deletes every message in a single conversation. */ clearConversation(key: ConversationKey): Promise; diff --git a/packages/sdk/src/features/chat/domain/MessageState.ts b/packages/sdk/src/features/chat/domain/MessageState.ts index 54090fbc8..f44181229 100644 --- a/packages/sdk/src/features/chat/domain/MessageState.ts +++ b/packages/sdk/src/features/chat/domain/MessageState.ts @@ -1,5 +1,24 @@ export enum MessageState { Pending = "pending", Ack = "ack", + Relayed = "relayed", Failed = "failed", } + +const messageStatePrecedence: Record = { + [MessageState.Pending]: 1, + [MessageState.Relayed]: 2, + [MessageState.Failed]: 3, + [MessageState.Ack]: 4, +}; + +export function getMessageStatePrecedence(state: MessageState): number { + return messageStatePrecedence[state]; +} + +export function shouldApplyMessageStateUpdate( + current: MessageState, + next: MessageState, +): boolean { + return getMessageStatePrecedence(next) >= getMessageStatePrecedence(current); +} diff --git a/packages/sdk/src/features/chat/index.ts b/packages/sdk/src/features/chat/index.ts index 90bef9166..e6bc4d82f 100644 --- a/packages/sdk/src/features/chat/index.ts +++ b/packages/sdk/src/features/chat/index.ts @@ -7,7 +7,11 @@ export type { export type { DraftRepository } from "./domain/DraftRepository.ts"; export { InMemoryDraftRepository } from "./infrastructure/repositories/InMemoryDraftRepository.ts"; export type { Message } from "./domain/Message.ts"; -export { MessageState } from "./domain/MessageState.ts"; +export { + getMessageStatePrecedence, + MessageState, + shouldApplyMessageStateUpdate, +} from "./domain/MessageState.ts"; export type { ConversationKey, MessageRepository, diff --git a/packages/sdk/src/features/chat/infrastructure/MessageMapper.ts b/packages/sdk/src/features/chat/infrastructure/MessageMapper.ts index 63a97eef8..54af3d678 100644 --- a/packages/sdk/src/features/chat/infrastructure/MessageMapper.ts +++ b/packages/sdk/src/features/chat/infrastructure/MessageMapper.ts @@ -16,6 +16,7 @@ export const MessageMapper = { type: packet.type, text: packet.data, state, + routingError: undefined, }; }, }; diff --git a/packages/sdk/src/features/chat/infrastructure/repositories/InMemoryMessageRepository.test.ts b/packages/sdk/src/features/chat/infrastructure/repositories/InMemoryMessageRepository.test.ts index 58760587b..dc2b1b3c5 100644 --- a/packages/sdk/src/features/chat/infrastructure/repositories/InMemoryMessageRepository.test.ts +++ b/packages/sdk/src/features/chat/infrastructure/repositories/InMemoryMessageRepository.test.ts @@ -1,10 +1,19 @@ +import * as Protobuf from "@meshtastic/protobufs"; import { describe, expect, it } from "vitest"; import { ChannelNumber } from "../../../../core/types.ts"; import type { Message } from "../../domain/Message.ts"; import { MessageState } from "../../domain/MessageState.ts"; import { InMemoryMessageRepository } from "./InMemoryMessageRepository.ts"; -function msg(id: number, ms: number, text = "t"): Message { +const LOCAL_NODE = 100; +const PEER_NODE = 200; + +function msg( + id: number, + ms: number, + text = "t", + state = MessageState.Ack, +): Message { return { id, from: 1, @@ -13,11 +22,45 @@ function msg(id: number, ms: number, text = "t"): Message { rxTime: new Date(ms), type: "broadcast", text, - state: MessageState.Ack, + state, + }; +} + +function directMsg( + id: number, + from: number, + to: number, + state = MessageState.Ack, + routingError?: Protobuf.Mesh.Routing_Error, +): Message { + return { + id, + from, + to, + channel: ChannelNumber.Primary, + rxTime: new Date(1000), + type: "direct", + text: "dm", + state, + routingError, }; } describe("InMemoryMessageRepository", () => { + it("deletes a message by id", async () => { + const repo = new InMemoryMessageRepository(); + await repo.append(msg(1, 1000)); + + await repo.delete(1); + + expect( + await repo.loadRecent( + { kind: "channel", channel: ChannelNumber.Primary }, + 10, + ), + ).toEqual([]); + }); + it("loadRecent returns the tail of a bucket", async () => { const repo = new InMemoryMessageRepository(); await repo.appendBatch([msg(1, 1000), msg(2, 2000), msg(3, 3000)]); @@ -46,13 +89,78 @@ describe("InMemoryMessageRepository", () => { it("updateState mutates the matching message", async () => { const repo = new InMemoryMessageRepository(); - await repo.append(msg(42, 1000)); - await repo.updateState(42, MessageState.Failed); + await repo.append(msg(42, 1000, "t", MessageState.Pending)); + await repo.updateState( + 42, + MessageState.Failed, + Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + ); + const [found] = await repo.loadRecent( + { kind: "channel", channel: ChannelNumber.Primary }, + 1, + ); + expect(found?.state).toBe(MessageState.Failed); + expect(found?.routingError).toBe( + Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + ); + }); + + it("does not downgrade an ack state", async () => { + const repo = new InMemoryMessageRepository(); + await repo.append(msg(43, 1000)); + await repo.updateState(43, MessageState.Relayed); + + const [found] = await repo.loadRecent( + { kind: "channel", channel: ChannelNumber.Primary }, + 1, + ); + expect(found?.state).toBe(MessageState.Ack); + }); + + it("allows actionable failures to replace a relayed state", async () => { + const repo = new InMemoryMessageRepository(); + await repo.append(msg(44, 1000, "t", MessageState.Relayed)); + await repo.updateState( + 44, + MessageState.Failed, + Protobuf.Mesh.Routing_Error.PKI_SEND_FAIL_PUBLIC_KEY, + ); + const [found] = await repo.loadRecent( { kind: "channel", channel: ChannelNumber.Primary }, 1, ); expect(found?.state).toBe(MessageState.Failed); + expect(found?.routingError).toBe( + Protobuf.Mesh.Routing_Error.PKI_SEND_FAIL_PUBLIC_KEY, + ); + }); + + it("keys outbound direct messages by recipient peer", async () => { + const repo = new InMemoryMessageRepository({ localNodeNum: LOCAL_NODE }); + await repo.append( + directMsg(7, LOCAL_NODE, PEER_NODE, MessageState.Pending), + ); + await repo.updateState( + 7, + MessageState.Failed, + Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + ); + + const out = await repo.loadRecent({ kind: "direct", peer: PEER_NODE }, 10); + expect(out).toHaveLength(1); + expect(out[0]?.state).toBe(MessageState.Failed); + expect(out[0]?.routingError).toBe( + Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT, + ); + }); + + it("keys inbound direct messages by sender peer", async () => { + const repo = new InMemoryMessageRepository({ localNodeNum: LOCAL_NODE }); + await repo.append(directMsg(8, PEER_NODE, LOCAL_NODE)); + + const out = await repo.loadRecent({ kind: "direct", peer: PEER_NODE }, 10); + expect(out.map((m) => m.id)).toEqual([8]); }); it("prune enforces maxPerBucket", async () => { diff --git a/packages/sdk/src/features/chat/infrastructure/repositories/InMemoryMessageRepository.ts b/packages/sdk/src/features/chat/infrastructure/repositories/InMemoryMessageRepository.ts index a3f6801ef..954aa57aa 100644 --- a/packages/sdk/src/features/chat/infrastructure/repositories/InMemoryMessageRepository.ts +++ b/packages/sdk/src/features/chat/infrastructure/repositories/InMemoryMessageRepository.ts @@ -1,5 +1,9 @@ +import type * as Protobuf from "@meshtastic/protobufs"; import type { Message } from "../../domain/Message.ts"; -import type { MessageState } from "../../domain/MessageState.ts"; +import { + type MessageState, + shouldApplyMessageStateUpdate, +} from "../../domain/MessageState.ts"; import { type ConversationKey, conversationKeyString, @@ -7,12 +11,21 @@ import { type RetentionPolicy, } from "../../domain/MessageRepository.ts"; +export interface InMemoryMessageRepositoryOptions { + localNodeNum?: number | (() => number | undefined); +} + /** * Default in-memory MessageRepository. No persistence across reloads; useful * for tests and for single-session apps that do not need history. */ export class InMemoryMessageRepository implements MessageRepository { private readonly buckets = new Map(); + private readonly localNodeNum: InMemoryMessageRepositoryOptions["localNodeNum"]; + + constructor(options: InMemoryMessageRepositoryOptions = {}) { + this.localNodeNum = options.localNodeNum; + } async loadRecent(key: ConversationKey, limit: number): Promise { const bucket = this.buckets.get(conversationKeyString(key)) ?? []; @@ -31,27 +44,44 @@ export class InMemoryMessageRepository implements MessageRepository { return bucket.slice(start, end); } - async append(message: Message): Promise { - await this.appendBatch([message]); + async append(message: Message, key?: ConversationKey): Promise { + this.appendToBucket(message, key); } async appendBatch(messages: ReadonlyArray): Promise { for (const message of messages) { - const k = this.inferKey(message); - const bucket = this.buckets.get(k) ?? []; - bucket.push(message); - bucket.sort((a, b) => a.rxTime.getTime() - b.rxTime.getTime()); - this.buckets.set(k, bucket); + this.appendToBucket(message); } } - async updateState(id: number, state: MessageState): Promise { + async updateState( + id: number, + state: MessageState, + routingError?: Protobuf.Mesh.Routing_Error, + ): Promise { for (const bucket of this.buckets.values()) { const idx = bucket.findIndex((m) => m.id === id); if (idx !== -1) { const existing = bucket[idx]; if (!existing) continue; - bucket[idx] = { ...existing, state }; + if (!shouldApplyMessageStateUpdate(existing.state, state)) return; + if ( + existing.state === state && + existing.routingError === routingError + ) { + return; + } + bucket[idx] = { ...existing, state, routingError }; + return; + } + } + } + + async delete(id: number): Promise { + for (const [key, bucket] of this.buckets) { + const next = bucket.filter((message) => message.id !== id); + if (next.length !== bucket.length) { + this.buckets.set(key, next); return; } } @@ -84,9 +114,32 @@ export class InMemoryMessageRepository implements MessageRepository { this.buckets.clear(); } - private inferKey(message: Message): string { - return message.type === "direct" - ? conversationKeyString({ kind: "direct", peer: message.from }) - : conversationKeyString({ kind: "channel", channel: message.channel }); + private appendToBucket(message: Message, key?: ConversationKey): void { + const k = conversationKeyString(key ?? this.inferConversationKey(message)); + const bucket = this.buckets.get(k) ?? []; + bucket.push(message); + bucket.sort((a, b) => a.rxTime.getTime() - b.rxTime.getTime()); + this.buckets.set(k, bucket); + } + + private inferConversationKey(message: Message): ConversationKey { + if (message.type !== "direct") { + return { kind: "channel", channel: message.channel }; + } + + const localNodeNum = this.resolveLocalNodeNum(); + return { + kind: "direct", + peer: + localNodeNum !== undefined && message.from === localNodeNum + ? message.to + : message.from, + }; + } + + private resolveLocalNodeNum(): number | undefined { + return typeof this.localNodeNum === "function" + ? this.localNodeNum() + : this.localNodeNum; } } diff --git a/packages/sdk/src/features/chat/state/chatStore.test.ts b/packages/sdk/src/features/chat/state/chatStore.test.ts new file mode 100644 index 000000000..a316a1e08 --- /dev/null +++ b/packages/sdk/src/features/chat/state/chatStore.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { ChannelNumber } from "../../../core/types.ts"; +import type { Message } from "../domain/Message.ts"; +import { MessageState } from "../domain/MessageState.ts"; +import { ChatStore } from "./chatStore.ts"; + +function message(id: number, type: Message["type"]): Message { + return { + id, + from: 1, + to: type === "direct" ? 2 : 0xffffffff, + channel: ChannelNumber.Primary, + rxTime: new Date(0), + type, + text: "retry me", + state: MessageState.Failed, + }; +} + +describe("ChatStore", () => { + it("deletes a message id from every conversation bucket", () => { + const store = new ChatStore(); + const channel = store.messagesForChannel(ChannelNumber.Primary); + const direct = store.messagesForDirect(2); + store.append( + store.channelKey(ChannelNumber.Primary), + message(42, "broadcast"), + ); + store.append(store.directKey(2), message(42, "direct")); + + expect(store.deleteMessage(42)).toBe(true); + + expect(channel.value).toEqual([]); + expect(direct.value).toEqual([]); + expect(store.deleteMessage(42)).toBe(false); + }); +}); diff --git a/packages/sdk/src/features/chat/state/chatStore.ts b/packages/sdk/src/features/chat/state/chatStore.ts index ea0926076..1edb74ea3 100644 --- a/packages/sdk/src/features/chat/state/chatStore.ts +++ b/packages/sdk/src/features/chat/state/chatStore.ts @@ -1,9 +1,13 @@ +import type * as Protobuf from "@meshtastic/protobufs"; import { type Signal, signal } from "@preact/signals-core"; import type { ReadonlySignal } from "../../../core/signals/createStore.ts"; import { toReadonly } from "../../../core/signals/createStore.ts"; import type { ChannelNumber } from "../../../core/types.ts"; import type { Message } from "../domain/Message.ts"; -import { MessageState } from "../domain/MessageState.ts"; +import { + MessageState, + shouldApplyMessageStateUpdate, +} from "../domain/MessageState.ts"; /** * Messages grouped by conversation bucket. Direct messages are keyed by @@ -73,18 +77,50 @@ export class ChatStore { } } - updateState(id: number, state: MessageState): void { + findMessage(id: number): Message | undefined { + for (const bucket of this.buckets.values()) { + const found = bucket.value.find((m) => m.id === id); + if (found) return found; + } + return undefined; + } + + deleteMessage(id: number): boolean { + let deleted = false; + for (const bucket of this.buckets.values()) { + const next = bucket.value.filter((message) => message.id !== id); + if (next.length !== bucket.value.length) { + bucket.value = next; + deleted = true; + } + } + return deleted; + } + + updateState( + id: number, + state: MessageState, + routingError?: Protobuf.Mesh.Routing_Error, + ): boolean { for (const [, bucket] of this.buckets) { const idx = bucket.value.findIndex((m) => m.id === id); if (idx !== -1) { const next = bucket.value.slice(); const existing = next[idx]; if (!existing) continue; - next[idx] = { ...existing, state }; + if (!shouldApplyMessageStateUpdate(existing.state, state)) return false; + if ( + existing.state === state && + existing.routingError === routingError + ) { + return false; + } + next[idx] = { ...existing, state, routingError }; bucket.value = next; - return; + return true; } } + return false; } private writeBucket(key: string): Signal {