fix(mesh): single-flight send + spinner + async federation POST

Root cause of the "every bubble shows twice" complaint after the prior
dedup fix: the frontend was firing mesh.send twice per user action. A
held/repeating Enter key on the input fires a keydown per repeat, and
handleSendMessage didn't guard on mesh.sending, so both calls queued
through the store's sendQueue and both executed against the same
contact_id (backend logs show two mesh.send RPCs 13ms apart, same text).
That's why sender and receiver both saw doubles — the envelope actually
was transmitted twice.

Mesh.vue: handleSendMessage now early-returns if mesh.sending or
sendingArch is already set. Send button replaces the `...` placeholder
with a proper spinning ring (`.mesh-send-spinner`) so the held-Enter case
stops looking like the app is ignoring the user.

mesh/mod.rs: send_typed_wire_via_federation no longer blocks on the Tor
POST. Sent MeshMessage is recorded synchronously (UI bubble appears
instantly); the HTTP goes in tokio::spawn. Tor circuit setup was the
1–5s lag the user was seeing on every send to a federation peer. Delivery
failure still shows as `delivered: false` via the read-receipt path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-18 15:57:11 -04:00
parent 7e4fed7967
commit 3a52c766ac
3 changed files with 57 additions and 20 deletions

View File

@@ -782,6 +782,14 @@ impl MeshService {
/// This does NOT use chunking and does NOT go through the mesh radio —
/// it is a straight HTTP POST over Tor to the peer's
/// `/archipelago/mesh-typed` endpoint.
///
/// The POST itself is fire-and-forget: we record the Sent MeshMessage
/// synchronously (so the UI sees the bubble immediately) and spawn the
/// Tor HTTP in the background. Tor circuit setup is 15s per envelope
/// and blocking the RPC on it made `mesh.send` feel laggy — especially
/// over a held Enter key. Delivery failures still surface via the
/// absent read-receipt path: `delivered` stays `false` on the Sent
/// record if the peer never echoes back a receipt.
pub async fn send_typed_wire_via_federation(
&self,
contact_id: u32,
@@ -814,25 +822,45 @@ impl MeshService {
"signature": signature,
});
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
.map_err(|e| anyhow::anyhow!("Invalid Tor proxy: {}", e))?;
let client = reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| anyhow::anyhow!("HTTP client build failed: {}", e))?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("Federation POST failed: {}", e))?;
if !resp.status().is_success() {
anyhow::bail!("Peer rejected typed envelope: HTTP {}", resp.status());
}
Ok(self
// Record Sent now so the UI bubble appears immediately.
let msg = self
.record_sent_typed(contact_id, type_label, display_text, typed_payload, sender_seq)
.await)
.await;
// Fire the Tor POST in the background. Failures are logged but do
// not propagate — the caller has already been handed the Sent
// MeshMessage and the UI's delivery indicator tracks the receipt.
tokio::spawn(async move {
let proxy = match reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY) {
Ok(p) => p,
Err(e) => {
warn!(contact_id, "Invalid Tor proxy: {}", e);
return;
}
};
let client = match reqwest::Client::builder()
.proxy(proxy)
.timeout(std::time::Duration::from_secs(120))
.build()
{
Ok(c) => c,
Err(e) => {
warn!(contact_id, "HTTP client build failed: {}", e);
return;
}
};
match client.post(&url).json(&body).send().await {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => warn!(
contact_id,
status = %resp.status(),
"Peer rejected federation-routed envelope"
),
Err(e) => warn!(contact_id, "Federation POST failed: {}", e),
}
});
Ok(msg)
}
/// Inject a typed envelope received over federation (Tor) into MeshState