Manual Recipes Typing Indicator

Typing Indicator

elements man recipes/typing-indicator Read as markdown

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

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:

  • 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<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), 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.