Messages
Use im.messages to show a conversation's messages, send all kinds of messages, and recall, react, pin and handle read receipts. For the conversation list see Conversations; for uploads see Files.
Quickstart
Open a conversation's message list, subscribe to changes, then send a message:
// 用户点开一个会话
const list = im.messages.open(conversationKey);
const off = list.subscribe(() => renderMessages(list.getSnapshot().items));
renderMessages(list.getSnapshot().items);
// 用户点击“发送”:消息立刻出现在列表末尾,发出后自动更新状态
await im.messages.send({ conversation_key: conversationKey }, { type: 'text', body: { text: '你好' } });
// 用户离开这个会话
off();
list.dispose();All you do is render items. The SDK handles the rest without any code from you:
| The SDK handles | What that means |
|---|---|
| Local data first, then sync in the background | Conversations you've opened show immediately after a refresh or while offline |
| New messages, recalls, reactions, edits | Applied to open lists automatically; no merging on your side |
| Offline sending and retries | Messages queue without a network and send once it's back; failures retry automatically without duplicates |
| Read state and unread counts | New messages in the conversation the user is viewing don't count as unread |
| Multiple tabs | Any tab can send, and every tab's list updates together |
conversationKey is the conversation_key from the conversation list, see Conversations. Message fields (type, body, seq and so on) match the server, see Message format.
Common tasks
| You want to | Use | See |
|---|---|---|
| Load older messages | list.loadOlder() | Load older messages |
| Jump to a message from search, a pin or a mention | open(key, { around_seq }), list.jumpTo() | Jump to a message |
| Render a message by type | type, body and the render helpers | Render a message |
| Send images, voice, video or files | Pass file to send() | Send an image |
| Handle send failures | The message.sendFailed event, resend(), discard() | Failures and resending |
| Mention someone, reply to a message | mentions, reply_to in send() | Mentions, Replies |
| Alert on new messages (notifications, sounds) | The message.received event | Receive new messages |
| Recall or delete | recall(), deleteForMe() | Recall, Delete messages |
| React or pin | react(), setPinned() | Reactions, Pinned messages |
| Show "Read" or "3 unread" | peer_read_seq, receipts() | Read receipts |
| Search chat history | searchLocal() | Find messages |
Show the message list
im.messages.open() returns a message list (MessageList) that keeps itself up to date. A complete render usually looks like this:
import type { MessageView } from '@deeprespond/im-web';
const list = im.messages.open(conversationKey);
function renderMessages(): void {
const { items, gaps, hasOlder, loading, pinned, error } = list.getSnapshot();
render({
messages: items.map((m: MessageView) => ({
key: m.seq !== null ? `s${m.seq}` : `p${m.client_msg_id}`,
mine: m.sender === im.auth.currentUser?.username,
status: m._local.status, // sent / queued / uploading / sending / failed
message: m,
})),
gaps,
showLoadOlder: hasOlder,
spinner: loading !== 'none',
pinned,
error,
});
}
const unsubscribe = list.subscribe(renderMessages);
renderMessages();
// 离开这个会话时
unsubscribe();
list.dispose();Snapshot fields (what getSnapshot() returns):
| Field | Use it for |
|---|---|
items | Messages, oldest to newest by sequence; unsent messages come last |
hasOlder | There are older messages above; show "Load more" |
loading | 'initial' (opening), 'older', 'newer' or 'none', for loading spinners |
error | The error, if loading failed |
pinned | The conversation's pinned messages, see Pinned messages |
gaps | Messages in the middle that aren't loaded yet, see Gaps |
Message keys: sent messages use seq (the sequence within the conversation); unsent messages have seq set to null, so use client_msg_id. The example above does this.
Always call dispose(): otherwise the SDK keeps filling in messages for the list, and the conversation keeps counting as "being viewed", so new messages don't count as unread.
Compare by reference: getSnapshot() returns the same object until something changes; then unchanged messages are still the original objects, so you can rely on React's memo and similar optimizations. Snapshots are read-only.
You can open several lists for one conversation at once (say, the main window and a pop-out); they share local data.
Local data first
On open, the SDK shows the latest 50 messages stored locally, then fetches anything missed while offline in the background and updates the snapshot:
| Case | What happens |
|---|---|
| A conversation opened before | Shows immediately after a refresh or while offline |
| The first open on a new device | Nothing is stored locally; loading is 'initial' until the latest page arrives |
| New messages while the list is open | Filled in automatically, with no user action |
| Messages older than the app's retention period | No longer shown, even if still stored locally |
Tell the SDK whether the user is viewing
While the user is "viewing" a conversation, new messages don't count as unread; with autoMarkRead enabled when creating the client, they're also marked read automatically.
By default the SDK assumes an open list is "at the newest message and visible". Tell it otherwise in two cases:
- The user scrolls up into history and is no longer at the newest message.
- The list is hidden, for example a page cached by Vue's
<KeepAlive>, or a tabbed UI switched to another panel.
scroller.addEventListener('scroll', () => {
const atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 40;
list.setViewing({ atBottom });
if (atBottom) list.markRead();
});
// 面板被切走、再切回时
list.setViewing({ visible: false });
list.setViewing({ visible: true });list.markRead() moves the read position to the newest message, like im.conversations.markRead(); the SDK merges repeated calls into one request. When the browser tab is hidden, the SDK treats the user as not viewing automatically.
Load older messages
When the user scrolls to the top and hasOlder is true, call loadOlder() for 50 more:
async function onReachTop(): Promise<void> {
const { hasOlder, loading } = list.getSnapshot();
if (hasOlder && loading === 'none') await list.loadOlder();
}Locally stored messages are read directly; the rest come from the server. On failure the snapshot has error; tell the user and call it again.
Jump to a message
When the user arrives from a search result, a pinned message, a reply or a mention, open the list positioned at that message:
// 打开会话并定位到第 1024 条(取它前后各约 25 条)
const around = im.messages.open(conversationKey, { around_seq: 1024 });
// 已打开的列表中跳转
await list.jumpTo(1024);
// 定位后向下翻到最新
if (list.getSnapshot().hasNewer) await list.loadNewer();After jumping, the list isn't at the newest message: hasNewer is true and unsent messages are hidden for now. Call loadNewer() as the user scrolls down; things return to normal once they reach the newest message. To go straight back to the newest, dispose of the list and open it again without around_seq.
The sequence of the message that mentioned the user is mention_seq in the conversation state, see Conversations.
Missing messages in the middle (gaps)
gaps lists ranges of messages in the middle of the list that aren't loaded, each a sequence range from from_seq to to_seq inclusive. Show a marker at that position:
kind | When it appears | What to show |
|---|---|---|
skipped | After a long time offline, many new messages piled up and the SDK only fetched the newest | "Show earlier messages"; call loadGap(gap) on click. Don't treat it as "no messages" |
rejoin | The user left a group and rejoined, and can't see messages from between the two joins | A divider such as "Messages before you rejoined"; it can't be loaded |
import type { Gap } from '@deeprespond/im-web';
function onGapClick(gap: Gap): void {
if (gap.kind === 'skipped') void list.loadGap(gap);
}At a skipped gap, hasOlder is false; the user continues upward by clicking the gap.
Render a message
Each item in the list is a MessageView: the server's message object plus the SDK's local state in _local. Render it by type:
import type { MessageView } from '@deeprespond/im-web';
import { displayName, renderCallRecord, renderRecall, renderTip } from '@deeprespond/im-web/render';
function describe(m: MessageView): string {
const me = im.auth.currentUser?.username ?? '';
const group_id = m.conversation_type === 'group' ? m.conversation_id : undefined;
const nameOf = (u: string) => displayName(im, u, { group_id });
if (m.recalled) return renderRecall(m, { self: me, nameOf });
if (m.erased) return '[消息已删除]';
if (m._local.unsupported) return '[当前版本不支持此消息]';
const body = (m.body ?? {}) as Record<string, unknown>;
switch (m.type) {
case 'text':
return String(body.text);
case 'tip':
return renderTip(m, { nameOf, self: me });
case 'call':
return renderCallRecord(m, { self: me });
case 'custom':
// 涉及资金、订单的卡片只信任不是由用户在客户端发出的
return m._local.trusted ? `[卡片] ${String(body.title ?? '')}` : '[消息]';
default:
return `[${m.type}]`;
}
}How to show each part:
| Part | How to tell | How to show it |
|---|---|---|
| Sender | sender is a username | Use displayName() for the friend remark, group nickname or nickname |
| System message | sender_type is 'system' and sender is null | Group notices and group messages sent as the system; usually centered |
| Deleted user | sender_type is 'user' and sender is null | "Deleted user" |
| Unknown type | _local.unsupported is true | "This message isn't supported in this version"; don't drop it |
| Trusted card | _local.trusted is true | Written by your server, the console or the system. Only trust custom cards about money or orders when this is set |
| Recalled or erased | recalled isn't null, or erased is true | The content is cleared; show a notice, using renderRecall() for recalls |
| Edited | edited isn't null | You can show "Edited", see Editing |
| Reply | reply_to is { seq, sender } | Find the original in the list by sequence; if it's not there, fetch it with im.messages.lookup(); if it was recalled, show "Original message recalled" |
| Image, voice, video, file | body.url is a file address | It can't go straight into <img src>; get a download address first, see Files |
| Time | created_at, an ISO 8601 string | Format it with Intl.DateTimeFormat or your date library; unsent messages have null, which you can show as "Just now" |
Send messages
im.messages.send(to, content, opts?) sends a message. to says where it goes:
to | Description |
|---|---|
{ conversation_key } | An open conversation: one-to-one, group or draft |
{ to_user: 'lisi' } | A user, even without an existing conversation |
{ to_group: groupId } | A group |
Send text
const local = await im.messages.send({ conversation_key: conversationKey }, { type: 'text', body: { text: '你好' } });
console.log(local._local.status); // 'queued'send() returns without waiting for the message to go out. Its status changes step by step and the list updates with it:
_local.status | Meaning |
|---|---|
queued | Waiting in the send queue |
uploading | Uploading an attachment (image, file and so on) |
sending | Being sent |
sent | Sent; the item is replaced by the server's message with seq, message_id and created_at |
failed | Failed, see Failures and resending |
If you need to wait until it's sent (for example before navigating away), set waitUntil: 'sent'; you get the server's message once it's sent, or an error if it finally fails:
const sent = await im.messages.send({ to_group: groupId }, { type: 'text', body: { text: '会议纪要已上传' } }, { waitUntil: 'sent' });
console.log(sent.seq, sent.message_id);The SDK first checks the type, required fields, control characters, size and mention rules locally; if any fail, it throws right away and the message never enters the queue. Text can't be empty but can contain line breaks.
Send queue
| Case | Behavior |
|---|---|
| No network | The message queues as queued and sends once online; it survives page refreshes and browser restarts |
| Order | Messages in one conversation go out in call order; different conversations send in parallel |
| Sending many in a row | The SDK paces itself (4 per second per conversation, 8 per second overall), so the server's rate limit isn't hit |
| Multiple tabs | Any tab can call send(); one of them does the sending |
| Not sent after 48 hours | No more automatic attempts; it's marked failed (send_abandoned, reason expired) and the user decides whether to resend |
Before the page closes, im.hasPendingWork() is true while messages or uploads are unfinished, so you can warn the user:
window.addEventListener('beforeunload', (e) => {
if (im.hasPendingWork()) e.preventDefault();
});Nothing is lost without the warning either: sending resumes the next time the page opens.
Failures and resending
On network errors, timeouts and temporary server failures, the SDK retries automatically (after 1, 2, 4, 8, 16 and 30 seconds) without creating duplicates; after 2 minutes of failures with a working network, it marks the message failed. Messages the server rejects (muted, blocked by the recipient and so on) fail immediately without retries.
Failed messages stay in the list with _local.status set to 'failed' and the cause in _local.error, and a message.sendFailed event is emitted. Let the user resend or delete:
import { describeError } from '@deeprespond/im-web/locale/zh-CN';
im.on('message.sendFailed', ({ error }) => {
showToast(describeError(error)); // 如“你已被禁言”“对方拒收了你的消息”
});
// 用户点击“重发”
await im.messages.resend(failed.client_msg_id!);
// 用户点击“删除”(只删除本地的这条,没完成的上传一并取消)
await im.messages.discard(failed.client_msg_id!);resend()keeps the originalclient_msg_id, which the server uses to deduplicate; failed attachment uploads start over.- A failed message with
_local.status_unknownset totruemay actually have been sent (the app's rules changed during retries); you can show "Send status unknown". The SDK checks after the next sync and replaces it with the server's message if it was sent.
Common causes:
code | Cause |
|---|---|
user_muted | The user is muted |
group_muted | Muted in the group: reason member means the user is muted, all means everyone is; details.muted_until is when it ends |
not_group_member | No longer in the group |
user_blocked | The recipient blocked the user |
not_friend | The app requires friendship to chat |
message_rejected | Failed moderation; when reason is app_rejected, message is the text from your server |
not_found | The recipient doesn't exist or was deleted |
payload_too_large | Over the message size limit |
For every error code, see Events and errors and the server error codes.
Send an image
Pass the file the user picked straight to send(); the SDK processes and uploads it, then sends:
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
try {
await im.messages.send({ conversation_key: conversationKey }, { type: 'image', file });
} catch (err) {
showToast(err); // 调用时的检查不通过,如服务端没有开通文件服务
}
});- While uploading: the message appears in the list right away with
_local.status'uploading'and_local.progressfrom 0 to 1. You can show a local preview withURL.createObjectURL(file). - Compression: the long edge is scaled down to 2048 pixels and the image is re-encoded, with metadata such as location removed; GIFs are left alone. To send the original, set
original: true(only metadata is removed). - Sent as a file instead: images over 20 MB or 50 megapixels, or in formats the browser can't decode (such as HEIC), are sent as file messages with
typefile. The same applies to voice and video in formats the server doesn't accept. - Size fields in
body: the SDK fills inurl,thumbnail_url,width,height,sizeandformatand ignores yours; you can add your own fields. - Page refreshes: if the page refreshes mid-upload, the upload resumes when it reopens (files up to 50 MB); larger ones are marked failed (
send_abandoned,reasonattachment_lost), so ask the user to pick the file again.
To upload without sending, or to download and display images, see Files.
Send voice
Record with im.files.recordVoice() and get the recording and its duration when done:
const recorder = await im.files.recordVoice(); // 用户没有允许使用麦克风时以 permission_required 拒绝
await recorder.start();
// 用户松开“按住说话”
const { blob, duration_seconds } = await recorder.stop();
await im.messages.send({ conversation_key: conversationKey }, { type: 'voice', file: blob, body: { duration_seconds } });body.duration_seconds is required (seconds, rounded up, at least 1). Voice files are at most 5 MB. Browsers without recording support throw unsupported; call recorder.cancel() if the user cancels.
Send video
await im.messages.send({ conversation_key: conversationKey }, { type: 'video', file });The SDK reads the duration and dimensions and uploads the first frame as the cover. Video formats the server doesn't accept are sent as file messages.
Send a file
await im.messages.send({ conversation_key: conversationKey }, { type: 'file', file, body: { name: '季度报告.pdf' } });body.name is the displayed file name and defaults to the File name. Files can't exceed the app's upload limit (max_upload_bytes in the runtime config); over the limit you get local_validation (reason too_large, with the limit in details.max_bytes).
Send an attachment that's already uploaded
If the file is already uploaded (with im.files.upload(), or on your own file server), give the body directly and nothing is uploaded:
await im.messages.send({ conversation_key: conversationKey }, {
type: 'image',
body: { url: 'https://im.example.com/media/v1/f/99606017553203200/5nOzK6E40hgYIKl80dYVLQ', width: 1080, height: 720 },
});url is required; dimensions, size and duration must be non-negative integers. If the app only allows this service's file addresses (media_url_only), external addresses are rejected, see Message format.
Send a location
await im.messages.send({ conversation_key: conversationKey }, {
type: 'location',
body: { latitude: 39.9087, longitude: 116.3975, name: '天安门', address: '北京市东城区' },
});Latitude is between -90 and 90, longitude between -180 and 180.
Send a custom message
The body of a custom message is any JSON object you define, such as an order card, a contact card or a business notice:
await im.messages.send({ conversation_key: conversationKey }, {
type: 'custom',
body: { card: 'order', order_id: '8812', title: '你的订单已发货' },
});Strings can't contain control characters (including line breaks). Users can also send cards that look like "transfers" or "orders" from a client, so recipients should only trust these when _local.trusted is true, meaning your server sent them.
Mentions
Group messages can mention members or everyone. You write the "@Li Si" in the text yourself; the SDK only submits mentions:
await im.messages.send({ to_group: groupId }, { type: 'text', body: { text: '@李四 @王五 请确认' } }, {
mentions: { usernames: ['lisi', 'wangwu'] },
});
// @ 全体成员(只有群主和管理员可以)
await im.messages.send({ to_group: groupId }, { type: 'text', body: { text: '@所有人 明天放假' } }, {
mentions: { all: true },
});| Rule | If broken |
|---|---|
| Group messages only | local_validation (reason mentions_not_allowed) |
| At most 20 people per message; non-members are ignored | — |
| Only owners and admins can mention everyone | permission_denied (reason mention_all_denied) |
Mentioned users see "You were mentioned" in the conversation list, see Conversations.
Replies
await im.messages.send({ conversation_key: conversationKey }, { type: 'text', body: { text: '同意' } }, {
reply_to: { seq: quoted.seq! },
});Give only the sequence of the message you're replying to. It must be in the same conversation and not recalled, or the server returns invalid_argument (reason invalid_reply).
Other options
| Option | Description |
|---|---|
ext | Your own extension fields (a JSON object), stored and returned as is, such as a business ID or forwarding source |
need_receipt | Ask for group read receipts; group chats only, see Group read receipts |
exclude_from_unread | Don't count as unread for recipients; good for business messages that don't need an alert |
push | { disabled: true } skips the offline push to recipients |
waitUntil | 'queued' (default) returns once queued; 'sent' returns once sent |
A message's type, body, ext, mentions and reply_to together can't exceed the app's size limit (max_message_body_bytes in the runtime config, 5 KB by default).
Message someone without a conversation
Send with to_user, or open the one-to-one chat first with im.conversations.openSingle():
const view = await im.conversations.openSingle('lisi');
const list = im.messages.open(view.conversation_key); // 还没有会话时为 draft:lisi
await im.messages.send({ conversation_key: view.conversation_key }, { type: 'text', body: { text: '你好,我是张三' } });Until a conversation exists, messages sit in the draft conversation draft:lisi with conversation_id null. Once the first message is sent, the server creates the conversation and the SDK swaps the draft for it:
- Open lists switch over automatically, and
conversation_keybecomes the new conversation ID. - The
messages.changedevent includesrenamed_from(the old draft key); if you usedconversation_keyin routes or caches, update them from it.
If the other user doesn't exist, was deleted or is the user themself, sending fails (not_found or invalid_argument).
Forward
forward() sends a message's type and content to someone else as a new message, reusing attachment addresses without re-uploading:
await im.messages.forward(message, { to_user: 'wangwu' }, { ext: { forwarded_from: message.message_id } });Group notices, call records, and recalled or erased messages can't be forwarded (local_validation). Attachments expire based on their upload time, so a forwarded message can't be downloaded after that either. For combined forwarding, define your own custom message.
Online-only messages
Real-time signals such as "recording a voice message" or whiteboard strokes don't need storing; send them with sendOnline(). They go only to the recipients' devices online right now, aren't stored and don't count as unread; offline devices never get them, even later.
await im.messages.sendOnline({ to_group: groupId }, { body: { signal: 'whiteboard', stroke: [1, 2, 3, 4] } });
im.on('message.online', ({ message }) => {
console.log(message.sender, message.body);
});customtype only. They skip the send queue and aren't retried; without a network, the call throws right away.- The recipient's
message.onlineevents are already deduplicated, and these messages don't appear in message lists. - For typing indicators, use
im.conversations.typing(), see Conversations.
Receive new messages
Open message lists, the conversation list and unread counts all update automatically, so you usually don't need new-message events. To alert the user (browser notifications, sounds), listen for message.received:
im.on('message.received', ({ message, alert }) => {
if (alert) console.log('新消息', message.conversation_id, message.seq);
});- Only fires for messages from others received in real time. Messages the user sent from other devices and messages from while offline (filled in by sync) don't fire it.
- Every tab receives the event; alert only in the tab where
alertistrueto avoid duplicates. Thenotifyfield in the payload is the SDK's suggestion based on push settings and Do Not Disturb; see Alerts, push settings and reports.
To follow changes in a conversation (new messages, recalls, reactions, send status), use the messages.changed event:
im.on('messages.changed', ({ conversation_key, upserted, removed, pending, renamed_from }) => {
// upserted:新增或变化的消息序号;removed:删除的序号;pending:发送队列中变化的 client_msg_id
console.log(conversation_key, upserted, removed, pending, renamed_from);
});When the user's own message is sent, the same event has its client_msg_id in pending and its new sequence in upserted, which you can use to keep the scroll position steady.
Recall
if (im.messages.canRecall(message)) {
await im.messages.recall(message.conversation_id, message.seq!);
}Use canRecall() to decide whether to show a "Recall" menu item. The rules:
| Who | Can recall |
|---|---|
| One-to-one | Only their own messages, within the recall window (recall_window_seconds in the runtime config, 120 seconds by default) |
| Ordinary group members | Their own messages, within the window |
| Group admins | Their own messages (within the window) and ordinary members' messages |
| Group owners | Anyone's messages, at any time |
- Group notices, call records and system messages can't be recalled; delete unsent messages with
discard(). - Messages in a group the user has left can't be recalled (
permission_denied,read_only_membership).canRecall()doesn't check this, so also check that the conversation'sstate.membershipismember. - Past the window you get
permission_denied(reasonrecall_window_expired); without permission,recall_denied.
After a recall, both sides see it as recalled: recalled is { by, role, at } and the content is cleared. Use renderRecall() to show "You recalled a message", "Zhang San recalled a message" or "Zhang San recalled a message from Li Si"; recalls by your server or moderation (role server or platform) show "A message was recalled".
Editing
The Web SDK can't edit messages. Your server can edit text and custom messages (see Recall, edit and pin); client lists update to the new content automatically, and edited is { at, count }, so you can show "Edited".
Delete messages
deleteForMe() deletes messages for the user only; the other side and other group members are unaffected, and the user's other devices sync the deletion:
await im.messages.deleteForMe(conversationId, [101, 102, 105]);Up to 100 at a time. There's no delete-for-everyone; recall instead so the other side can't see it. Delete unsent messages with discard().
Reactions
const id = message.conversation_id;
const seq = message.seq!;
// 添加、取消
await im.messages.react(id, seq, 'thumbs_up');
await im.messages.unreact(id, seq, 'thumbs_up');
// 显示:每种表情的人数、前 3 个回应的人,以及本人是否回应过
for (const r of message.reactions ?? []) {
const mine = message._local.reacted?.[r.key] ?? r.reacted ?? false;
console.log(r.key, r.count, r.users, mine);
}
// 回应这种表情的全部人(翻页)
const page = await im.messages.reactionUsers(id, seq, 'thumbs_up', { limit: 50 });
console.log(page.items, page.next_cursor);- You define the reaction
keys (such asthumbs_upandheart); keep them consistent across platforms. - Reactions must be enabled for the app (
message_reaction_enabledin the runtime config), otherwise you getpermission_denied(reaction_disabled). - Each message allows up to
max_reactions_per_messagekinds; beyond that you getlimit_exceeded(reaction_limit). - Group notices and recalled messages can't get reactions; in a group the user has left, they can't react or see who reacted (
read_only_membership). changedisfalsein the result if nothing changed. Other people's reactions update the list in real time.
Pinned messages
await im.messages.setPinned(message.conversation_id, message.seq!, true);
// 消息列表的快照中有这个会话的置顶消息,按置顶时间倒序
for (const item of list.getSnapshot().pinned) {
console.log(item.seq, item.pinned.by, item.message?.body ?? '加载中');
}- Both sides of a one-to-one chat can pin; in groups only owners and admins can (otherwise
permission_denied,pin_denied). - Each conversation allows up to
max_pinned_messages_per_conversationpins (beyond that,limit_exceeded,pinned_message_limit). - Pinned messages whose content isn't loaded yet have
messageset tonull; the snapshot updates once it arrives. On click, jump to it withjumpTo(item.seq).
Read receipts
One-to-one
No separate query is needed: peer_read_seq in the conversation state is how far the other side has read, so the user's own messages with seq up to it show "Read". It updates in real time as the other side reads.
const view = await im.conversations.get(list.conversation_key);
const peerRead = view?.state?.peer_read_seq ?? 0;
const me = im.auth.currentUser?.username;
const enabled = im.config?.single_read_ack_enabled === true;
for (const m of list.getSnapshot().items) {
if (enabled && m.sender === me && m.seq !== null) {
console.log(m.seq, m.seq <= peerRead ? '已读' : '未读');
}
}Don't show read status unless one-to-one read receipts are enabled for the app (single_read_ack_enabled in the runtime config). Check that setting, not whether peer_read_seq is null.
Group chats
Group read receipts require need_receipt: true when sending, and only the sender (while still in the group) can see them. Group read receipts must be enabled for the app (group_read_ack_enabled), otherwise sending returns permission_denied (read_ack_disabled).
await im.messages.send({ to_group: groupId }, { type: 'text', body: { text: '请大家确认' } }, { need_receipt: true });
// 查询已读人数:可以逐条调用,SDK 合并请求
const counts = await im.messages.receipts(groupId, seqs);
for (const c of counts) {
if ('read_count' in c) console.log(c.seq, `${c.read_count} 人已读,${c.unread_count} 人未读`);
else console.log(c.seq, '不能查看', c.code);
}
// 已读人数变化时(成员读到了你的消息)
im.on('receipts.changed', ({ conversation_id, seqs: changed }) => {
for (const seq of changed) console.log(seq, im.messages.getReceipt(conversation_id, seq));
});
// 已读、未读的人
const readers = await im.messages.receiptUsers(groupId, seqs[0]!, { status: 'read', limit: 50 });
console.log(readers.items, readers.next_cursor);receipts(): fine to call per message as messages scroll into view; the SDK merges, deduplicates (skipping anything queried in the last 10 seconds) and batches the requests. A message that can't be checked (not sent by the user, or no receipt requested) comes back as{ seq, code }without affecting the rest. If the user has left the group, you getpermission_denied(receipt_denied).getReceipt(): reads the latest count from memory synchronously;undefinedif never queried.- Live updates: in groups of up to 500 people, counts are pushed as members read, and the SDK emits
receipts.changed; larger groups need explicit queries. - Frequency: receipt queries, receipt lists and reaction lists share a limit of 60 per user per minute.
Find messages
Fetch by sequence: when a message mentioned in a reply, pin or notification isn't in the list, fetch it from the server with lookup() (it's stored locally too):
const { items, missing } = await im.messages.lookup(conversationId, [3, 57]);
// missing:不存在、已过期或本人看不到的序号Search chat history: the server has no message search. searchLocal() searches text messages stored in this browser by keyword (case-insensitive), newest first:
import type { MessageView } from '@deeprespond/im-web';
const results = await im.messages.searchLocal({ keyword: '季度报告', limit: 20 });
// 用户点击一条结果时,打开会话并定位到这条消息
function onResultClick(m: MessageView) {
return im.messages.open(m.conversation_id, { around_seq: m.seq! });
}It only finds messages stored locally (from conversations that were opened or synced). Pass conversation_id to search a single conversation.
Display helpers
@deeprespond/im-web/render provides functions that build display text, and @deeprespond/im-web/locale/zh-CN provides the Chinese strings. Both are optional; you can write your own.
import { conversationTitle, displayName, renderCallRecord, renderRecall, renderTip, summarize } from '@deeprespond/im-web/render';| Function | Purpose | Example output |
|---|---|---|
displayName(im, username, opts?) | A user's display name | Li Si |
conversationTitle(im, view) | Conversation title: the other user's display name, or the group name | Product discussion |
summarize(message, opts) | The last-message summary in the conversation list | [Image], [File] report.pdf |
renderTip(message, opts) | Group notice text | Zhang San invited Li Si and Wang Wu to the group |
renderCallRecord(message, opts) | Call record text | Video call, duration 03:12; Missed call |
renderRecall(message, opts) | Recall notice | You recalled a message |
Display names
displayName(im, username, { group_id }) returns the first non-empty of: the friend remark you set, their group nickname in this group (when group_id is given and group member data is stored locally), their nickname, and their username. Leave out group_id outside groups.
import { displayName } from '@deeprespond/im-web/render';
const inChat = displayName(im, 'lisi');
const inGroup = displayName(im, 'lisi', { group_id: '99582580415791104' });- Synchronous: if the user's profile isn't stored locally, it returns the username first and fetches the profile in the background, then emits
users.changed; re-render to show the nickname. Changes to remarks, group nicknames and group names emit events too (friends.changed,groupMembers.changed,groups.changed). - The
nameOfoption: turns usernames into display names, usually(u) => displayName(im, u, { group_id }). - The
selfoption: the user's own username. In group notices and recall notices the user appears as "You"; call records show "Canceled" or "Canceled by caller" depending on who started the call. - Fallback text: deleted users show as "Deleted user"; unknown group notices show "[Group notice]", and unknown message types summarize as "[Message]".
Replace the strings
Strings come from the zhCN table. Every function takes a locale option, so you can replace some entries or switch to another language entirely:
import { summarize } from '@deeprespond/im-web/render';
import { zhCN, type Locale } from '@deeprespond/im-web/locale/zh-CN';
const en: Partial<Locale> = {
you: 'You',
deletedUser: 'Deleted user',
summary: { ...zhCN.summary, image: '[Photo]', voice: '[Voice]', video: '[Video]', file: '[File] {name}' },
};
const text = summarize({ type: 'image', body: {}, sender: 'lisi', sender_type: 'user', recalled: null }, { nameOf: (u) => u, locale: en });
// '[Photo]'When replacing a group (such as summary, tip, recall or call), spread that group from zhCN first and then override; entries you leave out stay in Chinese. Keep placeholders such as {name} as they are.
describeError(err) gives Chinese text for an error code, see Events and errors.
API reference
im.messages.open()
Opens a conversation's message list. Each call returns a new list; call its dispose() when you're done with it.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_key | string | Yes | Conversation key: a conversation ID, a group ID, or a draft conversation's draft:{username} |
opts.around_seq | number | No | Open positioned around this sequence number |
Returns: MessageList; see MessageList.
Errors: throws not_signed_in or local_validation (reason is required) synchronously.
im.messages.send()
Sends a message: validates it and writes it to the send queue, uploading any file first.
| Parameter | Type | Required | Description |
|---|---|---|---|
to | SendTarget | Yes | Who to send to; see SendTarget |
content | OutgoingContent | Yes | Message type and content; see OutgoingContent |
opts | SendOptions | No | See SendOptions |
Returns: Promise<MessageView>. By default, the local message once written to the send queue (seq is null); with waitUntil: 'sent', the final message once sent.
Errors:
- On the call:
local_validation(reasonisrequired,control_characters,invalid_value,invalid_type,too_large,mentions_not_allowed,receipt_not_allowed,too_many_items,unknown_typeornot_found, withdetails.fieldnaming the field),permission_denied(mention_all_denied,read_ack_disabled),unsupported(media_disabled: the server has no file service),invalid_state(peer_deleted: the other user in the one-to-one chat was deleted),not_signed_in. - With
waitUntil: 'sent', also send failures, the same aserrorinmessage.sendFailed.
im.messages.resend()
Resends a failed message. Does nothing if the message isn't in the failed state.
| Parameter | Type | Required | Description |
|---|---|---|---|
client_msg_id | string | Yes | The failed message's client_msg_id |
Returns: Promise<void>, resolved once it's back in the send queue.
Errors: local_validation (reason is not_found: it isn't in the send queue).
im.messages.discard()
Deletes a message from the send queue (failed or still waiting) and cancels any unfinished upload. Affects only local data.
| Parameter | Type | Required | Description |
|---|---|---|---|
client_msg_id | string | Yes | The message's client_msg_id |
Returns: Promise<void>.
im.messages.sendOnline()
Sends an online-only custom message.
| Parameter | Type | Required | Description |
|---|---|---|---|
to | SendTarget | Yes | Who to send to |
content.body | Record<string, unknown> | Yes | Message body |
content.ext | Record<string, unknown> | No | Extension fields |
Returns: Promise<OnlineResult>; see OnlineResult.
Errors: local_validation, network_error, and the same server rejections as regular messages.
im.messages.forward()
Sends a message's type and content as a new message.
| Parameter | Type | Required | Description |
|---|---|---|---|
message | MessageView | Yes | The message to forward |
to | SendTarget | Yes | Who to send to |
opts | SendOptions | No | Same as send() |
Returns: Promise<MessageView>, same as send().
Errors: local_validation (reason is unknown_type for group notices and call records; invalid_body for recalled or erased messages; url_not_allowed when the app has media_url_only on and the attachment URL isn't allowed), plus the errors of send().
im.messages.recall()
Recalls a message.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
seq | number | Yes | The message's sequence number |
Returns: Promise<{ changed: boolean }>. changed: false means it was already recalled.
Errors: permission_denied (recall_window_expired, recall_denied, read_only_membership), not_found.
im.messages.canRecall()
Checks synchronously whether the user can recall this message, to decide whether to show a recall option.
| Parameter | Type | Required | Description |
|---|---|---|---|
message | MessageView | Yes | The message |
Returns: boolean.
im.messages.deleteForMe()
Deletes messages for the user only.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
seqs | number[] | Yes | Sequence numbers, up to 100. Does nothing for an empty array |
Returns: Promise<void>.
Errors: local_validation (reason is too_many_items), not_found.
im.messages.react()
Adds a reaction.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
seq | number | Yes | The message's sequence number |
key | string | Yes | The reaction's key |
Returns: Promise<{ changed: boolean }>.
Errors: permission_denied (reaction_disabled, message_recalled, read_only_membership), limit_exceeded (reaction_limit), invalid_argument (invalid_reaction_key), user_blocked, not_friend, local_validation (reason is required: no key given).
im.messages.unreact()
Removes the user's reaction. Parameters and return value are the same as react().
im.messages.reactionUsers()
Lists who reacted with a given reaction, page by page.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
seq | number | Yes | The message's sequence number |
key | string | Yes | The reaction's key |
page.cursor | string | null | No | next_cursor from the previous page |
page.limit | number | No | Items per page; default 20, max 100 |
Returns: Promise<{ items: unknown[]; next_cursor: string | null }>. Each item is { username, nickname, avatar_url, reacted_at }; username is null for deleted users.
Errors: permission_denied (read_only_membership), rate_limited, not_found.
im.messages.setPinned()
Pins or unpins a message.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
seq | number | Yes | The message's sequence number |
pinned | boolean | Yes | true to pin, false to unpin |
Returns: Promise<{ changed: boolean }>.
Errors: permission_denied (pin_denied, message_recalled, read_only_membership), limit_exceeded (pinned_message_limit), not_found.
im.messages.lookup()
Fetches messages by sequence number from the server and saves them locally.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Conversation ID |
seqs | number[] | Yes | Sequence numbers. More than 100 are requested in batches by the SDK |
Returns: Promise<{ items: MessageView[]; missing: number[] }>. missing lists sequence numbers that don't exist, have expired or aren't visible to the user.
Errors: not_found (the conversation doesn't exist or isn't visible to the user), network_error.
im.messages.searchLocal()
Searches text messages stored locally.
| Parameter | Type | Required | Description |
|---|---|---|---|
keyword | string | Yes | Keyword, matched as a case-insensitive substring |
conversation_id | string | No | Search only this conversation |
limit | number | No | Maximum results; default 50 |
Returns: Promise<MessageView[]>, newest first.
Errors: local_validation (reason is required), storage_error.
im.messages.receipts()
Gets read counts for group messages.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Group conversation ID |
seqs | number[] | Yes | Sequence numbers of messages the user sent with a receipt requested |
Returns: Promise<ReceiptCount[]>, one per entry in seqs; see ReceiptCount.
Errors: permission_denied (receipt_denied: the user has left the group), rate_limited.
im.messages.receiptUsers()
Lists who has or hasn't read a group message, page by page.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Group conversation ID |
seq | number | Yes | The message's sequence number |
p.status | 'read' | 'unread' | Yes | People who have read it, or who haven't |
p.cursor | string | null | No | next_cursor from the previous page |
p.limit | number | No | Items per page; default 20, max 100 |
Returns: Promise<{ items: unknown[]; next_cursor: string | null }>. Each item is { username, nickname, avatar_url }.
Errors: permission_denied (receipt_denied), rate_limited, not_found.
im.messages.getReceipt()
Reads the most recently fetched read counts from memory, synchronously. receipts.changed is emitted when they change.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversation_id | string | Yes | Group conversation ID |
seq | number | Yes | The message's sequence number |
Returns: ReceiptCount | undefined.
MessageList
The message list returned by im.messages.open().
| Member | Description |
|---|---|
conversation_key | Read-only. The list's conversation key; changes to the new conversation ID when a draft conversation becomes a real one |
getSnapshot() | The current snapshot; see MessageListSnapshot |
subscribe(listener) | Calls listener (with no arguments) when the snapshot changes. Returns an unsubscribe function |
loadOlder() | Loads the 50 older messages. Returns Promise<void>; resolves immediately if there are none or a load is in progress |
loadNewer() | Loads downward from the position it was opened at. Returns Promise<void>; resolves immediately if already at the newest |
loadGap(gap) | Loads a skipped gap. Returns Promise<void>; does nothing for rejoin gaps |
jumpTo(seq) | Moves the list to around this sequence number. Returns Promise<void> |
markRead() | Moves the read position to the newest message; requests are batched |
setViewing({ atBottom?, visible? }) | Reports whether the user is at the newest message and whether the list is shown; omitted fields stay unchanged |
dispose() | Disposes the list. Afterward loadOlder() and similar methods reject with invalid_state (reason is disposed) |
When loading fails, the methods don't reject; the error goes into the snapshot's error.
The render entry point
Import from @deeprespond/im-web/render. The client argument is a client created by createDRClient(); passing anything else throws TypeError.
| Function | Parameters | Returns |
|---|---|---|
displayName(client, username, opts?) | username: string | null; opts.group_id: use the group nickname in this group; opts.locale | string |
conversationTitle(client, view, opts?) | view: ConversationView; opts.locale | string |
summarize(message, opts) | message: needs at least type, body, sender, sender_type, recalled; opts.nameOf: required; opts.self; opts.locale | string. Text messages use the original text (runs of whitespace collapsed to one space); recalled messages give the recall notice; call records give the call text when self is given |
renderTip(message, opts) | message: a group notice; opts.nameOf: required; opts.self; opts.locale | string |
renderCallRecord(message, opts) | message: a call record; opts.self: required; opts.locale | string |
renderRecall(message, opts) | message: a recalled message; opts.self and opts.nameOf: required; opts.locale | string; empty if the message wasn't recalled |
nameOf has type (username: string) => string, and locale has type Partial<Locale>.
The locale/zh-CN entry point
Import from @deeprespond/im-web/locale/zh-CN.
| Export | Description |
|---|---|
zhCN | The Chinese string table: errors (error messages), summary (summaries), recall (recall notices), tip (group notices), call (call records), plus entries such as you, deletedUser and unnamedGroup |
Locale | Type, typeof zhCN |
describeError(err, locale?) | Message text for an error code and reason; see Events and errors |
formatTemplate(template, values) | Replaces placeholders such as {name} in a string with values from values |
Types
Message
The server's message object. For field meanings, see Message format.
| Field | Type | Description |
|---|---|---|
conversation_id | string | Conversation ID |
conversation_type | 'single' | 'group' | One-to-one or group |
seq | number | Sequence number within the conversation |
message_id | string | Message ID, unique across the app |
client_msg_id | string | null | De-duplication ID used when sending |
sender | string | null | The sender's username; null for system messages or deleted senders |
sender_type | 'user' | 'system' | User or system |
recipient | string | null | The recipient in a one-to-one chat |
type | string | Message type: text, image, voice, video, file, location, custom, tip, call; more may be added |
body | Record<string, unknown> | null | Message body; null when recalled or erased |
ext | Record<string, unknown> | null | Extension fields |
mentions | { usernames: string[] | null; all: boolean } | null | Who is mentioned |
reply_to | { seq: number; sender: string | null } | null | The quoted message |
need_receipt | boolean | Whether a group read receipt was requested |
exclude_from_unread | boolean | Whether it's excluded from unread counts |
reactions | Array<{ key, count, users, reacted? }> | null | Reactions: one item per reaction; users is the first 3 people who reacted (null for deleted users) |
pinned | { by: string | null; at: string } | null | Pin information |
edited | { at: string; count: number } | null | Edit information |
recalled | { by: string | null; role: string; at: string } | null | Recall information; role is sender, owner, admin, server or platform |
erased | boolean | Whether it was erased |
via | 'client' | 'openapi' | 'console' | 'system' | How it was written |
created_at | string | Send time |
message_version | number | Change version of the message |
MessageView
A message in a message list or returned by send(): a Message plus local state.
| Field | Type | Description |
|---|---|---|
| All Message fields | See above | |
seq, message_id, created_at | number | null, string | null, string | null | null for messages in the send queue |
conversation_id | string | null for unsent messages to a draft conversation (a one-to-one chat with no server conversation yet) |
_local.status | 'sent' | 'queued' | 'uploading' | 'sending' | 'failed' | Sent, waiting to send, uploading, sending, or failed |
_local.progress | number | Optional. Upload progress from 0 to 1; only while uploading |
_local.error | DRError | Optional. Why it failed |
_local.status_unknown | boolean | Optional. The failed message may actually have been sent |
_local.reacted | Record<string, boolean> | Optional. Whether the user has reacted with each reaction |
_local.unsupported | boolean | An unknown message type |
_local.trusted | boolean | via isn't client, i.e. not sent by a user from a client |
MessageListSnapshot
| Field | Type | Description |
|---|---|---|
items | readonly MessageView[] | In ascending sequence order, with messages in the send queue last |
gaps | readonly Gap[] | Ranges not loaded; see Gap |
hasOlder | boolean | There are older messages above |
hasNewer | boolean | Opened at a specific message and not yet scrolled to the newest |
loading | 'none' | 'initial' | 'older' | 'newer' | Loading state |
pinned | readonly PinnedItem[] | Pinned messages; see PinnedItem |
error | DRError | Optional. The error from the most recent load |
Gap
| Field | Type | Description |
|---|---|---|
from_seq | number | Starting sequence number (inclusive) |
to_seq | number | Ending sequence number (inclusive) |
kind | 'skipped' | 'rejoin' | skipped: skipped while catching up after being offline, and can be loaded. rejoin: from before the user rejoined the group, which they can't see and can't be loaded |
PinnedItem
| Field | Type | Description |
|---|---|---|
seq | number | The message's sequence number |
pinned | { by: string | null; at: string } | Who pinned it (null when pinned by the server) and when |
message | MessageView | null | The message; null until its content has been fetched |
SendTarget
One of:
| Form | Description |
|---|---|
{ conversation_key: string } | A conversation key, including a draft conversation's draft:{username} |
{ to_user: string } | The other user's username |
{ to_group: string } | A group ID |
OutgoingContent
type | Other fields | Description |
|---|---|---|
'text' | body: { text: string } | Text |
'image' | file: Blob; original?: boolean; body? | An image the SDK processes and uploads; original sends the original image |
'voice' | file: Blob; body: { duration_seconds: number } | Voice uploaded by the SDK; the duration is required |
'video' | file: Blob; body? | Video uploaded by the SDK |
'file' | file: Blob; body?: { name?: string } | A file uploaded by the SDK |
'image', 'voice', 'video', 'file' | body: Record<string, unknown> | An attachment that already has a URL; body.url is required |
'location' | body: { latitude, longitude, name?, address? } | Location |
'custom' | body: Record<string, unknown> | Custom message |
SendOptions
| Field | Type | Description |
|---|---|---|
ext | Record<string, unknown> | Extension fields |
mentions | { usernames?: string[]; all?: boolean } | Who to mention; group chats only |
reply_to | { seq: number } | The message to quote |
need_receipt | boolean | Request a group read receipt; group chats only |
exclude_from_unread | boolean | Don't count toward the recipient's unread count |
push | { disabled: boolean } | Whether to turn off offline push for this message |
waitUntil | 'queued' | 'sent' | When send() returns; default 'queued' |
ReceiptCount
One of:
| Form | Description |
|---|---|
{ seq: number; read_count: number; unread_count: number } | Read and unread counts |
{ seq: number; code: string; details?: Record<string, unknown> } | This message can't be viewed; code is the error code, such as permission_denied (receipt_denied) or not_found |
OnlineMessage
The message in the message.online event.
| Field | Type | Description |
|---|---|---|
message_id | string | Message ID, for troubleshooting only |
client_msg_id | string | ID generated by the sender |
conversation_type | 'single' | 'group' | One-to-one or group |
conversation_id | string | null | Conversation ID; null if the two users have no conversation yet |
sender | string | null | Sender |
sender_type | string | user or system |
recipient | string | null | The recipient in a one-to-one chat |
group_id | string | null | The group ID in a group chat |
type | string | Always custom |
body | Record<string, unknown> | Message body |
ext | Record<string, unknown> | null | Extension fields |
via | string | How it was written |
created_at | string | Send time |
OnlineResult
| Field | Type | Description |
|---|---|---|
message_id | string | Message ID, for troubleshooting only |
client_msg_id | string | ID generated by the SDK |
created_at | string | Send time |
online_only | true | Always true |
