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:
parent
e741f7eb13
commit
902e730bd2
@ -782,6 +782,14 @@ impl MeshService {
|
|||||||
/// This does NOT use chunking and does NOT go through the mesh radio —
|
/// 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
|
/// it is a straight HTTP POST over Tor to the peer's
|
||||||
/// `/archipelago/mesh-typed` endpoint.
|
/// `/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 1–5s 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(
|
pub async fn send_typed_wire_via_federation(
|
||||||
&self,
|
&self,
|
||||||
contact_id: u32,
|
contact_id: u32,
|
||||||
@ -814,25 +822,45 @@ impl MeshService {
|
|||||||
"signature": signature,
|
"signature": signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
let proxy = reqwest::Proxy::all(crate::constants::TOR_SOCKS_PROXY)
|
// Record Sent now so the UI bubble appears immediately.
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid Tor proxy: {}", e))?;
|
let msg = self
|
||||||
let client = reqwest::Client::builder()
|
.record_sent_typed(contact_id, type_label, display_text, typed_payload, sender_seq)
|
||||||
|
.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)
|
.proxy(proxy)
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| anyhow::anyhow!("HTTP client build failed: {}", e))?;
|
{
|
||||||
let resp = client
|
Ok(c) => c,
|
||||||
.post(&url)
|
Err(e) => {
|
||||||
.json(&body)
|
warn!(contact_id, "HTTP client build failed: {}", e);
|
||||||
.send()
|
return;
|
||||||
.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_typed(contact_id, type_label, display_text, typed_payload, sender_seq)
|
match client.post(&url).json(&body).send().await {
|
||||||
.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
|
/// Inject a typed envelope received over federation (Tor) into MeshState
|
||||||
|
|||||||
@ -723,6 +723,12 @@ function closeChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendMessage() {
|
async function handleSendMessage() {
|
||||||
|
// Single-flight guard: the input's `@keydown.enter` fires per keydown, so a
|
||||||
|
// repeating/held Enter or a rapid Enter→click before the button's disabled
|
||||||
|
// state flips queues a second mesh.send against the same text. That's how
|
||||||
|
// every bubble was showing up twice — sender transmitted the same envelope
|
||||||
|
// twice, receiver stored both. Bail if a send is already in flight.
|
||||||
|
if (mesh.sending || sendingArch.value) return
|
||||||
if (archChannelActive.value) {
|
if (archChannelActive.value) {
|
||||||
await sendArchMessage()
|
await sendArchMessage()
|
||||||
nextTick(() => scrollChatToBottom())
|
nextTick(() => scrollChatToBottom())
|
||||||
@ -1711,7 +1717,8 @@ function isImageMime(mime?: string): boolean {
|
|||||||
:disabled="(!messageText.trim() && !pendingAttachment) || mesh.sending || sendingArch"
|
:disabled="(!messageText.trim() && !pendingAttachment) || mesh.sending || sendingArch"
|
||||||
@click="handleSendMessage"
|
@click="handleSendMessage"
|
||||||
>
|
>
|
||||||
{{ (mesh.sending || sendingArch) ? '...' : (pendingReply ? 'Reply' : (pendingAttachment ? 'Share' : 'Send')) }}
|
<span v-if="mesh.sending || sendingArch" class="mesh-send-spinner" aria-label="Sending"></span>
|
||||||
|
<template v-else>{{ pendingReply ? 'Reply' : (pendingAttachment ? 'Share' : 'Send') }}</template>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -117,8 +117,10 @@
|
|||||||
.mesh-chat-input { flex: 1; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 20px; color: rgba(255, 255, 255, 0.9); padding: 10px 16px; font-size: 0.9rem; font-family: inherit; outline: none; }
|
.mesh-chat-input { flex: 1; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 20px; color: rgba(255, 255, 255, 0.9); padding: 10px 16px; font-size: 0.9rem; font-family: inherit; outline: none; }
|
||||||
.mesh-chat-input:focus { border-color: rgba(251, 146, 60, 0.4); }
|
.mesh-chat-input:focus { border-color: rgba(251, 146, 60, 0.4); }
|
||||||
.mesh-chat-input::placeholder { color: rgba(255, 255, 255, 0.25); }
|
.mesh-chat-input::placeholder { color: rgba(255, 255, 255, 0.25); }
|
||||||
.mesh-chat-send-btn { padding: 10px 20px; border-radius: 20px; font-size: 0.85rem; background: rgba(251, 146, 60, 0.15); border-color: rgba(251, 146, 60, 0.25); }
|
.mesh-chat-send-btn { padding: 10px 20px; border-radius: 20px; font-size: 0.85rem; background: rgba(251, 146, 60, 0.15); border-color: rgba(251, 146, 60, 0.25); min-width: 72px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
.mesh-chat-send-btn:hover:not(:disabled) { background: rgba(251, 146, 60, 0.25); }
|
.mesh-chat-send-btn:hover:not(:disabled) { background: rgba(251, 146, 60, 0.25); }
|
||||||
|
.mesh-send-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255, 255, 255, 0.2); border-top-color: rgba(251, 146, 60, 0.9); border-radius: 50%; animation: mesh-send-spin 0.7s linear infinite; }
|
||||||
|
@keyframes mesh-send-spin { to { transform: rotate(360deg); } }
|
||||||
.mesh-mobile-back-btn { display: none; }
|
.mesh-mobile-back-btn { display: none; }
|
||||||
|
|
||||||
@media (max-width: 1279px) {
|
@media (max-width: 1279px) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user