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 e741f7eb13
commit 902e730bd2
3 changed files with 57 additions and 20 deletions

View File

@@ -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')) }}
<span v-if="mesh.sending || sendingArch" class="mesh-send-spinner" aria-label="Sending"></span>
<template v-else>{{ pendingReply ? 'Reply' : (pendingAttachment ? 'Share' : 'Send') }}</template>
</button>
</div>
</div>

View File

@@ -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) {