# Chat Rooms 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 ```bash elements create migration 'add chat' -tables=chatUsers,chatRooms,chatMessages ``` `app/migrations/-add-chat.migration.sql`: ```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`: ```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({ sort: "createdAt asc", }); export let messages = new LiveTable({ 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 ```bash 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`: ```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`: ```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`: ```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}`); } , private handle: { value: string } = { value: "" }, private newRoom: { value: string } = { value: "" })>

chat

login(handle)}>

signed in as {session.get('userName')}.

rooms

  • {r.name}
  • no rooms yet. create one below.
createAndOpen(newRoom)}>
``` 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 ```bash 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`: ```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( `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`: ```html import "./style.css"; import { LiveTable, session } from "@elements/app"; import { Room, Message } from "#app/shared/services/chat"; function insert(feed: LiveTable, draft: { value: string }) { feed.insert( { userId: session.getOrThrow('userId'), userName: session.getOrThrow('userName'), body: draft.value, }, () => draft.value = "", ); } , private draft: { value: string } = { value: "" })>

{room.name}

back to rooms
  • {m.userName} {m.body}
insert(messages, draft)}>
``` `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`: ```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`.