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: 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)}, passcreatedAt: new Date()in the insert payload.createdAtis server-generated (default now()), so the optimistic row has it asundefineduntil the broadcast lands, andnew Date(undefined)rendersInvalid Datein the meantime. The server's realnow()reconciles the placeholder a moment later. - Ephemeral users. Every
signinAsHandlecall 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 can be added with anauthcallback onrooms.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 authenticationand replacesigninAsHandlewith 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.