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();

-- 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

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:

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:

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<Message>({
  channel: () => "chatMessages",
});

export const typing = new Channel<TypingPing>("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<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="page-chat" (
  messages: LiveView<Message>,
  typing: Listener<TypingPing>,
  private userName: string = "",
  private body: { value: string } = { value: "" },
  private typers: string[] = [],
) oninit={() => typing.on("notify", (p) => onTypingPing(userName, p, typers))}>
  <main class="page-shell">
    <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.sort((a, b) => +a.createdAt - +b.createdAt)}>
        <strong>{m.userName}</strong>: {m.body}
      </li>
    </ul>

    <p e:if={typers.length > 0} class="typing">
      {typers.join(", ")} typing…
    </p>

    <form onsubmit={() => onSubmit(messages, userName, body)}>
      <input type="text"
             value={body.value}
             oninput={() => onType(userName)}
             placeholder="say something"
             required>
      <button type="submit">send</button>
    </form>
  </main>
</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, 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<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.