Understand Chats
This guide explains how to use the Chat adapter to implement real-time messaging between the users of an Epicenter application.
Implementing chat rooms
The code examples are drawn from a template application in the chat branch of the dev-base-build repo. The template app supports episode- and world-scoped group chats, as well as direct messages (DMs) between participants.
Define your conversations
The application defines two types of conversation - a group chat and a DM conversation:
import { GenericScope } from 'epicenter-libs';
type ChannelConversation = {
kind: 'episode' | 'world';
room: string; // Unique room name within the scope.
scope: GenericScope;
label: string; // Display name shown in the sidebar.
};
type DmConversation = {
kind: 'dm';
room: string;
scope: GenericScope;
label: string;
peerKey: string; // The userKey of the DM recipient.
};
export type Conversation = ChannelConversation | DmConversation;
// Helper: produce a stable, symmetric room name for a DM between two users.
export const dmRoom = (a: string, b: string): string =>
`dm:${[a, b].sort().join(':')}`;
Then three types of conversations are constructed from the available session data:
- A chat for everyone participating in this episode.
- A chat for everyone assigned to this world.
- One-on-one conversations between users in the current episode.
import { SCOPE_BOUNDARY } from 'epicenter-libs';
import { Conversation, dmRoom } from './chat/types';
const conversations = useMemo(() => {
const list: Conversation[] = [
// Episode-wide chat — visible to everyone in the episode.
{
kind: 'episode',
room: 'episode-chat',
scope: { scopeBoundary: SCOPE_BOUNDARY.EPISODE, scopeKey: episode.episodeKey },
label: 'Episode Chat',
},
// World-scoped chat — visible only to participants in this world.
{
kind: 'world',
room: 'world-chat',
scope: { scopeBoundary: SCOPE_BOUNDARY.WORLD, scopeKey: world.worldKey },
label: 'World Chat',
},
// DM — one room per pair of participants, scoped to the episode.
...participants
.filter((p) => p.user.userKey !== session.userKey)
.map((p) => ({
kind: 'dm' as const,
room: dmRoom(session.userKey, p.user.userKey),
scope: { scopeBoundary: SCOPE_BOUNDARY.EPISODE, scopeKey: episode.episodeKey },
label: p.user.displayName ?? p.user.detail?.handle ?? p.user.userKey,
peerKey: p.user.userKey,
})),
];
return list;
}, [episode.episodeKey, world, participants, session.userKey]);
Use SCOPE_BOUNDARY.EPISODE for rooms that all participants share and SCOPE_BOUNDARY.WORLD for rooms limited to one team. DMs can be scoped to either — the reference application scopes them to the episode so they survive world changes.
Find or create a chat room
Before fetching messages, you need a chatKey, the server-side identifier for a room. Use chatAdapter.query() to find an existing room by its name and scope, and chatAdapter.create() to create it if it doesn't exist yet:
import { queryOptions } from '@tanstack/react-query';
import { GenericScope, ROLE, chatAdapter } from 'epicenter-libs';
const byRoom = ({ room, scope }: { room: string; scope: GenericScope }) =>
queryOptions({
queryKey: ['chat', 'byRoom', room, scope],
queryFn: async () => {
// Search for an existing room matching the name and scope.
const {
values: [found],
} = await chatAdapter.query({
filter: [
`room=${room}`,
`scopeBoundary=${scope.scopeBoundary}`,
`scopeKey=${scope.scopeKey}`,
],
});
if (found) return found;
// Room doesn't exist yet — create it.
return chatAdapter.create(room, scope, {
readLock: ROLE.PARTICIPANT, // Who can read messages.
writeLock: ROLE.PARTICIPANT, // Who can post messages.
});
},
staleTime: Infinity, // Room identity doesn't change; never re-fetch.
});
export const ChatQuery = { byRoom, messages };
chatAdapter.create() accepts a readLock and writeLock drawn from the ROLE enum. Setting both to ROLE.PARTICIPANT allows any authenticated participant to read and write. Adjust these values if you need facilitator-only rooms. To learn more, read the conceptual topic on permits.
Fetch and display messages
Once you have a chatKey, call chatAdapter.getMessages() to load the message history:
import { ChatMessage } from '~/types/chat';
const messages = ({ chatKey }: { chatKey: string }) =>
queryOptions({
queryKey: ['chat', 'messages', chatKey],
queryFn: () => chatAdapter.getMessages(chatKey) as unknown as Promise<ChatMessage[]>,
});
Reverse message order
The Chat adapter returns messages in reverse-chronological order (newest first). If you want the newest messages to appear at the bottom, reverse the array before rendering:
const { data: rawMessages } = useQuery({
...ChatQuery.messages({ chatKey: chat.chatKey }),
enabled: !!chat.chatKey,
});
// Reverse for display: oldest at the top, newest at the bottom.
const messages = useMemo(() => [...(rawMessages ?? [])].reverse(), [rawMessages]);
Subscribe to live updates
To ensure that chat messages appear in real time without polling, subscribe to the episode's and world's CHAT push channels. When a push arrives, append the new message directly to the query cache or fetch it by ID, then mark the room as unread if it isn't currently open.
import { chatAdapter, PUSH_CATEGORY, SCOPE_BOUNDARY } from 'epicenter-libs';
import { useChannel, useChannelEffect } from '~/query/channel';
import { ChatChannelPush } from '~/types/push';
import { ChatMessage } from '~/types/chat';
// Create channel handles for episode-scoped and world-scoped chat.
const episodeChannel = useChannel({
scopeBoundary: SCOPE_BOUNDARY.EPISODE,
scopeKey: episodeKey,
pushCategory: PUSH_CATEGORY.CHAT,
});
const worldChannel = useChannel({
scopeBoundary: SCOPE_BOUNDARY.WORLD,
scopeKey: worldKey,
pushCategory: PUSH_CATEGORY.CHAT,
});
const onChatPush = async (data: ChatChannelPush) => {
const { queryKey } = ChatQuery.messages({ chatKey: data.content.chatKey });
const { chatMessage, room } = data.content;
// Own messages are already shown via the optimistic update — just reconcile.
if (chatMessage.senderKey === currentUserKey) {
return queryClient.invalidateQueries({ queryKey });
}
if (data.type === 'BROADCAST') {
// Append the broadcast message directly into the cache.
queryClient.setQueryData(queryKey, (prev: ChatMessage[] | undefined) => {
if (!prev) return prev;
if (prev.some((m) => m.id === chatMessage.id)) return prev; // De-duplicate.
return [{ ...chatMessage, receiverKey: chatMessage.receiverKey || null }, ...prev];
});
} else if (data.type === 'TARGETED') {
// Targeted (DM) pushes carry only an ID — fetch the full message.
const fetched = await chatAdapter
.getMessages(data.content.chatKey, { horizon: chatMessage.id, maxRecords: 1 })
.then((response) => (response as unknown as ChatMessage[])[0]);
if (fetched) {
queryClient.setQueryData(queryKey, (prev: ChatMessage[] | undefined) => {
if (!prev) return prev;
if (prev.some((m) => m.id === fetched.id)) return prev;
return [fetched, ...prev];
});
}
}
// Mark the room as unread if the user isn't currently viewing it.
store.set(markChatRoomUnreadAtom, room);
return queryClient.invalidateQueries({ queryKey });
};
useChannelEffect({ token, channel: episodeChannel, callback: onChatPush });
useChannelEffect({ token, channel: worldChannel, callback: onChatPush });
The ChatChannelPush type in src/types/push.ts describes the wire format. Always check data.type: "BROADCAST" for group messages, "TARGETED" for DMs. They require different cache-update strategies.
BROADCAST pushes contain the full message body, so they can be inserted directly into the cache. TARGETED pushes (DMs) contain only the message ID, so the handler fetches the full content using chatAdapter.getMessages() with a horizon and maxRecords: 1.
Send a message
To post a message, call chatAdapter.sendMessage(). For DMs, pass the recipient's userKey in the options object. For group channels, pass an empty object.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { chatAdapter } from 'epicenter-libs';
const sendMutation = useMutation({
mutationFn: (message: string) =>
chatAdapter.sendMessage(
chat.chatKey,
message,
conversation.kind === 'dm'
? { userKey: conversation.peerKey } // Addressed to one recipient.
: {} // Broadcast to the room.
),
// Optimistic update: show the message immediately before the server confirms.
onMutate: async (message) => {
await queryClient.cancelQueries(ChatQuery.messages({ chatKey: chat.chatKey }));
const prev = queryClient.getQueryData(ChatQuery.messages({ chatKey: chat.chatKey }).queryKey);
queryClient.setQueryData(
ChatQuery.messages({ chatKey: chat.chatKey }).queryKey,
(old: typeof prev) => [
{
id: -Date.now(), // Temporary negative ID flags the optimistic entry.
senderKey: currentUserKey,
receiverKey: conversation.kind === 'dm' ? conversation.peerKey : null,
message,
created: Date.now(),
},
...(old ?? []),
]
);
return { prev };
},
// Roll back if the request fails.
onError: (_err, _msg, context) => {
if (context?.prev) {
queryClient.setQueryData(
ChatQuery.messages({ chatKey: chat.chatKey }).queryKey,
context.prev
);
}
},
// Reconcile with the server's confirmed state once settled.
onSettled: () => {
queryClient.invalidateQueries(ChatQuery.messages({ chatKey: chat.chatKey }));
},
});
The optimistic update makes the UI feel instant. A temporary entry with a negative ID is inserted immediately. Then onSettled invalidates the cache so the confirmed server message replaces it.
Track unread rooms
To track which rooms have unread messages and which room is currently active, use lightweight Jotai atoms. This lets the sidebar display an unread indicator without re-fetching messages.
import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';
// The currently open room.
export const activeChatRoomAtom = atom<string | null>(null);
// A set of room names that have unread messages.
export const unreadRoomsAtom = atom<Set<string>>(new Set<string>());
// Write-only: mark a room as unread (no-op if it is currently active).
export const markChatRoomUnreadAtom = atom(null, (get, set, room: string) => {
if (get(activeChatRoomAtom) === room) return;
const next = new Set(get(unreadRoomsAtom));
next.add(room);
set(unreadRoomsAtom, next);
});
// Write-only: clear the unread flag when the user opens a room.
export const clearChatRoomUnreadAtom = atom(null, (get, set, room: string) => {
const next = new Set(get(unreadRoomsAtom));
next.delete(room);
set(unreadRoomsAtom, next);
});
// Read a single room's unread state reactively (used by the sidebar buttons).
export const chatRoomUnreadAtomFamily = atomFamily((room: string) =>
atom((get) => get(unreadRoomsAtom).has(room))
);
Reset chat state
Reset all chat state whenever the combination of user, group, episode, world, and run changes so that stale unread indicators don't carry over between episodes:
export const syncChatStateScopeAtom = atom(null, (get, set, scope: string) => {
if (get(chatStateScopeAtom) === scope) return;
set(chatStateScopeAtom, scope);
set(activeChatRoomAtom, null);
set(unreadRoomsAtom, new Set());
});
Sync chat state
Call syncChatStateScopeAtom from a useEffect in the layout component, passing a string that uniquely identifies the current session context:
const chatStateScope = [
session.userKey,
session.groupKey ?? 'no-group',
episode.episodeKey,
world.worldKey,
run?.runKey ?? 'no-run',
].join(':');
useEffect(() => {
syncChatStateScope(chatStateScope);
}, [chatStateScope, syncChatStateScope]);
Render the sidebar and message panel
With rooms, messages, and unread state in place, all that remains is to render the messages.
List conversations in the sidebar
The sidebar lists all conversations and marks unread rooms with a dot. The message panel displays the history and the send form.
const ConversationButton: FC<ConversationButtonProps> = ({ conversation, activeRoom, onSelect }) => {
const unread = useAtomValue(chatRoomUnreadAtomFamily(conversation.room));
const active = conversation.room === activeRoom;
return (
<button onClick={() => onSelect(conversation.room)}>
{conversation.kind !== 'dm' && <span>#</span>}
<span>{conversation.label}</span>
{unread && <span aria-hidden />} {/* Unread dot */}
</button>
);
};
List messages
{messages.map((msg) => {
const own = msg.senderKey === currentUserKey;
return (
<div key={msg.id}>
<div>
<span>{displayNames.get(msg.senderKey) ?? msg.senderKey}</span>
<span>{formatRelativeTime(msg.created)}</span>
</div>
<div>{msg.message}</div>
</div>
);
})}
Auto-scroll to the latest messages
Because our app lists messages in chronological order, auto-scroll to the bottom whenever the message count increases.
useEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages.length]);
Summary
| Step | Where | What |
|---|---|---|
| 1 | types.ts / index.tsx | Define Conversation objects (kind, room name, scope, label). |
| 2 | chat.ts | Look up or create a room with chatAdapter.query() and chatAdapter.create(). |
| 3 | chat.ts / chat-messages.tsx | Fetch message history with chatAdapter.getMessages(). |
| 4 | chat-layout.tsx | Subscribe to PUSH_CATEGORY.CHAT channels. Handle BROADCAST and TARGETED pushes. |
| 5 | chat-messages.tsx | Send with chatAdapter.sendMessage(). Use optimistic updates for instant feedback. |
| 6 | chat-state.ts | Track active and unread rooms with Jotai atoms. Reset on context change. |
| 7 | chat-sidebar.tsx / chat-messages.tsx | Render the conversation list, unread indicators, message history, and send form. |