Conversations
A conversation is the message stream between two users (one-to-one) or within a group (group chat). This page shows how to use im.conversations to display the conversation list, unread counts and drafts; pin, mark as unread and delete conversations; and open a conversation or read its details. For the messages inside a conversation, see Messages; for mute settings, see Alerts, push settings and reports.
A typical flow: after sign-in, call im.conversations.list() and subscribe to it. The list updates by itself on new messages, read changes and actions on the user's other devices. When the user clicks a conversation, open its message list with its conversation_key.
The conversation list and unread counts are read from the browser's local storage first, so they show immediately after a reload or while offline, and are then synced with the server. All tabs in the same browser share this data: pin or delete a conversation in one tab and the lists in the other tabs update too.
| You want to | Use |
|---|---|
| Show a conversation list that updates itself | im.conversations.list(); see Show the conversation list |
| Show unread counts and a badge | unread_count, getUnreadTotal(); see Unread counts |
| Clear unread once the user has seen it | markRead(), markAllRead(); see Mark as read |
| Pin, mark as unread or delete a conversation | setPinned(), setMarkedUnread(), remove() |
| Keep unsent text from the input box | setDraft(); see Drafts |
| Show "typing…" | typing(), typing.changed; see Typing indicator |
| Start a chat from contacts or a group | openSingle(), the group ID; see Open a conversation |
Show the conversation list
im.conversations.list() returns a live list (ConversationList). Read the current snapshot with getSnapshot(), subscribe to changes with subscribe(), and call dispose() when you no longer show it:
import { createDRClient } from '@deeprespond/im-web';
import { conversationTitle, displayName, summarize } from '@deeprespond/im-web/render';
const im = createDRClient({ appKey: 'org#app', apiUrl: 'https://im.example.com' });
await im.auth.loginWithTicket({ ticket: await fetchTicketFromYourServer() });
const list = im.conversations.list();
const me = im.auth.currentUser?.username ?? '';
function renderList(): void {
const { items, hasMore, loading } = list.getSnapshot();
const rows = items.map((c) => ({
key: c.conversation_key,
title: conversationTitle(im, c),
preview: c.draft
? `[草稿] ${c.draft.text}`
: c.last_message
? summarize(c.last_message, { nameOf: (u) => displayName(im, u), self: me })
: '',
unread: c.unread_count,
dot: c.unread_count === 0 && c.state?.marked_unread === true,
mentioned: c.state?.mention_seq != null,
pinned: c.state?.pinned_at != null,
muted: c.muted !== null,
}));
render(rows, { hasMore, loading });
}
const unsubscribe = list.subscribe(renderList);
renderList();
// 离开页面时
unsubscribe();
list.dispose();- Snapshot.
itemsis a sorted array of ConversationView.hasMoremeans more conversations can be loaded,loadingmeans a load is in progress, anderroris set if reading failed. - Stable references. If nothing changed,
getSnapshot()returns the same object. When something changes you get a new snapshot in which unchanged conversations keep their old objects, so the UI can compare references to decide which rows to re-render. Snapshots and their objects are frozen and can't be modified. - Batched notifications. Several changes in the same event-loop turn produce one notification. When your listener runs,
getSnapshot()already returns the new snapshot. - Disposal. On sign-out or user switch, the SDK first replaces the snapshot with an empty list and notifies once, then disposes the list, so the UI never keeps showing the previous user's conversations. Call
list()again after signing in. Callinglist()while signed out throwsnot_signed_in. - Display names.
conversationTitle(),displayName()andsummarize()come from@deeprespond/im-web/render; see "Display helpers" in Messages.
Sort order
- Pinned conversations come first, most recently pinned first.
- The rest are ordered by the time of the last message, newest first. Groups with no messages yet use the time you joined.
- One-to-one chats that don't have a server conversation yet (see Open a conversation) use the time of their newest queued message, or the draft's creation time if they only have a draft.
- Typing a draft doesn't move an existing conversation.
- Deleted conversations (
state.hiddenistrue) are hidden and reappear when a new message arrives.
Load more
At first only the top conversations are loaded into memory (1000 by default; change it with the conversationWindow option when creating the client; see Initialization and login). When the user scrolls to the end and hasMore is true, call loadMore(). The SDK reads 100 more from local storage, and once that's exhausted fetches older conversations from the server.
async function onReachEnd(): Promise<void> {
const { hasMore, loading } = list.getSnapshot();
if (hasMore && !loading) await list.loadMore();
}On the first sign-in on a new device, local storage doesn't have every conversation yet. The SDK syncs recent conversations first and fetches older ones from the server on loadMore().
Unread counts
Each conversation's unread_count is its number of unread messages. Group notices, call records, the user's own messages and messages sent with exclude_from_unread don't count.
- Marked as unread. When
unread_countis 0 butstate.marked_unreadistrue, show an unread dot; see Mark as unread. - Mentions. When
state.mention_seqisn'tnull, the conversation has an unread message that @-mentions the user or everyone, and you can show "[Mentioned]" before the preview. It goes back tonullonce the user reads that message. If that message is recalled, it falls back to the previous mention that's still valid. - Conversation being viewed. While the user is looking at a conversation (its message list is open, scrolled to the newest message and the page is visible; see Messages), new messages in it don't add to the local unread count. Mark them as read when the user sees them, or turn on
autoMarkReadwhen creating the client; otherwise the unread count comes back from the server on the next sync.
Unread total
im.conversations.getUnreadTotal() returns the unread total across all conversations synchronously, for the tab title or a navigation badge. conversations.unreadChanged is emitted when it changes:
function updateBadge(): void {
const total = im.conversations.getUnreadTotal({ excludeMuted: true });
document.title = total > 0 ? `(${total}) 消息` : '消息';
}
im.on('conversations.unreadChanged', updateBadge);
updateBadge();- The total covers every conversation, not just those loaded into memory. Deleted conversations don't count.
- A conversation that is only marked as unread, with no unread messages, counts as 1.
- With
excludeMuted: true, muted conversations don't count; see Alerts, push settings and reports. - On a new device whose local conversation list isn't complete yet, the SDK uses the server's unread total, and
excludeMutedhas no effect. The event payload'ssourceis'server'in that case and becomes'local'once the local list is complete.
Mark as read
When the user sees the latest messages in a conversation, call markRead() to move the read position to the newest message:
im.conversations.markRead(conversationId);
// 只标记到某一条(如用户只看到了第 120 条)
im.conversations.markRead(conversationId, { seq: 120 });markRead()doesn't wait or return anything. The SDK merges repeated calls for the same conversation and sends at most once per second. The local unread count drops to zero immediately, and nothing is sent if the read position is already at the newest message.- Marking as read also clears "marked as unread" and mentions. In one-to-one chats with read receipts on, the other user sees "Read"; see "Read receipts" in Messages.
- Open message lists have their own
markRead()that does the same thing. Or setautoMarkRead: truewhen creating the client and let the SDK mark conversations the user is viewing.
To mark every conversation as read:
const { count, truncated } = await im.conversations.markAllRead();count is the number of conversations marked. The server only processes the groups the user is in plus the most recent 1000 one-to-one chats and left groups; truncated is true if older conversations still have unread messages. markAllRead() can be called at most twice per minute; beyond that it rejects with rate_limited (retryAfter is the wait in seconds).
Mark as unread
When the user wants to come back to a conversation later, they can mark it as unread:
await im.conversations.setMarkedUnread(conversationId, true);
// 取消
await im.conversations.setMarkedUnread(conversationId, false);Marking as unread doesn't change the read position, so the other user's "Read" status doesn't change either. It's cleared automatically when the conversation is marked as read, its history is cleared or it's deleted. The setting syncs across all of the user's devices.
Pin conversations
await im.conversations.setPinned(conversationId, true);
await im.conversations.setPinned(conversationId, false);Pinned conversations appear at the top of the list; state.pinned_at is when it was pinned. Each user can pin up to 100 conversations; beyond that it rejects with limit_exceeded (reason is pinned_conversation_limit). Deleting a conversation unpins it.
Delete conversations and clear history
// 从会话列表中删除,消息仍然保留
await im.conversations.remove(conversationId);
// 删除并清空聊天记录
await im.conversations.remove(conversationId, { clear_messages: true });
// 只清空聊天记录,会话仍在列表中
await im.conversations.clearMessages(conversationId);- Delete a conversation. It's removed from the user's list, its unread count is cleared, and it's unpinned and unmarked. It reappears when the other user or the group sends a new message, and earlier messages can still be scrolled back to.
- Clear history. The user can no longer see any earlier messages, and locally saved copies are deleted too. The unread count is cleared. The other user and other group members aren't affected.
- Both affect only the user, and sync across all of their devices.
- Calling
remove()on a one-to-one chat without a server conversation yet (key starting withdraft:) only deletes its draft.
Drafts
When the user has typed text but not sent it, you can save it as the conversation's draft and restore it the next time the conversation opens:
// 离开会话时保存
await im.conversations.setDraft(conversationKey, { text: '明天上午十点', reply_to: { seq: 42 } });
// 打开会话时恢复
const view = await im.conversations.get(conversationKey);
const draftText = view?.draft?.text ?? '';
// 发出后清除
await im.conversations.setDraft(conversationKey, null);- Drafts are stored only in this browser and don't sync to other devices. All tabs in the same browser share them.
- A draft can include
mentions(people already @-mentioned in the input) andreply_to(the message being quoted); they come back when you restore it. A draft with an emptytextand noreply_tocounts as cleared. - The
draftfield in the conversation list updates too, so you can show "[Draft]" in the preview. Drafts don't change the order of existing conversations.
Showing muted conversations
When the user mutes a conversation, ConversationView.muted isn't null. mode is 'none' (no alerts) or 'mention_only' (alert only when the user is @-mentioned; groups only), and until is when it ends (null means indefinitely). In the list you can show a mute icon and a gray dot instead of the unread number.
Mute is set with im.push.mute(). When it expires the SDK restores it and updates the list automatically; see Alerts, push settings and reports.
Typing indicator
Call typing() while the user types, and the other user sees "typing…":
input.addEventListener('input', () => im.conversations.typing(conversationKey));
im.on('typing.changed', ({ conversation_key, usernames }) => {
if (conversation_key === conversationKey) {
render(usernames.length > 0 ? '对方正在输入…' : '');
}
});- You can call
typing()on every keystroke; the SDK sends at most once every 3 seconds, and drops it when not connected. It works in one-to-one chats and groups of up to 100 members. - A received typing indicator disappears if nothing new arrives within 5 seconds.
im.conversations.getTyping(conversation_key)returns who is typing in a conversation synchronously; each item in the conversation list has the same value in itstypingfield.
Open a conversation
Every item in the conversation list has a conversation_key; use it to open the message list. When entering a chat from elsewhere (such as contacts or a group list):
One-to-one: use openSingle() to get the conversation by the other user's username:
const view = await im.conversations.openSingle('lisi');
const messages = im.messages.open(view.conversation_key);If the two users have chatted before, this returns the existing conversation. Otherwise it returns a local "draft conversation" whose conversation_key is draft:lisi and whose conversation_id and state are null. When the first message is sent from it, the server creates the real conversation and the SDK swaps it in automatically:
- In the conversation list, the real conversation replaces the draft in the same position. The
conversations.changedevent includes both the removed draft key and the new conversation ID. - A message list opened with the draft key switches to the real conversation, and its
conversation_keybecomes the new conversation ID. - Methods called afterward with the old key, such as
typing(),setDraft()andim.messages.send(), are mapped to the new conversation ID automatically.
A draft conversation appears in the list only while it has a draft or queued messages; opening one without writing anything doesn't add it. If the other user messages first, or the user starts the chat on another device first, the draft conversation is merged into the real one in the same way.
Group: a group conversation's ID is the group ID, so open it with the group ID directly:
const messages = im.messages.open(groupId);Conversation details
im.conversations.get() reads one conversation from local storage:
const view = await im.conversations.get(conversationId);
if (view?.state) {
const { membership, peer_read_seq, start_seq, max_seq } = view.state;
if (view.conversation_type === 'group' && membership !== 'member') {
render('你已不在群中'); // left 已退出 / removed 已被移出 / dismissed 群已解散
}
if (view.conversation_type === 'single' && view.peer === null) {
render('对方已注销');
}
}- Returns
undefinedif the conversation isn't stored locally. The result is the same object as the item in the conversation list. stateis the server's conversation state (ConversationState) as is, such as the read position, the other user's read position and the user's membership in the group.- Groups the user has left. After leaving, being removed, or the group being dissolved, the conversation stays in the list but
state.membershipis no longermember. The user can still scroll through earlier messages, mark as read, pin and delete the conversation, but can't send, recall, react or pin messages. - Deleted users. When the other user in a one-to-one chat is deleted,
peerisnull,conversationTitle()shows "Deleted user", and no more messages can be sent.
To fetch the latest state from the server right away (for example after a notification from your backend), call refresh():
const latest = await im.conversations.refresh(conversationId);You usually don't need it: the SDK updates conversation state in real time over the connection and syncs automatically after reconnecting.
Listen for conversation changes
The conversation list updates itself, so in most cases subscribing to the list is enough. To react to changes elsewhere (for example in another component), listen for the event:
im.on('conversations.changed', ({ upserted, removed }) => {
// upserted:新增或有变化的会话键;removed:从本地移除的会话键(含并入正式会话的草稿键)
console.log(upserted, removed);
});The event fires after local data is updated. The payload only says which conversations changed; read the latest content with get() or from a list snapshot. For all events, see Events and errors.
API reference
Methods of im.conversations. Apart from list(), markRead(), getUnreadTotal(), getTyping() and typing(), they all return a Promise that rejects with a DRError on failure: not_signed_in when signed out, and network_error or timeout for network errors. For error codes, see Events and errors and the server error codes.
In parameters, conversation_id is the server's conversation ID. conversation_key is the key in the conversation list: the conversation ID for real conversations, or draft:{username} for a one-to-one chat with no server conversation yet.
im.conversations.list()
Gets the conversation list. Each call returns a new list object; call its dispose() when you're done with it.
Returns: ConversationList, a LiveList<ConversationView>:
| Method | Description |
|---|---|
getSnapshot() | The current snapshot { items, hasMore, loading, error? } |
subscribe(listener) | Calls listener (with no arguments) when the snapshot changes. Returns an unsubscribe function |
loadMore() | Loads more conversations. Returns Promise<void>; resolves immediately when there are no more |
dispose() | Disposes the list. Afterward loadMore() rejects with invalid_state (reason is disposed) |
Errors: throws not_signed_in synchronously when signed out.
im.conversations.get()
Reads one conversation from local storage.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID. Can also be draft:{username} to get that draft conversation (undefined if it has no draft or queued messages) |
Returns: Promise<ConversationView | undefined>, undefined if not stored locally.
Errors: not_signed_in, storage_error.
im.conversations.openSingle()
Gets the one-to-one conversation with a user. Returns it from local storage if present; otherwise asks the server, and returns a draft conversation if the two users have no conversation yet.
| Parameter | Type | Required | Description |
|---|---|---|---|
username | string | Yes | The other user's username, case-insensitive |
Returns: Promise<ConversationView>. A draft conversation has conversation_key draft:{username} (lowercase) and null conversation_id and state. A draft conversation is also returned if the username doesn't exist, was deleted or is the user themself; sending will fail in that case.
Errors: local_validation (reason is required: no username given), network_error.
im.conversations.refresh()
Fetches the conversation state from the server again, merges it locally and returns it.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
Returns: Promise<ConversationView>.
Errors: not_found (the conversation doesn't exist or isn't visible to the user), local_validation (reason is not_found: the server returned the conversation but it wasn't saved locally), network_error.
im.conversations.markRead()
Moves the read position to the newest message or a given one. Doesn't wait or return a result; the SDK batches requests and only logs failures.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID. Does nothing for draft conversations or when signed out |
opts.seq | number | No | The sequence number read up to. Defaults to the newest message |
Returns: nothing.
im.conversations.markAllRead()
Marks every conversation as read. On success the SDK immediately syncs conversation state once.
Returns: Promise<{ count: number; truncated: boolean }>. count is the number of conversations marked. When truncated is true, older conversations weren't processed, and local unread counts update after the next sync.
Errors: rate_limited (more than twice per minute; source is local and retryAfter is the wait in seconds), network_error.
im.conversations.setMarkedUnread()
Marks a conversation as unread or clears the mark.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
marked | boolean | Yes | true to mark as unread, false to clear |
Returns: Promise<void>.
Errors: not_found, network_error.
im.conversations.setPinned()
Pins or unpins a conversation.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
pinned | boolean | Yes | true to pin, false to unpin |
Returns: Promise<void>.
Errors: limit_exceeded (reason is pinned_conversation_limit: 100 conversations already pinned), not_found, network_error.
im.conversations.remove()
Deletes a conversation, optionally clearing its history.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID. For draft:{username}, only the draft is deleted |
opts.clear_messages | boolean | No | Also clear the history. Defaults to false |
Returns: Promise<void>.
Errors: not_found, network_error.
im.conversations.clearMessages()
Clears the user's history in a conversation. The conversation stays in the list.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
Returns: Promise<void>.
Errors: not_found, network_error.
im.conversations.setDraft()
Saves or clears a conversation's draft (stored only in this browser).
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_key | string | Yes | Conversation key; can be a draft conversation's draft:{username} |
draft | Draft | null | Yes | The draft; see Draft. null clears it |
Returns: Promise<void>.
Errors: local_validation (reason is required: draft.text isn't a string), storage_error.
im.conversations.getUnreadTotal()
Returns the unread total synchronously. conversations.unreadChanged is emitted when it changes.
| Parameter | Type | Required | Description |
|---|---|---|---|
opts.excludeMuted | boolean | No | Leave out muted conversations. Defaults to false. Has no effect while the server's total is used |
Returns: number.
im.conversations.getTyping()
Returns who is typing in a conversation, synchronously. typing.changed is emitted when it changes.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_key | string | Yes | Conversation key |
Returns: readonly string[], the usernames of people typing, or an empty array.
im.conversations.typing()
Tells the other side the user is typing. Sent at most once every 3 seconds, and dropped when not connected.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_key | string | Yes | Conversation key; draft conversations work too |
Returns: nothing.
Types
ConversationView
An item in the conversation list, combining the server's conversation state with local data.
| Field | Type | Description |
|---|---|---|
conversation_key | string | Conversation key: the conversation ID for real conversations, or draft:{username} for a one-to-one chat with no server conversation yet |
conversation_id | string | null | Conversation ID; null for draft conversations |
conversation_type | 'single' | 'group' | One-to-one or group |
peer | string | null | The other user's username in a one-to-one chat; null if that user was deleted, and for groups |
group_id | string | null | The group ID for group chats; null for one-to-one |
state | ConversationState | null | The server's conversation state; see ConversationState. null for draft conversations |
last_message | MessageView | null | The last message, including ones still in the send queue; see Messages |
unread_count | number | Unread messages (not counting "marked as unread") |
draft | Draft | null | Local draft |
muted | MuteInfo | null | Conversation mute; null if not set or expired. See MuteInfo |
typing | string[] | People who are typing |
ConversationState
The user's state in a conversation, the same as the server's user conversation object.
| Field | Type | Description |
|---|---|---|
conversation_id | string | Conversation ID; for groups this is the group ID |
conversation_type | 'single' | 'group' | One-to-one or group |
peer | string | null | The other user's username in a one-to-one chat; null if that user was deleted |
group_id | string | null | Group ID for group chats |
membership | 'member' | 'left' | 'removed' | 'dismissed' | null | The user's membership in a group: member, left, removed, or group dissolved. null for one-to-one |
start_seq | number | Sequence number of the first message the user can see (joining, clearing history and message expiry all raise it) |
max_seq | number | Sequence number of the last message the user can see |
read_seq | number | The user's read position |
unread_count | number | Unread count as computed by the server. In the UI use ConversationView.unread_count, which includes messages just received locally |
marked_unread | boolean | Whether it's marked as unread |
peer_read_seq | number | null | In one-to-one chats, the sequence number the other user has read up to; null if one-to-one read receipts are off. null for groups |
mention_seq | number | null | Sequence number of the latest unread message that @-mentions the user or everyone |
cleared_seq | number | Position where history was cleared; 0 if never cleared |
hidden_seq | number | Position where the conversation was deleted; 0 if never deleted |
hidden | boolean | Whether the conversation is currently deleted |
pinned_at | string | null | When it was pinned |
joined_at | string | null | For groups, when the user most recently joined |
left_at | string | null | For groups, when the user left |
message_version | number | Version number for message changes in the conversation |
last_message_at | string | null | Time of the last message |
version | number | State version, used by the SDK for sync |
Draft
| Field | Type | Description |
|---|---|---|
text | string | Text in the input box |
mentions | { usernames?: string[]; all?: boolean } | Optional. People already @-mentioned |
reply_to | { seq: number } | Optional. The message being quoted |
created_at | number | When it was first saved (millisecond timestamp). Set by the SDK; any value you pass is ignored |
updated_at | number | When it was last saved. Set by the SDK |
MuteInfo
| Field | Type | Description |
|---|---|---|
mode | 'none' | 'mention_only' | none: no alerts. mention_only: alert only when the user is @-mentioned (groups only) |
until | string | null | When it ends; null means indefinitely |
