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.
Prerequisites
Section titled “Prerequisites”- A
ChatUserClientand, for receiving, a connectedChatNotificationsClient. See Live updates. - The user is a member of the conversation.
messages.readUser scope: chat:access1. Signal typing
Section titled “1. Signal typing”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.
2. Show typing from others
Section titled “2. Show typing from others”A typing event carries conversation_id and user_id. There is no “stopped typing” event, so hide the indicator after a quiet period.
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();3. Mark a conversation read
Section titled “3. Mark a conversation read”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.
4. Report presence
Section titled “4. Report presence”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.
5. Look up who is online
Section titled “5. Look up who is online”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.
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);}Verify
Section titled “Verify”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.
Troubleshooting
Section titled “Troubleshooting”| 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. |