diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 5fb4219c..48bf4922 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -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 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( &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 diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 275ca97c..c15324bd 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -723,6 +723,12 @@ function closeChat() { } 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) { await sendArchMessage() nextTick(() => scrollChatToBottom()) @@ -1711,7 +1717,8 @@ function isImageMime(mime?: string): boolean { :disabled="(!messageText.trim() && !pendingAttachment) || mesh.sending || sendingArch" @click="handleSendMessage" > - {{ (mesh.sending || sendingArch) ? '...' : (pendingReply ? 'Reply' : (pendingAttachment ? 'Share' : 'Send')) }} + + diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css index 250afd1f..55048724 100644 --- a/neode-ui/src/views/mesh/mesh-styles.css +++ b/neode-ui/src/views/mesh/mesh-styles.css @@ -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:focus { border-color: rgba(251, 146, 60, 0.4); } .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-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; } @media (max-width: 1279px) {