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 each room has its own broadcast channel 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
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 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 -c` 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('chatMessages:roomId=' || r.roomId), 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.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<Room>();
export let chatMessages: LiveTable<Message> = new LiveTable<Message>({
channel: (partition) =>
partition ? `chatMessages:${partition}` : "chatMessages",
insert: (item) => {
session.isLoggedInOrThrow();
return chatMessages.insert(item);
},
});
channel pins the name the trigger notifies. Without it the compiler derives a
channel from where the declaration lives, which is not a name a migration can
write. The partition key is roomId=<id>, so one room's channel is
chatMessages:roomId=<id> and a browser in another room never sees the message.
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.
chatRooms has no partition: every browser on the home page watches the same
channel and sees a new room the moment someone creates it. chatMessages is
opened per room below with view({ roomId }, window); each room is its own
broadcast channel, so a new message in one room only reaches subscribers of
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
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.
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() });
}
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}`);
}
<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
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.
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<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. Opened without it, view({ roomId }) loads every message
the room has ever had into every browser that opens it. 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.ehtml:
import "./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:
import home from "#app/pages/home";
import room from "#app/pages/room";
// ...
app.route("/", home);
app.route("/rooms/:id", room);
Notes
- Sign out is a navigation, by design. A LiveTable view is stamped at
creation with the session that opened it.
session.logout()ends that session, so the server stops the view and the rows freeze at their last state. This is the authorization model working: the route checked the session once, and the check does not re-run when the user changes. Callredirect("/")aftersignout()and the new page opens a fresh view. 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.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
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. - 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 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.