# 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: a `rooms` LiveTable opened whole, which every browser watches, and a `messages` LiveTable opened per room as a window, `view({ roomId }, { orderBy, limit })`, so a message reaches only the browsers viewing its room, and a browser holds the newest fifty messages, not the room's whole history. 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 Terminal elements create migration 'add chat' -tables=chatUsers,chatRooms,chatMessages ``` ```sql app/migrations/-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 chatMessagesRoomIdx on chatMessages (roomId, createdAt desc, id desc); 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 -sql` still reaches every open page. Writes -- through the view broadcast themselves and need none of this; see -- `elements man recipes live-from-sql` for the whole story. 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), 'roomId', r.roomId, 'userId', r.userId, '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('chat_messages'), payload); return r; end; $$; create trigger chatMessagesNotifyTrigger after insert or update or delete on chatMessages for each row execute function chatMessagesNotify(); ``` `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. `chatMessagesRoomIdx` matches the room page's window, `where roomId = $1 order by createdAt desc, id desc`, so the first page and every older one read the index instead of sorting the room. ## LiveTables Both pages open these tables, so they live in `app/shared/services/` rather than in either page's `template.ehtml`. ```ts app/shared/services/chat.ts import { LiveTable, session } 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 chatRooms = new LiveTable(); export let chatMessages: LiveTable = new LiveTable({ insert: (item) => { session.isLoggedInOrThrow(); return chatMessages.insert(item); }, }); ``` Each declaration is named for the table it reads: a LiveTable takes its table from the variable name, camelCase to snake_case, so `chatRooms` reads `chat_rooms`. Name the variable after the table and they cannot drift. The table's name is also its channel, so the trigger above notifies `chat_messages`, and every view of the table hears it. `chatRooms` has no partition: every browser on the home page sees a new room the moment someone creates it. `chatMessages` is opened per room below with `view({ roomId }, window)`; each app server sends a message only to the views whose `roomId` it matches, so a new message in one room only reaches browsers in that room, and the window keeps each browser's copy to the pages it has loaded. The `insert` handler on `chatMessages` requires a logged-in session, which after the handle login means a `chatUsers` row exists, then runs the raw auto-SQL on the declaration. The declaration carries an explicit `LiveTable` annotation because its initializer refers to it. ## Home page ```bash Terminal 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. These three RPC functions belong to the home page alone, so they go in its `template.ehtml` with the markup that calls them. `signin` 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, and with it every live view that session opened, so follow it with a `redirect`. See the note below. ```ts app/pages/home/index.ts import { Request, Response } from "@elements/app"; import home from "./template"; import { chatRooms } from "#app/shared/services/chat"; export default function route(req: Request, res: Response) { return new home({ rooms: chatRooms.view() }); } ``` ```ehtml app/pages/home/template.ehtml import "./style.css"; import { LiveView, sql, session, redirect, ValidationError } from "@elements/app"; import { Room } from "#app/shared/services/chat"; /** @rpc */ export function signin(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; } function onSigninSubmit(handle: { value: string }) { signin(handle.value); handle.value = ""; } function onCreateRoomSubmit(name: { value: string }) { let id = createRoom(name.value); name.value = ""; redirect(`/rooms/${id}`); } , private handle: { value: string } = { value: "" }, private newRoom: { value: string } = { value: "" }, )>

chat

onSigninSubmit(handle)}>

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

rooms

  • +a.createdAt - +b.createdAt)}> {r.name}
  • no rooms yet. create one below.
onCreateRoomSubmit(newRoom)}>
``` The two branches share the same template instance. After `signin` 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 `chatRooms` is a live, broadcasting LiveTable. ## Room page ```bash Terminal elements create page room ``` The room page renders the message feed and a composer. Inserting a message goes through the room's view, which broadcasts to every browser watching that room. ```ts app/pages/room/index.ts import { Request, Response, sql, session } from "@elements/app"; import room from "./template"; import { Room, chatMessages } from "#app/shared/services/chat"; export default function route(req: Request, res: Response) { 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: chatMessages.view({ roomId }, { orderBy: "createdAt desc", limit: 50, }), }); } ``` The route guards on a logged-in session and resolves the room before opening the partition. A 404 surfaces from `NotFoundError` if the room id in the URL does not exist. The second argument is the window: the newest fifty messages by `createdAt`, tie-broken on `id`, so each browser holds the pages it has loaded rather than the room's whole history. A new message sorts before the loaded range, so it is admitted live; an older page arrives only when the user asks. See `elements man livetable/windows`. ```ehtml app/pages/room/template.ehtml import "./style.css"; import { LiveView, session } from "@elements/app"; import { Room, Message } from "#app/shared/services/chat"; function onSubmit(feed: LiveView, 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}
onSubmit(messages, draft)}>
``` The view holds its rows in window order, newest first, so `toReversed()` puts the oldest loaded message at the top without a sort per render. `messages.more()` fetches the fifty before the oldest loaded row and adds them to the view, and `hasMore` turns false when a page comes back short, which removes the button. Day separators and author grouping go on the row as `e:if` flags, never as a loop inside the loop, and reactions are stored on the message row so they page with the window: `elements man recipes/grouped-feed` has both. `messages.insert(...)` passes every field the feed reads: `userId`, `userName`, and `body`. The partition fills the `roomId` column on insert. The `resetUI` callback clears the draft the moment the optimistic row lands. ## Routes Register the pages in `index.ts`: ```ts index.ts import home from "#app/pages/home"; import room from "#app/pages/room"; // ... app.route("/", home); app.route("/rooms/:id", room); ``` ## Notes - **Sign out, then navigate.** Call `redirect("/")` after `signout()` and the new page opens a fresh view. A view is authorized once, for the user whose route opened it, so `session.logout()` closes that session's views. A view opened by an anonymous request is public and keeps running, and signing in stops nothing, so a public room feed needs no redirect on login. Redirect after signin when the page's view is partitioned on `userId` and has to be re-created as the new user. Full rule: `elements man session`. - **Display a timestamp? Pass one at insert.** If the feed shows `{formatTime(m.createdAt)}`, pass `createdAt: new Date()` in the insert payload so the optimistic row renders its timestamp immediately. The server's `now()` replaces it on reconcile. - **Ephemeral users.** Every `signin` 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 go in a `delete` handler on the `chatRooms` declaration. - **Password login.** This recipe uses handle-only login. For password login, build the auth flow from `elements man recipes authentication` and replace `signin` 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`.