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 && (
+
-
+
+ {messageStatusInfo.displayText}
- )}
-
-
- {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 {