Typing Indicator
elements man recipes/typing-indicator Read as markdownA "someone is typing..." indicator layered on top of a chat. The browser fires a
debounced rpc as the user types into the composer. The rpc relays through a
typing channel. Every other browser receives the ping, adds the typer's name
to a local list, and removes the entry after a few seconds with no further
pings. Typing pings are events, not data rows, so a channel is the right
primitive: it broadcasts each notification and forgets, with nothing stored on
either side.
The recipe is a single-room chat for clarity. The same pattern slots into the
multi-room chat-rooms recipe by filtering the channel's listen() on roomId.
Migration
elements create migration 'add chat messages' -tables=chatMessages
app/migrations/<timestamp>-add-chat-messages.migration.sql:
-- add chat messages
-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
new.updatedAt = now();
return new;
end;
$$;
create table chatMessages (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now(),
userName text not null,
body text not null
);
create trigger chatMessagesTouchUpdatedAt
before update on chatMessages
for each row execute function touchUpdatedAt();
Single-room chat means no roomId, no chatUsers table. userName is whatever
the user typed into the handle input on the page; this recipe leaves identity
light so the focus stays on the channel pattern.
Page setup
elements create page chat
The LiveTable, the Channel, and the rpc are all used only by the /chat page,
so they live in the page's own services.ts. The route hands the template the
messages LiveTable plus a freshly-attached listener; the template wires the
listener on oninit.
app/pages/chat/services.ts:
import { Channel, LiveTable } from "@elements/app";
export interface Message {
id: string;
createdAt: Date;
userName: string;
body: string;
}
export interface TypingPing {
userName: string;
}
export let messages = new LiveTable<Message>({
sort: "createdAt asc",
});
export const typing = new Channel<TypingPing>("typing");
/** @rpc */
export function notifyTyping(userName: string) {
if (userName.trim().length === 0) {
return;
}
typing.notify({ userName: userName.trim() });
}
messages is a regular unscoped LiveTable. typing is a Channel: it
broadcasts pings but does not store them. Listeners receive the live
notifications and forget them after handling. notifyTyping is the bridge from
the browser to the channel; Channel.notify is server-only, so the browser
hands the ping off through an rpc. The rpc does no debouncing; the browser
decides how often to call.
app/pages/chat/index.ts:
import chat from "./template";
import { messages, typing } from "./services";
export default function route(req, res) {
return new chat({ messages, typing: typing.listen() });
}
The route hands the template the messages LiveTable and a freshly-attached
Listener for the typing channel. The listener serializes over the wire and
re-attaches in the browser over the WebSocket.
app/pages/chat/template.html:
import "./style.css";
import { LiveTable } from "@elements/app";
import type { Listener } from "@elements/app";
import { Message, TypingPing, notifyTyping } from "./services";
let lastNotifiedAt = 0;
function insert(messages: LiveTable<Message>, userName: string, body: { value: string }) {
if (userName.trim().length === 0) {
return;
}
if (body.value.trim().length === 0) {
return;
}
messages.insert(
{ userName: userName.trim(), body: body.value },
() => body.value = "",
);
}
function onType(userName: string) {
if (userName.trim().length === 0) {
return;
}
let now = Date.now();
if (now - lastNotifiedAt < 1500) {
return;
}
lastNotifiedAt = now;
notifyTyping(userName);
}
function onTypingPing(self: string, ping: TypingPing, typers: string[]) {
if (ping.userName === self) {
return;
}
if (!typers.includes(ping.userName)) {
typers.push(ping.userName);
}
setTimeout(() => {
let i = typers.indexOf(ping.userName);
if (i !== -1) {
typers.splice(i, 1);
}
}, 3000);
}
<html class="chat"
(messages: LiveTable<Message>,
typing: Listener<TypingPing>,
private userName: string = "",
private body: { value: string } = { value: "" },
private typers: string[] = [])
oninit={() => typing.on("notify", (p) => onTypingPing(userName, p, typers))}>
<h1>chat</h1>
<div class="me">
<label>your name</label>
<input type="text" value={userName} placeholder="alice">
</div>
<ul class="feed">
<li e:for={m of messages}>
<strong>{m.userName}</strong>: {m.body}
</li>
</ul>
<p e:if={typers.length > 0} class="typing">
{typers.join(", ")} typing…
</p>
<form onsubmit={() => insert(messages, userName, body)}>
<input type="text"
value={body.value}
oninput={() => onType(userName)}
placeholder="say something"
required>
<button type="submit">send</button>
</form>
</html>
Three parts:
onTypedebounces the outgoing pings. Every keystroke firesoninput, but the function only forwards tonotifyTypingevery 1.5 seconds. Without the gate, a fast typist would hit the rpc on every character.lastNotifiedAtis module-level state, scoped to the template module.onTypingPingis the listener handler. It drops pings from the local user (otherwise "alice typing…" would show on alice's own screen), adds new typers to the array, and schedules asetTimeoutto remove the entry after 3 seconds with no further pings. A re-ping during that window starts a new timer; the entry stays as long as pings keep arriving.oniniton the template instance wires the listener once. The handler capturesuserNameandtypersfrom the template's reactive scope.
Listener<TypingPing> is also a reactive value, but this recipe uses it as a
pure event stream via on("notify", ...). The typers array is what the
template binds to; pushing to it is enough to re-render the e:if.
Routes
Register the page in index.ts:
import chat from "#app/pages/chat";
// ...
app.route("/chat", chat);
Notes
- Why a channel and not a LiveTable. Typing pings are events, not rows. Storing them would mean inserting and deleting rows per keystroke, fighting both the database and the LiveTable broadcaster. Channels exist for exactly this case: notify, listen, no storage.
- Multi-room version. In a multi-room chat
(
elements man recipes chat-rooms), includeroomIdon theTypingPinginterface and filter the listener:typing.listen({ filter: (p) => p.roomId === currentRoomId }). The filter runs on the server before the message leaves the wire. - Self-filtering server-side. This recipe filters self pings in the browser.
To filter them server-side instead, give each ping a
clientId(an app-generated id the browser keeps inlocalStorage,crypto.randomUUID(), and passes on thenotifyTypingrpc), hand that same id to the page route as a query parameter, and filter the listener on it:typing.listen({ filter: (p) => p.clientId !== myClientId }). The filter closes over the id and runs on the server before the message leaves the wire. Browser-side filtering is fine for chat-scale traffic; server-side filtering matters when the per-client message count gets large. - Stale typers on disconnect. A user who closes the tab mid-typing leaves
their entry in everyone else's
typersarray for up to 3 seconds. ThesetTimeoutcleans it up regardless; no separate disconnect-detection is required. - Customizing the indicator. Two typers shows "alice, bob typing…". For
"alice and bob", join with
e:ifbranches. For numbers ("3 people typing"), rendertypers.lengthdirectly.