# 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(); -- Broadcast writes that do not come from a live view, so a message inserted by -- a job, an rpc, or `elements db -c` still reaches every open page. Writes -- through the view broadcast themselves and need none of this; see -- `elements man recipes live-from-sql`. create or replace function chatMessagesNotify() returns trigger language plpgsql as $$ declare r record; payload text; begin r := coalesce(new, old); payload := json_build_object( 'op', lower(tg_op), 'data', json_build_object( 'id', r.id, 'createdAt', json_build_object('$type', 'Date', '$value', (extract(epoch from r.createdAt) * 1000)::bigint), 'userName', r.userName, 'body', r.body ) )::text; -- NOTIFY takes under 8000 bytes. A larger row goes as its id, and the -- server reads the row back. if octet_length(payload) >= 8000 then payload := json_build_object('op', lower(tg_op), 'id', r.id)::text; end if; perform pg_notify(channel_name('chatMessages'), payload); return r; end; $$; create trigger chatMessagesNotifyTrigger after insert or update or delete on chatMessages for each row execute function chatMessagesNotify(); ``` 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 `template.ehtml`. The route imports them from there and hands the template the messages LiveTable plus a freshly-attached listener; the template wires the listener on `oninit`. `app/pages/chat/index.ts`: ```ts import { Request, Response } from "@elements/app"; import chat, { chatMessages, typing } from "./template"; export default function route(req: Request, res: Response) { return new chat({ messages: chatMessages.view(), typing: typing.listen() }); } ``` `channel` pins the name the trigger notifies, so a message written outside the view still reaches the page. Without it the compiler derives a channel from where the declaration lives, which a migration cannot write. `chatMessages` is a LiveTable opened whole, with no partition. It is named for the table the migration creates: a LiveTable takes its table from the variable name, camelCase to snake_case, so `chatMessages` reads `chat_messages`. `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. 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.ehtml`: ```ehtml import "./style.css"; import { Channel, LiveTable, LiveView, type Listener } from "@elements/app"; export interface Message { id: string; createdAt: Date; userName: string; body: string; } export interface TypingPing { userName: string; } export let chatMessages = new LiveTable({ channel: () => "chatMessages", }); export const typing = new Channel("typing"); /** @rpc */ export function notifyTyping(userName: string) { if (userName.trim().length === 0) { return; } typing.notify({ userName: userName.trim() }); } let lastNotifiedAt = 0; function onSubmit(messages: LiveView, 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

  • +a.createdAt - +b.createdAt)}> {m.userName}: {m.body}

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

onSubmit(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, local 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 attributes. `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.