eleven / messenger Public

web: render the typing indicator in the stream (+ typing beats from iOS) (#89)

"X is typing…" used to live in a band between the feed and the composer.
Toggling it resized the feed — jolting the stream and leaving a gap on
web/desktop — and in the native shells (which float their composer over
the page) it was covered entirely, or peeked out below the bar in the
home-indicator zone.

It now renders as the feed's last row, below the newest message: it
scrolls with the conversation, never resizes anything, and sits clear of
the native composer via the feed's inset padding. Live appends insert
above it so it stays glued to the bottom.

The native bridge also gains lchatNative.typing(hasText): the iOS
composer never broadcast typing beats at all, so iPhone typists were
invisible to everyone else. ChatView calls it on every keystroke;
chat.js paces the encrypted beats as before.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9dacc8c705f4070ee60dbecac7a2598bd186a5ac parent 105c1b1
5 files changed, +53 −15
  • changelog/2026-07-02-typing-in-stream.md +1 −0
  • ios/LChat/Views/ChatView.swift +12 −1
  • web/pages/index.html +0 −1
  • web/static/chat.js +31 −9
  • web/static/style.css +9 −4
diff --git a/changelog/2026-07-02-typing-in-stream.md b/changelog/2026-07-02-typing-in-stream.md
new file mode 100644
index 0000000..fa5c014
--- /dev/null
+++ b/changelog/2026-07-02-typing-in-stream.md
@@ -0,0 +1 @@
+Improved: "… is typing" now appears right in the conversation, below the newest message — so it no longer nudges the chat around, and it finally shows up in the iPhone app. Typing from the iPhone app now shows the indicator to everyone else, too.
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 47ee55d..ac1c60b 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -145,7 +145,10 @@ struct ChatView: View {
.onChange(of: bridge.replying) { _, r in
if r != nil { composerFocused = true } // mirror the web: replying focuses the input
}
- .onChange(of: text) { _, _ in bridge.updateTypeahead(text: text, caretUTF16: caret) }
+ .onChange(of: text) { _, _ in
+ bridge.updateTypeahead(text: text, caretUTF16: caret)
+ bridge.noteTyping(hasText: !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
.onChange(of: caret) { _, _ in bridge.updateTypeahead(text: text, caretUTF16: caret) }
}
@@ -398,6 +401,14 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
}
}
+ // Mirrors the web composer's per-keystroke typing beat: while there's text we
+ // keep this thread's "X is typing…" alive on other members' screens; an emptied
+ // field (or a send) stops it immediately. chat.js paces the actual broadcasts.
+ func noteTyping(hasText: Bool) {
+ webView?.evaluateJavaScript(
+ "window.lchatNative && window.lchatNative.typing && window.lchatNative.typing(\(hasText))")
+ }
+
// Forwards typed text to chat.js's window.lchatNative.sendText. JSON-encoding the
// string safely escapes quotes/newlines/unicode before it crosses into JS. The
// completion reports whether the web layer accepted it (socket open, hook present,
diff --git a/web/pages/index.html b/web/pages/index.html
index d4c989e..e72c077 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -121,7 +121,6 @@
<div class="composer-float">
<div class="composer-preview hidden" id="composer-preview"></div>
</div>
- <div class="typing hidden" id="typing" aria-live="polite"></div>
<div class="typeahead hidden" id="typeahead" role="listbox"></div>
<div class="composer-reply hidden" id="composer-reply"></div>
<div class="composer-edit hidden" id="composer-edit"></div>
diff --git a/web/static/chat.js b/web/static/chat.js
index 499d277..fce9f8e 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -314,6 +314,7 @@ const typingUntil = new Map();
let typingSweep = null;
let typingBeat = null;
let typingIdle = null;
+let typingEl = null; // the in-feed "X is typing…" row (created lazily by renderTyping)
const app = document.getElementById("app");
// The native shell (iOS/macOS) injects window.__lchatEmbed at documentStart with
@@ -469,8 +470,15 @@ function installNativeBridge() {
const t = (text || "").trim();
if (!t && !pending.length) return false;
sendMessage(t);
+ stopTyping(); // sending means you've stopped typing (mirrors the web submit)
return true;
},
+ // The native composer's keystrokes: beat "typing" to the thread while there's
+ // text, stop when it empties (mirrors the web composer's input handler).
+ typing(hasText) {
+ if (hasText) noteTyping();
+ else stopTyping();
+ },
// Stage a photo picked by the native + button (base64, standard alphabet). The
// web composer only handles images; anything else is rejected with an error.
stageAttachment(att) {
@@ -1790,7 +1798,10 @@ async function appendMessage(threadId, m, scroll = true, container = null, cache
st.msgEls[m.id] = wrap;
st.msgData[m.id] = { mine, createdAt: m.created_at, data, replyMeta };
if (scroll) wrap.classList.add("appear"); // live send/receive eases in; history doesn't
- target.appendChild(wrap);
+ // A live append lands above the in-feed typing row (someone else may still be
+ // typing) so the indicator stays glued below the newest message.
+ if (target === feed && typingEl && typingEl.parentNode === feed) feed.insertBefore(wrap, typingEl);
+ else target.appendChild(wrap);
refreshSeparator(wrap); // centered time separator above, if the gap warrants
refreshSenderName(wrap); // hide a repeated "them" name within a run
if (scroll) {
@@ -3160,17 +3171,28 @@ function bumpThread(id) {
// The name + expiry ride encrypted inside the blob; the server is a blind relay
// (see hub.go's "typing" case) and fans it only to the thread's other members.
-// Paint the "X is typing…" line from the live set, keeping the latest message
-// visible as the line appears/disappears below the feed.
+// Paint the "X is typing…" line from the live set. It lives INSIDE the feed as
+// its last row (appended below the newest message) rather than in a band between
+// feed and composer: a band resized the feed as it toggled — jolting the stream —
+// and the native shells (iOS/macOS) float their composer over the page bottom,
+// which covered it entirely. In-stream it scrolls with the messages.
function renderTyping() {
- const typingEl = document.getElementById("typing");
- if (!typingEl) return;
- const feed = document.getElementById("feed");
- const wasAtBottom = feed && feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
const names = [...typingUntil.keys()];
+ if (names.length === 0) {
+ if (typingEl) typingEl.remove();
+ return;
+ }
+ const feed = document.getElementById("feed");
+ if (!feed) return;
+ if (!typingEl) {
+ typingEl = document.createElement("div");
+ typingEl.className = "typing";
+ typingEl.setAttribute("aria-live", "polite");
+ }
+ const wasAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
typingEl.textContent = typingSummary(names); // textContent: names are untrusted
- typingEl.classList.toggle("hidden", names.length === 0);
- if (wasAtBottom && feed) feed.scrollTop = feed.scrollHeight;
+ feed.appendChild(typingEl); // (re-)appending keeps it below the newest message
+ if (wasAtBottom) feed.scrollTop = feed.scrollHeight;
}
// Prune lapsed typers once a second, but only while someone's typing — the timer
diff --git a/web/static/style.css b/web/static/style.css
index 3be2160..65d8eba 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -853,18 +853,23 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
.reply-bar-cancel svg { width: 15px; height: 15px; }
.reply-bar-cancel:hover { background: var(--bubble-them); }
-/* Subtle "… is typing" line, just above the composer. Transient presence only. */
+/* Subtle "… is typing" line. It renders INSIDE the feed, below the newest
+ message (chat.js appends/removes it), not in a band between feed and
+ composer: a band resized the feed as it toggled — jolting the stream — and
+ the native shells float their composer over the page bottom, which covered
+ it entirely. In-stream it scrolls with the messages, like an incoming one. */
.typing {
- padding: 2px 16px 6px;
+ align-self: flex-start;
+ max-width: 78%;
+ margin-top: 10px;
+ padding: 0 6px;
font-size: 12px;
font-style: italic;
color: var(--muted);
- background: var(--panel);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.typing.hidden { display: none; }
/* composer autocomplete (:emoji / @mention) — sits above the composer, in flow */
.typeahead {