Skip to content

Typing, read state and presence

Typing indicators, unread badges and online dots make a chat feel alive. Your app sends them as the signed-in user with ChatUserClient and receives them from the realtime stream.

Tell other members when the user types, clear the unread badge when they read, and report whether they are online.

  • A ChatUserClient and, for receiving, a connected ChatNotificationsClient. See Live updates.
  • The user is a member of the conversation.
Chat permission: messages.readUser scope: chat:access

messages.typing(conversationId) sends an ephemeral “is typing” signal to the other members. Chat never stores it. Call it from a keystroke handler, but at most once every few seconds, for example every 3 s. Ignore its errors: a missed signal is harmless.

A typing event carries conversation_id and user_id. There is no “stopped typing” event, so hide the indicator after a quiet period.

typing-indicator.ts
import { ChatNotificationsClient } from '@central-code-station/ccs-chat-notifications-client';
import { context } from './fe-guide-context';
declare function showTyping(conversationId: string, userId: string, visible: boolean): void;
const live = new ChatNotificationsClient(context);
const hideTimers = new Map<string, ReturnType<typeof setTimeout>>();
// There is no "stopped typing" event: hide the indicator after a quiet period.
live.on('typing', ({ conversation_id, user_id }) => {
const key = `${conversation_id}:${user_id}`;
showTyping(conversation_id, user_id, true);
clearTimeout(hideTimers.get(key));
hideTimers.set(key, setTimeout(() => showTyping(conversation_id, user_id, false), 5_000));
});
live.connect();

messages.markRead(conversationId) marks it read up to the latest message. Pass a messageId to mark up to that message. Call it when the thread is visible and the newest message is on screen. has_unread on the conversation then turns false the next time you load it. Machine clients cannot mark read.

Send presence.heartbeat() about every 30 seconds while your app is in the foreground. Send presence.offline() when the user leaves or the tab is hidden. Presence is tenant-wide, not per application.

Chat sends web push only to users who are offline. A hidden tab that keeps sending heartbeats keeps the user “online” and suppresses their push, so stop the heartbeat when the app goes to the background.

presence.get(userIds) returns { user_id, status, last_seen_at } for each id. Ids that are not members of the tenant are left out. Fellow members also receive a presence.updated event when a user goes online or offline.

signals.ts
import { chat } from './fe-guide-context';
const TYPING_EVERY_MS = 3_000;
const HEARTBEAT_EVERY_MS = 30_000;
let lastTypingSignal = 0;
/** Call on every keystroke in the composer; at most one request per interval goes out. */
export function onKeystroke(conversationId: string): void {
if (Date.now() - lastTypingSignal < TYPING_EVERY_MS) return;
lastTypingSignal = Date.now();
void chat.messages.typing(conversationId).catch(() => undefined);
}
/** Call when the thread is visible and the newest message is on screen. */
export function onThreadVisible(conversationId: string): Promise<void> {
return chat.messages.markRead(conversationId); // up to the latest message
}
let heartbeat: ReturnType<typeof setInterval> | undefined;
function startPresence(): void {
if (heartbeat) return;
void chat.presence.heartbeat().catch(() => undefined);
heartbeat = setInterval(() => void chat.presence.heartbeat().catch(() => undefined), HEARTBEAT_EVERY_MS);
}
function stopPresence(): void {
clearInterval(heartbeat);
heartbeat = undefined;
void chat.presence.offline().catch(() => undefined);
}
document.addEventListener('visibilitychange', () => (document.visibilityState === 'visible' ? startPresence() : stopPresence()));
window.addEventListener('pagehide', stopPresence);
if (document.visibilityState === 'visible') startPresence();
export async function whoIsOnline(userIds: string[]): Promise<string[]> {
const statuses = await chat.presence.get(userIds); // ids outside the tenant are left out
return statuses.filter((s) => s.status === 'online').map((s) => s.user_id);
}

Type in one browser. The other shows the indicator within a moment and hides it after the quiet period. Open a thread with unread messages, and has_unread is false when you reload the list. presence.get(['client-john']) returns online while John’s app sends heartbeats.

You see Cause Fix
403 not_conversation_member on typing or read The user is not a member. Typing and read state need membership. Join the channel or have your backend add the user.
400 user_required A machine token called a user-only route. Use the user’s token.
429 rate_limited Signals sent on every keystroke. Throttle typing to once every few seconds.
User always shows offline No heartbeats, or the app stopped them. Start the heartbeat when the app is visible.