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>
11 files changed,
+324
−4
hub.go+25 −1ios/LChat/ChatSocket.swift+10 −0ios/LChat/ChatStore.swift+89 −0ios/LChat/Models.swift+25 −0ios/LChat/Views/ChatView.swift+18 −0test/format.test.mjs+19 −1web/pages/index.html+1 −0web/static/chat.js+106 −2web/static/format.js+12 −0web/static/message.js+6 −0web/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; |