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')) }}
+
+ {{ 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) {