eleven / messenger Public

feat: ephemeral "typing" indicators across server, web and iOS (#14)

Clients broadcast a "typing" frame while composing (re-sent every 10s with a
15s expiry). The server relays it as opaque ciphertext without ever storing or
pushing it — a transient control frame alongside the existing "active"/"ping".
Receivers decrypt the sender name + expiry, replace any prior hint from that
sender, and clear it once the expiry lapses (no explicit "stopped" message).

- hub.go: relay-only "typing" case (no DB write, no push)
- web: send beat + idle stop; subtle indicator above the composer
- iOS: ChatSocket/ChatStore/ChatView parity; shared {type,name,expiry} blob
- format.js + Models.swift: testable typingSummary mirror
Co-authored-by: Claude <noreply@anthropic.com>
James Adam c9129f3752810c19b4cb28e741c98368bd8e1410 parent 8229f64
11 files changed, +324 −4
  • hub.go +25 −1
  • ios/LChat/ChatSocket.swift +10 −0
  • ios/LChat/ChatStore.swift +89 −0
  • ios/LChat/Models.swift +25 −0
  • ios/LChat/Views/ChatView.swift +18 −0
  • test/format.test.mjs +19 −1
  • web/pages/index.html +1 −0
  • web/static/chat.js +106 −2
  • web/static/format.js +12 −0
  • web/static/message.js +6 −0
  • web/static/style.css +13 −0
diff --git a/hub.go b/hub.go
index 29bff21..bcfd440 100644
--- a/hub.go
+++ b/hub.go
@@ -103,6 +103,14 @@ type wsReactionOut struct {
Reactions []ReactionCount `json:"reactions"`
}
+// wsTypingOut relays a typing indicator. Like everything else it carries only an
+// opaque blob (the sender's name + expiry ride encrypted inside) — the server
+// never stores it and never pushes it; it's a transient presence hint.
+type wsTypingOut struct {
+ Type string `json:"type"`
+ Blob string `json:"blob"`
+}
+
// allowedReactions is the fixed emoji set the UI offers. Restricting it keeps
// the (plaintext) emoji column to known values — no arbitrary strings.
var allowedReactions = map[string]bool{
@@ -158,7 +166,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
s.markActive(token) // this device is open + focused; mute its siblings' push
continue
}
- if in.Type != "msg" && in.Type != "edit" && in.Type != "react" && in.Type != "unreact" {
+ if in.Type != "msg" && in.Type != "edit" && in.Type != "react" && in.Type != "unreact" && in.Type != "typing" {
continue
}
// Re-check the credential so a revoked invite / removed device can't keep
@@ -168,6 +176,22 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
}
switch in.Type {
+ case "typing":
+ // Transient presence hint: relay the opaque blob to everyone (the
+ // sender ignores its own echo) without storing or pushing it. The
+ // receiver decrypts the name + expiry and shows it briefly.
+ //
+ // This is a dedicated relay-only frame, like "active"/"ping" — typing
+ // isn't message-shaped (no id, created_at, reactions). If more
+ // ephemeral kinds appear later, consider generalising to a plaintext
+ // `ephemeral` flag on the frame (read by the server to skip storage —
+ // it must live on the envelope, not the ciphertext, which stays E2E).
+ if in.Blob == "" || len(in.Blob) > maxBlobBytes {
+ continue
+ }
+ out, _ := json.Marshal(wsTypingOut{Type: "typing", Blob: in.Blob})
+ s.hub.broadcast <- out
+
case "msg":
if in.Blob == "" || len(in.Blob) > maxBlobBytes {
continue
diff --git a/ios/LChat/ChatSocket.swift b/ios/LChat/ChatSocket.swift
index 5ac331c..a1f8dbb 100644
--- a/ios/LChat/ChatSocket.swift
+++ b/ios/LChat/ChatSocket.swift
@@ -10,6 +10,7 @@ enum WSEvent {
case disconnected
case message(ServerMessage)
case reaction(id: Int64, reactions: [ReactionView])
+ case typing(blob: String)
}
final class ChatSocket: NSObject {
@@ -54,6 +55,10 @@ final class ChatSocket: NSObject {
send(["type": "msg", "blob": blob])
}
+ func sendTyping(blob: String) {
+ send(["type": "typing", "blob": blob])
+ }
+
func sendReaction(messageID: Int64, emoji: String, add: Bool) {
send([
"type": add ? "react" : "unreact",
@@ -104,6 +109,11 @@ final class ChatSocket: NSObject {
if let f = try? JSONDecoder().decode(Frame.self, from: data) {
emit(.reaction(id: f.message_id, reactions: f.reactions ?? []))
}
+ case "typing":
+ struct Frame: Decodable { let blob: String }
+ if let f = try? JSONDecoder().decode(Frame.self, from: data) {
+ emit(.typing(blob: f.blob))
+ }
default:
break
}
diff --git a/ios/LChat/ChatStore.swift b/ios/LChat/ChatStore.swift
index 41dd4e9..e174eb7 100644
--- a/ios/LChat/ChatStore.swift
+++ b/ios/LChat/ChatStore.swift
@@ -2,6 +2,12 @@ import Foundation
import UIKit
import UserNotifications
+// Typing-indicator cadence, mirroring chat.js: re-broadcast every interval while
+// typing; tell receivers to keep showing it for the (longer) expiry so a single
+// late beat doesn't make it flicker.
+private let kTypingInterval: TimeInterval = 10
+private let kTypingExpiry = 15
+
// One decrypted, ready-to-render message.
struct ChatMessage: Identifiable {
let id: Int64
@@ -24,6 +30,9 @@ final class ChatStore: ObservableObject {
@Published private(set) var loadError: String?
@Published private(set) var unread = 0
+ // Who's currently typing (drives the subtle indicator above the composer).
+ @Published private(set) var typingNames: [String] = []
+
// Whether the app is frontmost. New messages that land while it isn't count
// toward the app-icon badge (works best on Mac, which keeps the socket alive
// in the background; iOS suspends it without push).
@@ -40,6 +49,14 @@ final class ChatStore: ObservableObject {
private var replyCounts: [Int64: Int] = [:]
private var imageCache: [String: UIImage] = [:]
+ // Typing indicators. Received: sender name -> when their hint lapses, swept
+ // every second while anyone's active. Sent: a beat re-broadcasts while you
+ // type, an idle timer stops it once you pause (peers then expire it).
+ private var typingUntil: [String: Date] = [:]
+ private var typingSweep: Timer?
+ private var typingBeat: Timer?
+ private var typingIdle: Timer?
+
init(session: Session) {
self.session = session
self.api = LChatAPI(baseURL: session.baseURL, token: session.token)
@@ -78,6 +95,8 @@ final class ChatStore: ObservableObject {
/// Stop the live connection (leaving the chat or removing it). Persistence of
/// the chat list itself lives in AppState.
func disconnect() {
+ stopTyping()
+ typingSweep?.invalidate(); typingSweep = nil
socket?.close()
}
@@ -110,6 +129,8 @@ final class ChatStore: ObservableObject {
ingest(m, live: true)
case .reaction(let id, let reactions):
applyReactions(id: id, reactions: reactions)
+ case .typing(let blob):
+ handleTyping(blob: blob)
}
}
@@ -209,12 +230,80 @@ final class ChatStore: ObservableObject {
socket?.sendReaction(messageID: id, emoji: emoji, add: adding)
}
+ // MARK: Typing indicator
+
+ /// The composer changed: beat a typing hint while there's text, stop on empty.
+ func composerChanged(isEmpty: Bool) {
+ if isEmpty { stopTyping(); return }
+ if typingBeat == nil {
+ sendTyping()
+ typingBeat = scheduled(every: kTypingInterval, repeats: true) { [weak self] in
+ self?.sendTyping()
+ }
+ }
+ // Restart the idle timer: one quiet interval and we stop beating.
+ typingIdle?.invalidate()
+ typingIdle = scheduled(every: kTypingInterval, repeats: false) { [weak self] in
+ self?.stopTyping()
+ }
+ }
+
+ private func sendTyping() {
+ guard let key, connected else { return }
+ let payload = TypingPayload(name: session.name, expiry: kTypingExpiry)
+ guard let blob = try? key.encryptJSON(payload) else { return }
+ socket?.sendTyping(blob: blob)
+ }
+
+ private func stopTyping() {
+ typingBeat?.invalidate(); typingBeat = nil
+ typingIdle?.invalidate(); typingIdle = nil
+ }
+
+ /// A received typing hint: ignore our own echo, then (re)set that sender's
+ /// expiry to now + their requested duration and start the sweep.
+ private func handleTyping(blob: String) {
+ guard let key, let p = try? key.decryptJSON(TypingPayload.self, blob: blob) else { return }
+ guard p.name != session.name else { return }
+ let secs = min(max(p.expiry, 0), 60) // clamp; a hostile peer can't pin it forever
+ guard secs > 0 else { return }
+ typingUntil[p.name] = Date().addingTimeInterval(TimeInterval(secs))
+ refreshTyping()
+ if typingSweep == nil {
+ typingSweep = scheduled(every: 1, repeats: true) { [weak self] in self?.sweepTyping() }
+ }
+ }
+
+ private func sweepTyping() {
+ refreshTyping()
+ if typingUntil.isEmpty { typingSweep?.invalidate(); typingSweep = nil }
+ }
+
+ /// Drop lapsed senders and republish the (sorted, stable) name list.
+ private func refreshTyping() {
+ let now = Date()
+ typingUntil = typingUntil.filter { $0.value > now }
+ typingNames = typingUntil.keys.sorted()
+ }
+
+ // A main-runloop timer whose body runs back on the main actor. Mirrors the
+ // keepalive timer in ChatSocket; the @MainActor hop keeps store state on one
+ // thread.
+ private func scheduled(every seconds: TimeInterval, repeats: Bool, _ body: @escaping () -> Void) -> Timer {
+ let t = Timer(timeInterval: seconds, repeats: repeats) { _ in
+ Task { @MainActor in body() }
+ }
+ RunLoop.main.add(t, forMode: .common)
+ return t
+ }
+
// MARK: Sending
func send(text: String, attachment: Attachment?, replyTo: ReplyTo?) {
guard let key else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || attachment != nil else { return }
+ stopTyping() // sending means you've stopped typing this message
var payload = MessagePayload(name: session.name)
payload.type = MessageType.chat
payload.text = trimmed
diff --git a/ios/LChat/Models.swift b/ios/LChat/Models.swift
index 33ec816..34e606e 100644
--- a/ios/LChat/Models.swift
+++ b/ios/LChat/Models.swift
@@ -81,6 +81,11 @@ enum MessageType {
// The type this client stamps on every message it sends.
static let chat = "chat"
+ // A transient "is typing" presence hint. It rides its own WebSocket frame
+ // (never stored, never pushed) and is handled before the feed, so it stays
+ // out of `accepted` below — it must never render as a message.
+ static let typing = "typing"
+
// The types this client will render. A nil/absent type is a legacy message
// from before payloads were tagged, so it's treated as a chat. Narrowing
// this set — or dropping the nil allowance — is how we get "more limited".
@@ -93,6 +98,16 @@ enum MessageType {
}
}
+// A typing indicator's decrypted body — the sender's name and how long (seconds)
+// receivers should keep showing it. Encoded keys match web/static/chat.js exactly
+// (`type`/`name`/`expiry`) so the blob crosses platforms. `expiry` is integer
+// seconds; keep it Int so a peer's `15` decodes cleanly.
+struct TypingPayload: Codable {
+ var type: String = MessageType.typing
+ var name: String
+ var expiry: Int
+}
+
struct Attachment: Codable, Hashable {
var id: String
var name: String?
@@ -124,6 +139,16 @@ let kReactions = ["👍", "❤️", "😂", "😮", "😢", "🎉"]
// MARK: - Reply snippet (mirrors format.js replySnippet)
+// Summarise who's currently typing into a subtle one-liner — mirrors
+// typingSummary in web/static/format.js so both clients read identically.
+func typingSummary(_ names: [String]) -> String {
+ let list = names.filter { !$0.isEmpty }
+ if list.isEmpty { return "" }
+ if list.count == 1 { return "\(list[0]) is typing…" }
+ if list.count == 2 { return "\(list[0]) and \(list[1]) are typing…" }
+ return "Several people are typing…"
+}
+
func replySnippet(_ payload: MessagePayload, max: Int = 140) -> String {
func clamp(_ s: String) -> String {
guard s.count > max else { return s }
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index e4952ee..55d4cb7 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -47,8 +47,15 @@ struct ChatView: View {
.background(.yellow.opacity(0.25))
}
feed
+ if !store.typingNames.isEmpty {
+ typingIndicator
+ }
composer
}
+ // Beat a typing hint to others while there's text in the composer.
+ .onChange(of: text) { _, new in
+ store.composerChanged(isEmpty: new.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
// On the Mac there's no soft keyboard to worry about, so focus the
// composer on entry — the Return-to-send shortcut then works without
// first clicking into the field. On iOS we leave it unfocused so the
@@ -149,6 +156,17 @@ struct ChatView: View {
}
}
+ // Subtle "… is typing" line between the feed and the composer.
+ private var typingIndicator: some View {
+ Text(typingSummary(store.typingNames))
+ .font(.caption)
+ .italic()
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 2)
+ }
+
// MARK: Composer
private var composer: some View {
diff --git a/test/format.test.mjs b/test/format.test.mjs
index e728155..55ac62c 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -1,6 +1,6 @@
// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
import assert from "node:assert/strict";
-import { tokenizeText, formatBytes, replySnippet, emojiOnlyCount } from "../web/static/format.js";
+import { tokenizeText, formatBytes, replySnippet, emojiOnlyCount, typingSummary } from "../web/static/format.js";
let failures = 0;
async function test(name, fn) {
@@ -138,5 +138,23 @@ await test("emojiOnlyCount is 0 for empty or whitespace-only text", () => {
assert.equal(emojiOnlyCount(null), 0);
});
+await test("typingSummary is empty when no one is typing", () => {
+ assert.equal(typingSummary([]), "");
+ assert.equal(typingSummary(null), "");
+});
+
+await test("typingSummary names one and two typers", () => {
+ assert.equal(typingSummary(["Sophie"]), "Sophie is typing…");
+ assert.equal(typingSummary(["Sophie", "James"]), "Sophie and James are typing…");
+});
+
+await test("typingSummary collapses three or more typers", () => {
+ assert.equal(typingSummary(["A", "B", "C"]), "Several people are typing…");
+});
+
+await test("typingSummary ignores empty names", () => {
+ assert.equal(typingSummary(["Sophie", ""]), "Sophie is typing…");
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/pages/index.html b/web/pages/index.html
index bd164e0..37d1289 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -33,6 +33,7 @@
<div id="feed"></div>
</div>
<div class="drop-overlay hidden" id="drop-overlay">Drop a photo to send</div>
+ <div class="typing hidden" id="typing" aria-live="polite"></div>
<div class="composer-reply hidden" id="composer-reply"></div>
<div class="composer-edit hidden" id="composer-edit"></div>
<div class="composer-preview hidden" id="composer-preview"></div>
diff --git a/web/static/chat.js b/web/static/chat.js
index bbd552c..1535a41 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -11,8 +11,8 @@ import {
} from "/static/crypto.js";
import { qrCanvas } from "/static/qrcode.js";
import { shouldSend } from "/static/ui.js";
-import { tokenizeText, formatBytes, replySnippet, emojiOnlyCount } from "/static/format.js";
-import { MESSAGE_TYPE_CHAT, acceptsMessageType } from "/static/message.js";
+import { tokenizeText, formatBytes, replySnippet, emojiOnlyCount, typingSummary } from "/static/format.js";
+import { MESSAGE_TYPE_CHAT, MESSAGE_TYPE_TYPING, acceptsMessageType } from "/static/message.js";
const MAX_UPLOAD = 10 * 1024 * 1024; // keep in step with maxUploadBytes (main.go)
const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser
@@ -39,6 +39,14 @@ const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
// server (hub.go), which is the real enforcement — this only gates the UI.
const EDIT_WINDOW_MS = 15 * 60 * 1000;
+// Typing indicators. While you're typing we re-broadcast every TYPING_INTERVAL_MS
+// so others know you're still at it; each beat tells them to keep showing it for
+// TYPING_EXPIRY_S. The expiry is comfortably longer than the beat so a single
+// late/dropped beat doesn't make the indicator flicker, and once you stop the
+// receivers clear it on their own (no explicit "stopped" message needed).
+const TYPING_INTERVAL_MS = 10000;
+const TYPING_EXPIRY_S = 15;
+
// A crisp, optically-centered close icon (the "×" text glyph sits high in its box).
const ICON_X =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" ' +
@@ -663,6 +671,62 @@ async function start(session) {
const replyCounts = new Map();
const replyCountEls = new Map();
+ // Typing indicators: sender name -> epoch ms at which their "typing" lapses. A
+ // fresh beat from a sender replaces their entry (extending the expiry); a sweep
+ // prunes lapsed entries so the line clears even if someone vanishes mid-type.
+ const typingEl = document.getElementById("typing");
+ const typingUntil = new Map();
+ let typingSweep = null;
+
+ function renderTyping() {
+ if (!typingEl) return;
+ const wasAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
+ const names = [...typingUntil.keys()];
+ typingEl.textContent = typingSummary(names); // textContent: names are untrusted
+ typingEl.classList.toggle("hidden", names.length === 0);
+ // The line occupies height below the feed; keep the latest message visible
+ // rather than letting it slip under the indicator as it appears.
+ if (wasAtBottom) feed.scrollTop = feed.scrollHeight;
+ }
+
+ // Run a 1s sweep only while someone's typing; it prunes lapsed entries and
+ // stops itself once the line is empty (no idle timer when the chat is quiet).
+ function ensureTypingSweep() {
+ if (typingSweep) return;
+ typingSweep = setInterval(() => {
+ const now = Date.now();
+ let changed = false;
+ for (const [name, until] of typingUntil) {
+ if (until <= now) {
+ typingUntil.delete(name);
+ changed = true;
+ }
+ }
+ if (changed) renderTyping();
+ if (typingUntil.size === 0) {
+ clearInterval(typingSweep);
+ typingSweep = null;
+ }
+ }, 1000);
+ }
+
+ // A received typing beat: decrypt to learn who, ignore our own echo, and (re)set
+ // that sender's expiry to now + their requested duration.
+ async function onTyping(msg) {
+ let payload;
+ try {
+ payload = await decrypt(key, msg.blob);
+ } catch {
+ return; // wrong key or corrupt — ignore
+ }
+ 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;
+ typingUntil.set(payload.name, Date.now() + secs * 1000);
+ renderTyping();
+ ensureTypingSweep();
+ }
+
// Loading spinner, shown only if the initial load/decrypt is slow (>500ms).
let chatLoadingEl = null;
function showChatLoading() {
@@ -1442,6 +1506,8 @@ async function start(session) {
} else if (msg.type === "reaction") {
reactionState.set(msg.message_id, msg.reactions || []);
renderReactionsFor(msg.message_id);
+ } else if (msg.type === "typing") {
+ onTyping(msg);
}
} catch {}
};
@@ -1765,9 +1831,45 @@ async function start(session) {
if (file) stageFile(file);
});
+ // ---- outgoing typing indicator ----
+ // typingBeat: the interval re-broadcasting while you actively type.
+ // typingIdle: stops the beat once you pause (receivers then expire it).
+ let typingBeat = null;
+ let typingIdle = null;
+
+ async function sendTyping() {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ // Name + expiry ride encrypted, so the server stays a blind relay.
+ const blob = await encrypt(key, {
+ type: MESSAGE_TYPE_TYPING,
+ name: session.name,
+ expiry: TYPING_EXPIRY_S,
+ });
+ ws.send(JSON.stringify({ type: "typing", blob }));
+ }
+
+ function stopTyping() {
+ if (typingBeat) clearInterval(typingBeat);
+ if (typingIdle) clearTimeout(typingIdle);
+ typingBeat = null;
+ typingIdle = null;
+ }
+
+ // Called on each keystroke while there's text: beat now if we weren't already,
+ // then keep beating every interval until a pause stops us.
+ function noteTyping() {
+ if (!typingBeat) {
+ sendTyping();
+ typingBeat = setInterval(sendTyping, TYPING_INTERVAL_MS);
+ }
+ if (typingIdle) clearTimeout(typingIdle);
+ typingIdle = setTimeout(stopTyping, TYPING_INTERVAL_MS);
+ }
+
const send = async () => {
const text = input.value.trim();
if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ stopTyping(); // sending means you've stopped typing this message
// Edit path: replace an existing message's text in place. Re-check the window
// here (it may have lapsed while typing) so we don't silently drop the edit —
@@ -1802,6 +1904,8 @@ async function start(session) {
input.addEventListener("input", () => {
autosize();
syncSendBtn();
+ if (input.value.trim()) noteTyping();
+ else stopTyping();
});
input.addEventListener("keydown", (e) => {
if (shouldSend(e, isCoarse)) {
diff --git a/web/static/format.js b/web/static/format.js
index cdef433..46b3ca6 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -75,6 +75,18 @@ export function emojiOnlyCount(text) {
return count;
}
+// Summarise who's currently typing into a subtle one-liner. Past two names it
+// collapses to "Several people…" so the line never grows unbounded. Pure so the
+// presence UI in chat.js stays testable; the caller renders it with textContent
+// (the names are attacker-controlled).
+export function typingSummary(names) {
+ const list = Array.isArray(names) ? names.filter(Boolean) : [];
+ if (list.length === 0) return "";
+ if (list.length === 1) return `${list[0]} is typing…`;
+ if (list.length === 2) return `${list[0]} and ${list[1]} are typing…`;
+ return "Several people are typing…";
+}
+
// Human-readable file size, e.g. 1536 → "1.5 KB".
export function formatBytes(n) {
if (!Number.isFinite(n) || n < 0) return "";
diff --git a/web/static/message.js b/web/static/message.js
index 4997fd2..0d10a3d 100644
--- a/web/static/message.js
+++ b/web/static/message.js
@@ -7,6 +7,12 @@
// The type this client stamps on every message it sends.
export const MESSAGE_TYPE_CHAT = "chat";
+// A transient "is typing" presence hint. It rides its own WebSocket frame
+// (never stored, never pushed) and is consumed before reaching the feed, so it's
+// deliberately kept out of ACCEPTED_TYPES below — it must never render as a
+// message even if one ever reached the render path.
+export const MESSAGE_TYPE_TYPING = "typing";
+
// The types this client will render. A null/absent type is a legacy message
// from before payloads were tagged, so it's treated as a chat. Narrowing this
// set — or dropping the null allowance — is how we get "more limited" later.
diff --git a/web/static/style.css b/web/static/style.css
index aea420e..d897a8c 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -541,6 +541,19 @@ 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. */
+.typing {
+ padding: 2px 16px 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 {
display: flex;
gap: 8px;