Manual Recipes Chat Rooms

Chat Rooms

elements man recipes/chat-rooms Read as markdown

A multi-room chat app with handle-only login. A user picks a handle on the home page, sees a list of rooms, optionally creates a new one, then clicks into /rooms/:id for a per-room message stream. Two LiveTables drive the recipe: an unscoped rooms LiveTable that every browser watches, and a messages LiveTable scoped by roomId so each room has its own broadcast channel.

Handle-only login means the user types a handle and the server creates a chatUsers row. There is no password. Each browser session starts a new ephemeral user; two browsers can use the same handle without conflict because the user's identity is the row id, not the handle.

Migration

elements create migration 'add chat' -tables=chatUsers,chatRooms,chatMessages

app/migrations/<timestamp>-add-chat.migration.sql:

-- add chat

-- 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 chatUsers (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  handle text not null
);

create trigger chatUsersTouchUpdatedAt
  before update on chatUsers
  for each row execute function touchUpdatedAt();

create table chatRooms (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  name text not null
);

create trigger chatRoomsTouchUpdatedAt
  before update on chatRooms
  for each row execute function touchUpdatedAt();

create table chatMessages (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  roomId uuid not null references chatRooms(id) on delete cascade,
  userId uuid not null references chatUsers(id) on delete cascade,
  userName text not null,
  body text not null
);

create index chatMessagesRoomIdIdx on chatMessages (roomId);

create trigger chatMessagesTouchUpdatedAt
  before update on chatMessages
  for each row execute function touchUpdatedAt();

chatUsers.handle is not unique. The same display name can belong to many rows; the row id is the actual identity. chatMessages.userName is denormalized from chatUsers.handle at write time so renames in the future do not rewrite chat history. chatMessagesRoomIdIdx keeps per-room reads fast as the table grows.

LiveTables

app/shared/services/chat.ts:

import { LiveTable } from "@elements/app";

export interface Room {
  id: string;
  createdAt: Date;
  name: string;
}

export interface Message {
  id: string;
  createdAt: Date;
  roomId: string;
  userId: string;
  userName: string;
  body: string;
}

export let rooms = new LiveTable<Room>({
  sort: "createdAt asc",
});

export let messages = new LiveTable<Message>({
  scope: "roomId",
  sort: "createdAt asc",
  insert: { auth: (item, s) => s.isLoggedIn() },
});

rooms is unscoped: every browser on the home page watches the same channel and sees a new room the moment someone creates it. messages is scoped by roomId; each room is its own broadcast channel, so a new message in one room only reaches subscribers of that room. insert.auth on messages requires a logged-in session, which after the handle login means a chatUsers row exists.

Home page

elements create page home

The home page renders one of two views: an unsigned-in handle entry form, or a signed-in room list with a create-room form and a logout button. session.isLoggedIn() is reactive in the browser, so submitting the handle login flips the view without a full page reload.

app/pages/home/services.ts:

import { sql, session, ValidationError } from "@elements/app";

/** @rpc */
export function signinAsHandle(handle: string) {
  if (handle.trim().length === 0) {
    throw new ValidationError("handle is required");
  }

  let user = sql<{ id: string }>(
    `insert into chatUsers (handle) values (${handle.trim()}) returning id`,
  ).firstOrThrow();

  session.login({ userId: user.id, userName: handle.trim() });
}

/** @rpc */
export function signout() {
  session.logout();
}

/** @rpc */
export function createRoom(name: string): string {
  session.isLoggedInOrThrow();

  if (name.trim().length === 0) {
    throw new ValidationError("room name is required");
  }

  return sql<{ id: string }>(
    `insert into chatRooms (name) values (${name.trim()}) returning id`,
  ).firstOrThrow().id;
}

signinAsHandle inserts a fresh chatUsers row and binds the session to it. createRoom returns the new room's id so the handler can navigate to /rooms/:id immediately. signout ends the session.

app/pages/home/index.ts:

import home from "./template";
import { rooms } from "#app/shared/services/chat";

export default function route(req, res) {
  return new home({ rooms });
}

app/pages/home/template.html:

import "./style.css";
import { LiveTable, session, redirect } from "@elements/app";
import { Room } from "#app/shared/services/chat";
import { signinAsHandle, signout, createRoom } from "./services";

function login(handle: { value: string }) {
  signinAsHandle(handle.value);
  handle.value = "";
}

function createAndOpen(name: { value: string }) {
  let id = createRoom(name.value);
  name.value = "";
  redirect(`/rooms/${id}`);
}

<html class="home"
      (rooms: LiveTable<Room>,
       private handle: { value: string } = { value: "" },
       private newRoom: { value: string } = { value: "" })>
  <h1>chat</h1>

  <form e:if={!session.isLoggedIn()} onsubmit={() => login(handle)}>
    <label>pick a handle to join the chat:</label>
    <input type="text" value={handle.value} placeholder="alice" required>
    <button type="submit">join</button>
  </form>

  <div e:else>
    <p>signed in as <strong>{session.get('userName')}</strong>. <button onclick={() => signout()}>sign out</button></p>

    <h2>rooms</h2>
    <ul class="rooms">
      <li e:for={r of rooms}>
        <a href={`/rooms/${r.id}`}>{r.name}</a>
      </li>
      <li e:if={rooms.length === 0}>no rooms yet. create one below.</li>
    </ul>

    <form onsubmit={() => createAndOpen(newRoom)}>
      <input type="text" value={newRoom.value} placeholder="new room name" required>
      <button type="submit">create room</button>
    </form>
  </div>
</html>

The two branches share the same template instance. After signinAsHandle returns, session.isLoggedIn() is true on the browser; the e:if/e:else switches and the rooms view renders. New rooms from any browser appear in the list immediately because rooms is a live, broadcasting LiveTable.

Room page

elements create page room

The room page renders the message feed and a composer. Inserting a message goes through the scoped LiveTable, which broadcasts to every browser watching that room.

app/pages/room/index.ts:

import { sql, session } from "@elements/app";
import room from "./template";
import { Room, messages } from "#app/shared/services/chat";

export default function route(req, res) {
  session.isLoggedInOrThrow();

  let roomId = req.params.id;

  let row = sql<Room>(
    `select id, createdAt, name from chatRooms where id = ${roomId}`,
  ).firstOrThrow("room not found");

  return new room({ room: row, messages: messages.scope(roomId) });
}

The route guards on a logged-in session and resolves the room before scoping the LiveTable. A 404 surfaces from NotFoundError if the room id in the URL does not exist.

app/pages/room/template.html:

import "./style.css";
import { LiveTable, session } from "@elements/app";
import { Room, Message } from "#app/shared/services/chat";

function insert(feed: LiveTable<Message>, draft: { value: string }) {
  feed.insert(
    {
      userId: session.getOrThrow('userId'),
      userName: session.getOrThrow('userName'),
      body: draft.value,
    },
    () => draft.value = "",
  );
}

<html class="room"
      (room: Room,
       messages: LiveTable<Message>,
       private draft: { value: string } = { value: "" })>
  <header>
    <h1>{room.name}</h1>
    <a href="/">back to rooms</a>
  </header>

  <ul class="feed">
    <li e:for={m of messages}>
      <strong>{m.userName}</strong>
      <span class="body">{m.body}</span>
    </li>
  </ul>

  <form onsubmit={() => insert(messages, draft)}>
    <input type="text" value={draft.value} placeholder={`message in ${room.name}`} required>
    <button type="submit">send</button>
  </form>
</html>

messages.insert(...) passes every field the feed reads: userId, userName, and body. The LiveTable's scope: "roomId" auto-fills the roomId column from the bound scope. The resetUI callback clears the draft the moment the optimistic row lands.

Routes

Register the pages in index.ts:

import home from "#app/pages/home";
import room from "#app/pages/room";

// ...
app.route("/", home);
app.route("/rooms/:id", room);

Notes

  • Display a timestamp? Pass one at insert. If the feed shows {formatTime(m.createdAt)}, pass createdAt: new Date() in the insert payload. createdAt is server-generated (default now()), so the optimistic row has it as undefined until the broadcast lands, and new Date(undefined) renders Invalid Date in the meantime. The server's real now() reconciles the placeholder a moment later.
  • Ephemeral users. Every signinAsHandle call inserts a new chatUsers row. The same browser session that signs in twice gets two different userId values. For a "log in to an existing handle and keep your history" UX, look up an existing row by handle first and only insert if missing, then session.login with the found or new id.
  • Denormalized userName. chatMessages.userName is copied from chatUsers.handle at write time. A user who changes their handle later sees the new handle on new messages but their old messages keep the old handle. This is what you want for a chat log.
  • Room deletion. The on delete cascade on chatMessages.roomId removes a room's history when the room is deleted. Add a "delete room" button that calls rooms.delete(room) from the home page; non-creator restrictions can be added with an auth callback on rooms.delete.
  • Auth model. This recipe uses the lightest possible login (handle, no password). For a real chat app, build the auth flow from elements man recipes authentication and replace signinAsHandle with the password-based signin. The rest of this recipe is unchanged.
  • Typing indicators and presence. Channel-driven UX layered over the same room (who's typing, who's in the room) is the next step. See elements man recipes typing-indicator and elements man recipes presence.