eleven / ocho Public

web: clear the typing indicator promptly (don't linger after a message)

'X is typing…' hung around on recipients for up to the ~15s beat expiry after the
sender sent or stopped. Now:
- receiving a message from someone clears their typing immediately (guaranteed
  signal — it's the actual message),
- stopTyping() sends an explicit 'stopped' beat (expiry 0) when you stop/send,
- a 0-expiry beat clears that sender on receivers right away.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell bfb04e5716b07cf9d5c5e538f73fbdc5799d7337 parent a6e6659
1 file changed, +16 −4
  • web/static/chat.js +16 −4
diff --git a/web/static/chat.js b/web/static/chat.js
index 4fe08ab..d3f5599 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -827,6 +827,9 @@ async function appendMessage(threadId, m, scroll = true) {
return; // can't decrypt — skip
}
if (threadId !== activeId) return; // switched threads mid-decrypt — don't bleed into the new feed
+ // Their message just arrived → they're no longer typing. Clear it immediately
+ // rather than waiting out the typing-beat expiry (the guaranteed signal).
+ if (data.name && typingUntil.delete(data.name)) renderTyping();
const feed = document.getElementById("feed");
const mine = data.name === session.name;
const t = threads.find((x) => x.id === threadId);
@@ -1866,30 +1869,39 @@ async function onTyping(msg) {
}
if (!payload || !payload.name || payload.name === session.name) return;
const secs = Math.min(Math.max(Number(payload.expiry) || 0, 0), 60); // clamp; bad input → 0
- if (!secs) return;
+ if (secs <= 0) {
+ // expiry 0 = an explicit "stopped typing" beat — clear this sender now.
+ if (typingUntil.delete(payload.name)) renderTyping();
+ return;
+ }
typingUntil.set(payload.name, Date.now() + secs * 1000);
renderTyping();
ensureTypingSweep();
}
-// Broadcast one typing beat for the open thread, encrypted with its key.
-async function sendTyping() {
+// Broadcast one typing beat for the open thread, encrypted with its key. expiry 0
+// is a "stopped typing" signal that clears the indicator on receivers immediately.
+async function sendTyping(expiry = TYPING_EXPIRY_S) {
if (!ws || ws.readyState !== WebSocket.OPEN || !activeId) return;
const key = threadKeys[activeId];
if (!key) return;
const blob = await encrypt(key, {
type: MESSAGE_TYPE_TYPING,
name: session.name,
- expiry: TYPING_EXPIRY_S,
+ expiry,
});
ws.send(JSON.stringify({ type: "typing", thread_id: activeId, blob }));
}
function stopTyping() {
+ const wasTyping = !!(typingBeat || typingIdle);
if (typingBeat) clearInterval(typingBeat);
if (typingIdle) clearTimeout(typingIdle);
typingBeat = null;
typingIdle = null;
+ // If we'd been typing, tell receivers we've stopped so they clear the indicator
+ // now instead of waiting out the ~15s beat expiry.
+ if (wasTyping) sendTyping(0);
}
// Called on each keystroke while there's text: beat now if we weren't already,