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>
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, |