# Typing Indicator A "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 ```bash elements create migration 'add chat messages' -tables=chatMessages ``` `app/migrations/-add-chat-messages.migration.sql`: ```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 ```bash 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`: ```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({ sort: "createdAt asc", }); export const typing = new Channel("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`: ```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`: ```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, 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); } , typing: Listener, private userName: string = "", private body: { value: string } = { value: "" }, private typers: string[] = []) oninit={() => typing.on("notify", (p) => onTypingPing(userName, p, typers))}>

chat

  • {m.userName}: {m.body}

0} class="typing"> {typers.join(", ")} typing…

insert(messages, userName, body)}> onType(userName)} placeholder="say something" required>
``` Three parts: - `onType` debounces the outgoing pings. Every keystroke fires `oninput`, but the function only forwards to `notifyTyping` every 1.5 seconds. Without the gate, a fast typist would hit the rpc on every character. `lastNotifiedAt` is module-level state, scoped to the template module. - `onTypingPing` is 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 a `setTimeout` to 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. - `oninit` on the template instance wires the listener once. The handler captures `userName` and `typers` from the template's reactive scope. `Listener` 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`: ```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`), include `roomId` on the `TypingPing` interface 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 in `localStorage`, `crypto.randomUUID()`, and passes on the `notifyTyping` rpc), 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 `typers` array for up to 3 seconds. The `setTimeout` cleans 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:if` branches. For numbers ("3 people typing"), render `typers.length` directly.