Chat Rooms
elements man recipes/chat-rooms Read as markdownA 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
Terminalelements 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 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.
app/shared/services/chat.tsimport { 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<Room>(); export let chatMessages: LiveTable<Message> = new LiveTable<Message>({ 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<Message> annotation because its
initializer refers to it.
Home page
Terminalelements 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.
app/pages/home/index.tsimport { 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() }); }
app/pages/home/template.ehtmlimport "./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}`); } <html class="page-home" ( rooms: LiveView<Room>, private handle: { value: string } = { value: "" }, private newRoom: { value: string } = { value: "" }, )> <main class="page-shell"> <h1>chat</h1> <form e:if={!session.isLoggedIn()} onsubmit={() => onSigninSubmit(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.sort((a, b) => +a.createdAt - +b.createdAt)}> <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={() => onCreateRoomSubmit(newRoom)}> <input type="text" value={newRoom.value} placeholder="new room name" required> <button type="submit">create room</button> </form> </div> </main> </html>
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
Terminalelements 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.
app/pages/room/index.tsimport { 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<Room>(` 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.
app/pages/room/template.ehtmlimport "./style.css"; import { LiveView, session } from "@elements/app"; import { Room, Message } from "#app/shared/services/chat"; function onSubmit(feed: LiveView<Message>, draft: { value: string }) { feed.insert( { userId: session.getOrThrow('userId'), userName: session.getOrThrow('userName'), body: draft.value, }, () => draft.value = "", ); } <html class="page-room" ( room: Room, messages: LiveView<Message>, private draft: { value: string } = { value: "" }, )> <header> <h1>{room.name}</h1> <a href="/">back to rooms</a> </header> <main class="page-shell"> <button e:if={messages.hasMore} onclick={() => messages.more()}>load older</button> <ul class="feed"> <li e:for={m of messages.toReversed()}> <strong>{m.userName}</strong> <span class="body">{m.body}</span> </li> </ul> <form onsubmit={() => onSubmit(messages, draft)}> <input type="text" value={draft.value} placeholder={`message in ${room.name}`} required> <button type="submit">send</button> </form> </main> </html>
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:
index.tsimport 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("/")aftersignout()and the new page opens a fresh view. A view is authorized once, for the user whose route opened it, sosession.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 onuserIdand 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)}, passcreatedAt: new Date()in the insert payload so the optimistic row renders its timestamp immediately. The server'snow()replaces it on reconcile. - Ephemeral users. Every
signincall inserts a newchatUsersrow. The same browser session that signs in twice gets two differentuserIdvalues. 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, thensession.loginwith the found or new id. - Denormalized userName.
chatMessages.userNameis copied fromchatUsers.handleat 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 cascadeonchatMessages.roomIdremoves a room's history when the room is deleted. Add a "delete room" button that callsrooms.delete(room)from the home page; non-creator restrictions go in adeletehandler on thechatRoomsdeclaration. - Password login. This recipe uses handle-only login. For password login,
build the auth flow from
elements man recipes authenticationand replacesigninwith 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-indicatorandelements man recipes presence.