feat(chat): fee floor, real delivery status, pay-from-funded, funds pre-check

Chat sends move 0 value, so the network fee is structurally load-bearing (it's
the only thing that forces a real shielded input; 0-value + 0-fee builds a
degenerate, unrelayable tx). Three gaps addressed:

1. Fee floor — broadcastChatMemos now uses max(getDefaultFee(), kChatMinFeeDrgx),
   so a 0 / too-low global default-fee setting can't silently break chat.

2. Real delivery status — the echo was marked Sent on SUBMIT regardless of the
   on-chain outcome (the z_sendmany callback was empty), so failures were
   invisible and the Retry affordance never fired for async failures. Add a third
   ChatDelivery::Sending state (appended so persisted 0=Sent stays valid); record
   the echo in-memory as Sending, and resolve it to Sent/Failed from the
   z_sendmany completion callback — persisting only the final status (so a restart
   never shows a stuck spinner; a stray persisted Sending loads as Sent). A subtle
   "sending…" label shows while in flight.

3. Pay-from-funded + pre-check — z_sendmany spends from one z-address, and the
   identity reply address may be unfunded while funds sit elsewhere. chatPayFromZaddr
   picks a spendable z-address that can cover the fee (preferring the identity
   address); the memo still advertises the identity address as reply-to, so paying
   from a different note is transport-transparent. If nothing can cover the fee, a
   clear "need a small shielded balance" toast replaces the cryptic failure.

Full node only for the callback path; lite resolves optimistically on queue.
8-language strings + CJK subset (+1 glyph 賄).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:27:43 -05:00
parent c291e8a587
commit ef247c95ff
19 changed files with 133 additions and 22 deletions

Binary file not shown.

View File

@@ -162,9 +162,11 @@
"chat_select_hint": "Wähle eine Unterhaltung aus, um sie anzuzeigen.", "chat_select_hint": "Wähle eine Unterhaltung aus, um sie anzuzeigen.",
"chat_send": "Senden", "chat_send": "Senden",
"chat_send_failed": "nicht gesendet", "chat_send_failed": "nicht gesendet",
"chat_sending": "senden…",
"chat_time_now": "jetzt", "chat_time_now": "jetzt",
"chat_toast_compose_failed": "Nachricht konnte nicht erstellt werden (zu lang?).", "chat_toast_compose_failed": "Nachricht konnte nicht erstellt werden (zu lang?).",
"chat_toast_lite_busy": "Es wird bereits gesendet, oder es ist keine Wallet geöffnet.", "chat_toast_lite_busy": "Es wird bereits gesendet, oder es ist keine Wallet geöffnet.",
"chat_toast_need_funds": "Ein kleines geschütztes Guthaben ist nötig, um Chats zu senden (zur Deckung der Gebühr).",
"chat_toast_no_zaddr": "Keine z-Adresse verfügbar, um den Chat zu senden.", "chat_toast_no_zaddr": "Keine z-Adresse verfügbar, um den Chat zu senden.",
"chat_toast_not_connected": "Nicht verbunden Nachricht nicht gesendet.", "chat_toast_not_connected": "Nicht verbunden Nachricht nicht gesendet.",
"chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).", "chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).",

View File

@@ -162,9 +162,11 @@
"chat_select_hint": "Selecciona una conversación para verla.", "chat_select_hint": "Selecciona una conversación para verla.",
"chat_send": "Enviar", "chat_send": "Enviar",
"chat_send_failed": "no enviado", "chat_send_failed": "no enviado",
"chat_sending": "enviando…",
"chat_time_now": "ahora", "chat_time_now": "ahora",
"chat_toast_compose_failed": "No se pudo componer el mensaje (¿demasiado largo?).", "chat_toast_compose_failed": "No se pudo componer el mensaje (¿demasiado largo?).",
"chat_toast_lite_busy": "Ya hay un envío en curso, o no hay ningún monedero abierto.", "chat_toast_lite_busy": "Ya hay un envío en curso, o no hay ningún monedero abierto.",
"chat_toast_need_funds": "Necesitas un pequeño saldo blindado para enviar chats (para cubrir la comisión).",
"chat_toast_no_zaddr": "No hay ninguna dirección z disponible desde la que enviar el chat.", "chat_toast_no_zaddr": "No hay ninguna dirección z disponible desde la que enviar el chat.",
"chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.", "chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.",
"chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).", "chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).",

View File

@@ -162,9 +162,11 @@
"chat_select_hint": "Sélectionnez une conversation pour l'afficher.", "chat_select_hint": "Sélectionnez une conversation pour l'afficher.",
"chat_send": "Envoyer", "chat_send": "Envoyer",
"chat_send_failed": "non envoyé", "chat_send_failed": "non envoyé",
"chat_sending": "envoi…",
"chat_time_now": "à l'instant", "chat_time_now": "à l'instant",
"chat_toast_compose_failed": "Impossible de composer le message (trop long ?).", "chat_toast_compose_failed": "Impossible de composer le message (trop long ?).",
"chat_toast_lite_busy": "Un envoi est déjà en cours, ou aucun portefeuille n'est ouvert.", "chat_toast_lite_busy": "Un envoi est déjà en cours, ou aucun portefeuille n'est ouvert.",
"chat_toast_need_funds": "Un petit solde blindé est nécessaire pour envoyer des messages (pour couvrir les frais).",
"chat_toast_no_zaddr": "Aucune adresse Z disponible pour envoyer le message.", "chat_toast_no_zaddr": "Aucune adresse Z disponible pour envoyer le message.",
"chat_toast_not_connected": "Non connecté — message non envoyé.", "chat_toast_not_connected": "Non connecté — message non envoyé.",
"chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).", "chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).",

View File

@@ -162,9 +162,11 @@
"chat_select_hint": "表示する会話を選択してください。", "chat_select_hint": "表示する会話を選択してください。",
"chat_send": "送信", "chat_send": "送信",
"chat_send_failed": "未送信", "chat_send_failed": "未送信",
"chat_sending": "送信中…",
"chat_time_now": "たった今", "chat_time_now": "たった今",
"chat_toast_compose_failed": "メッセージを作成できませんでした(長すぎませんか?)。", "chat_toast_compose_failed": "メッセージを作成できませんでした(長すぎませんか?)。",
"chat_toast_lite_busy": "すでに送信処理が進行中か、ウォレットが開かれていません。", "chat_toast_lite_busy": "すでに送信処理が進行中か、ウォレットが開かれていません。",
"chat_toast_need_funds": "チャットを送信するには、手数料を賄うための少額のシールド残高が必要です。",
"chat_toast_no_zaddr": "送信元に使えるZアドレスがありません。", "chat_toast_no_zaddr": "送信元に使えるZアドレスがありません。",
"chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。", "chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。",
"chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。", "chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。",

View File

@@ -162,9 +162,11 @@
"chat_select_hint": "볼 대화를 선택하세요.", "chat_select_hint": "볼 대화를 선택하세요.",
"chat_send": "전송", "chat_send": "전송",
"chat_send_failed": "전송 안 됨", "chat_send_failed": "전송 안 됨",
"chat_sending": "전송 중…",
"chat_time_now": "방금", "chat_time_now": "방금",
"chat_toast_compose_failed": "메시지를 작성할 수 없습니다 (너무 긴가요?).", "chat_toast_compose_failed": "메시지를 작성할 수 없습니다 (너무 긴가요?).",
"chat_toast_lite_busy": "이미 전송이 진행 중이거나 열린 지갑이 없습니다.", "chat_toast_lite_busy": "이미 전송이 진행 중이거나 열린 지갑이 없습니다.",
"chat_toast_need_funds": "채팅을 보내려면 수수료를 낼 소액의 보호 잔액이 필요합니다.",
"chat_toast_no_zaddr": "채팅을 보낼 z-주소가 없습니다.", "chat_toast_no_zaddr": "채팅을 보낼 z-주소가 없습니다.",
"chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.", "chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.",
"chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).", "chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).",

View File

@@ -162,9 +162,11 @@
"chat_select_hint": "Selecione uma conversa para visualizá-la.", "chat_select_hint": "Selecione uma conversa para visualizá-la.",
"chat_send": "Enviar", "chat_send": "Enviar",
"chat_send_failed": "não enviada", "chat_send_failed": "não enviada",
"chat_sending": "enviando…",
"chat_time_now": "agora", "chat_time_now": "agora",
"chat_toast_compose_failed": "Não foi possível compor a mensagem (muito longa?).", "chat_toast_compose_failed": "Não foi possível compor a mensagem (muito longa?).",
"chat_toast_lite_busy": "Já há um envio em andamento, ou nenhuma carteira está aberta.", "chat_toast_lite_busy": "Já há um envio em andamento, ou nenhuma carteira está aberta.",
"chat_toast_need_funds": "É necessário um pequeno saldo blindado para enviar chats (para cobrir a taxa).",
"chat_toast_no_zaddr": "Nenhum endereço-z disponível para enviar o chat.", "chat_toast_no_zaddr": "Nenhum endereço-z disponível para enviar o chat.",
"chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.", "chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.",
"chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).", "chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).",

View File

@@ -162,9 +162,11 @@
"chat_select_hint": "Выберите переписку для просмотра.", "chat_select_hint": "Выберите переписку для просмотра.",
"chat_send": "Отправить", "chat_send": "Отправить",
"chat_send_failed": "не отправлено", "chat_send_failed": "не отправлено",
"chat_sending": "отправка…",
"chat_time_now": "сейчас", "chat_time_now": "сейчас",
"chat_toast_compose_failed": "Не удалось составить сообщение (слишком длинное?).", "chat_toast_compose_failed": "Не удалось составить сообщение (слишком длинное?).",
"chat_toast_lite_busy": "Отправка уже выполняется, или кошелёк не открыт.", "chat_toast_lite_busy": "Отправка уже выполняется, или кошелёк не открыт.",
"chat_toast_need_funds": "Для отправки сообщений нужен небольшой экранированный баланс (для оплаты комиссии).",
"chat_toast_no_zaddr": "Нет доступного Z-адреса для отправки сообщений.", "chat_toast_no_zaddr": "Нет доступного Z-адреса для отправки сообщений.",
"chat_toast_not_connected": "Нет подключения — сообщение не отправлено.", "chat_toast_not_connected": "Нет подключения — сообщение не отправлено.",
"chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).", "chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).",

View File

@@ -162,9 +162,11 @@
"chat_select_hint": "选择一个对话以查看。", "chat_select_hint": "选择一个对话以查看。",
"chat_send": "发送", "chat_send": "发送",
"chat_send_failed": "未发送", "chat_send_failed": "未发送",
"chat_sending": "发送中…",
"chat_time_now": "刚刚", "chat_time_now": "刚刚",
"chat_toast_compose_failed": "无法编写该消息(内容过长?)。", "chat_toast_compose_failed": "无法编写该消息(内容过长?)。",
"chat_toast_lite_busy": "已有发送正在进行中,或未打开任何钱包。", "chat_toast_lite_busy": "已有发送正在进行中,或未打开任何钱包。",
"chat_toast_need_funds": "发送聊天需要少量屏蔽余额(用于支付手续费)。",
"chat_toast_no_zaddr": "没有可用于发送聊天的 z 地址。", "chat_toast_no_zaddr": "没有可用于发送聊天的 z 地址。",
"chat_toast_not_connected": "未连接——聊天消息未发送。", "chat_toast_not_connected": "未连接——聊天消息未发送。",
"chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。", "chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。",

View File

@@ -717,9 +717,14 @@ private:
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
void provisionChatIdentityFromSecret(std::string secret); void provisionChatIdentityFromSecret(std::string secret);
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr chat IDENTITY (reply-to)
// A spendable z-address that can actually PAY the fee (balance >= fee), preferring the identity
// reply address. The reply-to in the memo stays the identity address, so paying from a different
// funded note is transport-transparent. Empty if no z-address can cover the fee.
std::string chatPayFromZaddr(double fee) const;
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos); // returns true if submitted // Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed.
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // true if submitted
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
// Lite first-run welcome prompt: dismissed for the session once the user picks an action. // Lite first-run welcome prompt: dismissed for the session once the user picks an action.

View File

@@ -2885,6 +2885,27 @@ std::string App::chatReplyZaddr()
return best; return best;
} }
std::string App::chatPayFromZaddr(double fee) const
{
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
// spendable z-address that can cover the fee. The memo still advertises the identity address as
// reply-to, so paying from a different note doesn't change who the peer replies to.
std::string reply;
if (settings_) reply = settings_->getChatReplyZaddr();
for (const auto& a : state_.z_addresses)
if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply;
std::string best;
double bestBal = -1.0;
for (const auto& a : state_.z_addresses)
if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) {
best = a.address;
bestBal = a.balance;
}
return best; // empty → no z-address can cover the fee
}
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for // A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
// new conversation ids. Random — collisions are astronomically unlikely. // new conversation ids. Random — collisions are astronomically unlikely.
std::string App::generateChatLocalId(const char* prefix, int numBytes) const std::string App::generateChatLocalId(const char* prefix, int numBytes) const
@@ -2904,21 +2925,35 @@ std::string App::generateChatLocalId(const char* prefix, int numBytes) const
// LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim // LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim
// on receive, that recipient-array order maps to note position (the receive pairing needs the header // on receive, that recipient-array order maps to note position (the receive pairing needs the header
// at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop. // at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos) // A chat send moves 0 value, so the fee is the ONLY thing that forces a real shielded input — a
// 0-value + 0-fee tx builds a degenerate, unrelayable tx (see z_sendmany targetAmount). Never let the
// global default-fee setting drop chat below a working minimum (well above the node's min relay fee).
static constexpr double kChatMinFeeDrgx = 0.0001;
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId)
{ {
if (memos.recipientZaddr.empty()) return false; if (memos.recipientZaddr.empty()) return false;
if (lite_wallet_) { if (lite_wallet_) {
return broadcastChatMemosLite(memos); const bool ok = broadcastChatMemosLite(memos);
// The lite backend has no per-send completion callback here; resolve optimistically on a
// successful queue (its own broadcast log surfaces a later failure).
if (ok) chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
return ok;
} }
if (!state_.connected || !rpc_ || !worker_) { if (!state_.connected || !rpc_ || !worker_) {
ui::Notifications::instance().error(TR("chat_toast_not_connected")); ui::Notifications::instance().error(TR("chat_toast_not_connected"));
return false; return false;
} }
const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address
const double fee = std::max(settings_ ? settings_->getDefaultFee() : kChatMinFeeDrgx, kChatMinFeeDrgx);
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity
// reply address, so the peer replies to the right place regardless of which note paid.
const std::string from = chatPayFromZaddr(fee);
if (from.empty()) { if (from.empty()) {
ui::Notifications::instance().error(TR("chat_toast_no_zaddr")); ui::Notifications::instance().error(TR("chat_toast_need_funds"));
return false; return false;
} }
@@ -2933,12 +2968,16 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
recipients.push_back(std::move(recipient)); recipients.push_back(std::move(recipient));
} }
const double fee = settings_ ? settings_->getDefaultFee() : 0.0001;
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a // markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output. // SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status.
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients, submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
"HushChat / broadcast", /*markFeeGapRetry*/ true, /*callback*/{}); "HushChat / broadcast", /*markFeeGapRetry*/ true,
return true; // submitted (async build/broadcast; a later failure surfaces via the opid poller) [this, echoLocalId](bool ok, const std::string& /*result*/) {
chat_service_.resolveOutgoing(echoLocalId,
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
});
return true; // submitted (async build/broadcast; the callback resolves the final status)
} }
// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does // Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does
@@ -2997,8 +3036,6 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
ui::Notifications::instance().error(TR("chat_toast_compose_failed")); ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
return; return;
} }
const bool submitted = broadcastChatMemos(memos);
chat::ChatMessage echo; chat::ChatMessage echo;
echo.direction = chat::ChatDirection::Outgoing; echo.direction = chat::ChatDirection::Outgoing;
echo.kind = chat::ChatMessageKind::Message; echo.kind = chat::ChatMessageKind::Message;
@@ -3009,8 +3046,12 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
echo.timestamp = std::time(nullptr); echo.timestamp = std::time(nullptr);
echo.txid = generateChatLocalId("out:", 8); echo.txid = generateChatLocalId("out:", 8);
echo.payload_position = 0; echo.payload_position = 0;
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed; echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
chat_service_.recordOutgoing(echo); chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry
// affordance appears; otherwise the async callback flips it to Sent/Failed.
if (!broadcastChatMemos(memos, echo.txid))
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
} }
void App::startChatConversation(const std::string& peerZaddr, const std::string& text) void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
@@ -3030,8 +3071,6 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
ui::Notifications::instance().error(TR("chat_toast_request_compose_failed")); ui::Notifications::instance().error(TR("chat_toast_request_compose_failed"));
return; return;
} }
const bool submitted = broadcastChatMemos(memos);
chat::ChatMessage echo; chat::ChatMessage echo;
echo.direction = chat::ChatDirection::Outgoing; echo.direction = chat::ChatDirection::Outgoing;
echo.kind = chat::ChatMessageKind::ContactRequest; echo.kind = chat::ChatMessageKind::ContactRequest;
@@ -3041,9 +3080,12 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
echo.timestamp = std::time(nullptr); echo.timestamp = std::time(nullptr);
echo.txid = generateChatLocalId("out:", 8); echo.txid = generateChatLocalId("out:", 8);
echo.payload_position = 0; echo.payload_position = 0;
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed; echo.delivery = chat::ChatDelivery::Sending;
chat_service_.recordOutgoing(echo); chat_service_.recordOutgoingPending(echo);
if (submitted) ui::Notifications::instance().success(TR("chat_toast_request_queued")); if (broadcastChatMemos(memos, echo.txid))
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
else
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
} }
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's // HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's

View File

@@ -267,7 +267,10 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
out.body = parsed.value("b", std::string()); out.body = parsed.value("b", std::string());
out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0)); out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0));
out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0))); out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0)));
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent (0)
// A persisted "Sending" means we crashed mid-broadcast; the outcome is unknown. Resolve it
// optimistically to Sent on load so it can't show a stuck spinner forever.
if (out.delivery == ChatDelivery::Sending) out.delivery = ChatDelivery::Sent;
return true; return true;
} catch (const std::exception&) { } catch (const std::exception&) {
return false; return false;

View File

@@ -10,9 +10,11 @@ namespace dragonx::chat {
enum class ChatDirection { Incoming, Outgoing }; enum class ChatDirection { Incoming, Outgoing };
enum class ChatMessageKind { Message, ContactRequest }; enum class ChatMessageKind { Message, ContactRequest };
// Outgoing delivery: Sent = the broadcast was submitted; Failed = it wasn't (not connected, no // Outgoing delivery status. Sending = broadcast in flight (async op not yet resolved); Sent = the
// spendable address, a send already in progress). Always Sent for incoming. // daemon accepted + broadcast the tx; Failed = it didn't (not connected, no funded address, rejected).
enum class ChatDelivery { Sent, Failed }; // Always Sent for incoming. NB: the values are persisted (chat DB serializes the int), so Sent MUST
// stay 0 and new states are APPENDED — never reordered.
enum class ChatDelivery { Sent, Failed, Sending };
struct ChatMessage { struct ChatMessage {
ChatDirection direction = ChatDirection::Incoming; ChatDirection direction = ChatDirection::Incoming;

View File

@@ -109,4 +109,15 @@ bool ChatService::recordOutgoing(const ChatMessage& message) {
return false; return false;
} }
bool ChatService::recordOutgoingPending(const ChatMessage& message) {
// In-memory only — deliberately NOT persisted yet, so "Sending" never survives to a restart; the
// row is written by resolveOutgoing() once the broadcast resolves to Sent/Failed.
return store_.append(message);
}
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) {
const ChatMessage* updated = store_.updateDelivery(txid, delivery);
if (updated && db_) db_->append(*updated); // first persist, carrying the final status
}
} // namespace dragonx::chat } // namespace dragonx::chat

View File

@@ -76,6 +76,12 @@ public:
// only local record of what we sent.) // only local record of what we sent.)
bool recordOutgoing(const ChatMessage& message); bool recordOutgoing(const ChatMessage& message);
// Two-phase echo for delivery tracking: record in-memory only (shows immediately as Sending),
// then resolveOutgoing() flips the status once the broadcast completes AND persists it with the
// final status — so a restart never shows a stuck "Sending".
bool recordOutgoingPending(const ChatMessage& message);
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
const ChatStore& store() const { return store_; } const ChatStore& store() const { return store_; }
ChatStore& store() { return store_; } ChatStore& store() { return store_; }

View File

@@ -34,6 +34,16 @@ std::vector<ChatMessage> ChatStore::conversation(const std::string& conversation
return out; return out;
} }
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) {
for (auto& message : messages_) {
if (message.txid == txid) {
message.delivery = delivery;
return &message;
}
}
return nullptr;
}
std::vector<std::string> ChatStore::conversationIds() const { std::vector<std::string> ChatStore::conversationIds() const {
std::vector<std::string> ids; std::vector<std::string> ids;
std::unordered_set<std::string> seenIds; std::unordered_set<std::string> seenIds;

View File

@@ -21,6 +21,10 @@ public:
// Messages in a conversation, in insertion order. // Messages in a conversation, in insertion order.
std::vector<ChatMessage> conversation(const std::string& conversationId) const; std::vector<ChatMessage> conversation(const std::string& conversationId) const;
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
// message (for the caller to persist), or nullptr if no message has that txid.
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery);
// Distinct conversation ids, in first-seen order. // Distinct conversation ids, in first-seen order.
std::vector<std::string> conversationIds() const; std::vector<std::string> conversationIds() const;

View File

@@ -460,6 +460,7 @@ void RenderChatTab(App* app)
const bool outgoing = (m.direction == chat::ChatDirection::Outgoing); const bool outgoing = (m.direction == chat::ChatDirection::Outgoing);
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest); const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed; const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending;
const std::int64_t minute = m.timestamp / 60; const std::int64_t minute = m.timestamp / 60;
const bool startGroup = (prevDir != static_cast<int>(outgoing)) || (minute != prevMin); const bool startGroup = (prevDir != static_cast<int>(outgoing)) || (minute != prevMin);
@@ -514,6 +515,15 @@ void RenderChatTab(App* app)
ImGui::SameLine(); ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_retry"))) { app->sendChatMessage(sel->cid, m.body); s_scroll_to_cid = sel->cid; } if (ImGui::SmallButton(TR("chat_retry"))) { app->sendChatMessage(sel->cid, m.body); s_scroll_to_cid = sel->cid; }
} }
// In-flight send → subtle right-aligned "sending…"; the op callback resolves it.
if (sending) {
ImGui::Dummy(ImVec2(0.0f, 1.0f * dp));
const float sw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_sending")).x;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - sw));
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextUnformatted(TR("chat_sending"));
ImGui::PopStyleColor(); ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0.0f, 5.0f * dp)); ImGui::Dummy(ImVec2(0.0f, 5.0f * dp));
ImGui::PopID(); ImGui::PopID();
prevMin = minute; prevDir = static_cast<int>(outgoing); prevMin = minute; prevDir = static_cast<int>(outgoing);

View File

@@ -236,6 +236,8 @@ void I18n::loadBuiltinEnglish()
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?)."; strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?)."; strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
strings_["chat_toast_request_queued"] = "Contact request queued."; strings_["chat_toast_request_queued"] = "Contact request queued.";
strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee).";
strings_["chat_sending"] = "sending\xE2\x80\xA6";
strings_["chat_time_now"] = "now"; strings_["chat_time_now"] = "now";
strings_["chat_retry"] = "Retry"; strings_["chat_retry"] = "Retry";
strings_["chat_jump_latest"] = "Latest"; strings_["chat_jump_latest"] = "Latest";