消息
本页介绍用 im.messages 显示一个会话的消息、发送各种类型的消息、接收新消息,以及撤回、删除、表情回应、置顶和已读回执;最后是 @deeprespond/im-web/render 中的显示辅助函数。会话列表见会话,上传文件的细节见文件。
典型用法:用户点击会话时用 im.messages.open(conversation_key) 打开消息列表并订阅它;用户发送时调用 im.messages.send(),消息立即出现在列表末尾,发出后换成服务端的正式消息;对方的新消息、撤回、表情回应都会自动更新到列表中,不需要自己合并。
消息的格式(类型、body 的字段、seq 和 message_id)与服务端相同,见消息格式。
显示消息列表
im.messages.open() 返回一个会持续更新的消息列表(MessageList):
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();- 快照:
items按序号升序排列,发送队列中还没发出的消息排在最后;gaps是中间暂时没有加载的范围(见空洞);hasOlder表示上面还有更早的消息;pinned是这个会话的置顶消息;loading为'initial'(正在打开)、'older'、'newer'或'none';加载出错时带error。 - 识别消息:已发出的消息用
seq(会话内的序号)识别;还在发送队列中的消息seq、message_id、created_at为null,用client_msg_id识别。 - 引用不变:没有变化时
getSnapshot()返回同一个对象;有变化时返回新的快照,其中没有变化的消息沿用原来的对象。快照和其中的消息都已冻结。 - 释放:不再显示时一定要调用
dispose(),否则 SDK 会继续为它补齐消息,这个会话也会一直被当作“正在被看”。同一个会话可以同时打开多个列表(如主窗口和弹出的小窗口),它们共用本地数据。
本地优先
打开时 SDK 先显示本地保存的最新 50 条消息,再在后台向服务端补齐离线期间的新消息,补齐后快照更新。所以:
- 打开过的会话在页面刷新后、离线时也能立即显示;
- 新设备上第一次打开时本地没有消息,
loading为'initial',取回最新一页后显示; - 列表打开期间,同步发现这个会话有新消息时立即补齐,不需要用户操作;
- 超过应用消息保留期的消息不再显示,即使本地还保存着。
报告用户是否在看
一个会话“正在被看”是指:它的消息列表打开着、用户停在最新处、这个列表在界面上显示,并且页面可见。正在被看的会话收到新消息时不计入本地的未读数;创建客户端时开启了 autoMarkRead 的,SDK 还会自动标记已读。
列表打开后默认按“停在最新处、正在显示”处理。用户向上翻看历史、或者列表被隐藏(如 Vue 的 <KeepAlive> 缓存了页面、标签式界面切到了别的面板)时,请告诉 SDK:
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() 把这个会话的已读位置推进到最新一条,与 im.conversations.markRead() 相同,SDK 合并发送。
加载更早的消息
用户滚动到顶部、hasOlder 为 true 时调用 loadOlder(),每次加载 50 条:
async function onReachTop(): Promise<void> {
const { hasOlder, loading } = list.getSnapshot();
if (hasOlder && loading === 'none') await list.loadOlder();
}本地有更早的消息时直接读取,没有的再向服务端获取。加载失败时快照带 error,可以提示用户后再次调用。
跳转到某条消息
从搜索结果、置顶消息、引用或“有人 @ 我”进入时,打开列表并定位到某条消息:
// 打开会话并定位到第 1024 条(取它前后各约 25 条)
const around = im.messages.open(conversationKey, { around_seq: 1024 });
// 已打开的列表中跳转
await list.jumpTo(1024);
// 定位后向下翻到最新
if (list.getSnapshot().hasNewer) await list.loadNewer();定位后列表不再停在最新处,hasNewer 为 true,发送队列中的消息暂不显示;用户向下滚动时调用 loadNewer(),翻到最新后恢复正常。要直接回到最新处,可以释放这个列表,再不带 around_seq 重新打开。
“有人 @ 我”的消息序号在会话状态的 mention_seq 中,见会话。
空洞
gaps 中的每一项是消息列表中一段没有加载的序号范围(闭区间 from_seq 到 to_seq),在序号的相应位置显示一个提示:
skipped:长时间离线后,一个会话积累了很多新消息,SDK 只补齐最新的一部分,中间的跳过了。显示“查看更早的消息”,用户点击时调用loadGap(gap)加载。不能把它当作“没有消息”。rejoin:退群后又重新入群,两次入群之间的消息本人看不到,显示“以下为你重新加入群聊之前的消息”这类分隔,不能加载。
import type { Gap } from '@deeprespond/im-web';
function onGapClick(gap: Gap): void {
if (gap.kind === 'skipped') void list.loadGap(gap);
}hasOlder 遇到 skipped 空洞时为 false,由用户点击空洞继续向上加载。
显示一条消息
列表中的每一条是 MessageView:服务端的消息对象,加上 SDK 的本地状态 _local。按 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}]`;
}
}- 发送者:
sender为发送者的用户名,用displayName()显示(好友备注、群昵称、昵称)。sender_type为'system'的是系统消息(群提示、以系统身份发送的群消息),sender为null;sender_type为'user'而sender为null的,发送者已被删除,显示“已删除的用户”。 - 不认识的类型:
_local.unsupported为true,显示“当前版本不支持此消息”,不要丢弃。 - 可信的消息:
_local.trusted为true表示消息不是用户在客户端发出的(由你的服务端、控制台或系统写入)。custom的内容由发送方决定,涉及资金、订单状态的卡片只应信任trusted的消息。 - 已撤回、已擦除:
recalled不为null或erased为true时,body、ext、mentions、reply_to、reactions都为null,显示相应的提示。 - 已编辑:
edited不为null时可以显示“已编辑”,见编辑。 - 引用:
reply_to为{ seq, sender },被引用的消息按序号在列表中查找,不在列表中的用im.messages.lookup()取回;原消息已撤回的显示“原消息已撤回”。 - 附件:图片、语音、视频、文件消息的
body.url是文件地址,不能直接作为<img>的src,要先换取下载地址,见文件。 - 时间:
created_at为服务端的发送时间(ISO 8601 字符串),用Intl.DateTimeFormat或你的日期库格式化。SDK 不提供时间的格式化。发送队列中的消息created_at为null,可以显示为“刚刚”。
发送消息
im.messages.send(to, content, opts?) 发送一条消息。to 指定发给谁:
to | 说明 |
|---|---|
{ conversation_key } | 发到一个已打开的会话,单聊、群聊和草稿会话都可以 |
{ to_user: 'lisi' } | 发给一个用户,还没有会话也可以 |
{ to_group: groupId } | 发到一个群 |
发送文本
const local = await im.messages.send({ conversation_key: conversationKey }, { type: 'text', body: { text: '你好' } });
console.log(local._local.status); // 'queued'send() 在消息写入发送队列后立即返回本地消息,它同时出现在消息列表的末尾,状态为 queued(等待发送)或 sending(发送中)。发出后,列表中的这条换成服务端返回的正式消息(带 seq、message_id、created_at),状态为 sent。
需要等发出后再继续的(如发送后跳转页面),设置 waitUntil: 'sent',send() 在发出后以正式消息兑现,最终失败时以错误拒绝:
const sent = await im.messages.send({ to_group: groupId }, { type: 'text', body: { text: '会议纪要已上传' } }, { waitUntil: 'sent' });
console.log(sent.seq, sent.message_id);调用时 SDK 先检查不依赖网络的部分(类型、必填字段、字符串中的控制字符、大小、@ 的规则),不通过的直接以错误拒绝,消息不会进入发送队列。文本消息的 text 不能为空,可以包含换行。
发送队列
- 离线发送:没有网络、长连接断开时也可以发送,消息保存在发送队列中,状态为
queued,联网后自动发出。发送队列保存在本地存储中,页面刷新、浏览器重启后继续发送。 - 顺序:同一会话中的消息按调用的顺序逐条发出,前一条发出或最终失败之前,后一条等待;不同会话的消息并行发送。
- 限速:SDK 按每个会话每秒 4 条、全部会话合计每秒 8 条发送,连续发送很多条时不会触发服务端的限流。
- 多个标签页:在任何标签页调用
send()都可以,消息由同一浏览器中的一个标签页统一发送,各标签页的列表同时更新。 - 长时间没有发出:创建 48 小时后还没有发出的消息不再自动发送,标为失败(
send_abandoned,reason为expired),由用户决定是否重发。 - 关闭页面前:
im.hasPendingWork()为true时还有没发出的消息或没完成的上传,可以提示用户:
window.addEventListener('beforeunload', (e) => {
if (im.hasPendingWork()) e.preventDefault();
});不提示也不会丢失:下次打开页面时继续发送。
发送失败与重发
网络错误、超时和服务器暂时故障,SDK 用同一条消息自动重试(间隔 1、2、4、8、16、30 秒),重复提交不会产生两条消息;网络可用而连续失败满 2 分钟的标为失败。被服务端拒绝的(如被禁言、对方拒收、内容未通过审核)立即标为失败,不自动重试。
失败的消息留在列表中,_local.status 为 'failed',_local.error 为失败的原因,同时发出 message.sendFailed 事件。用户可以重发或删除它:
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()仍用原来的client_msg_id,服务端据此去重。带附件、上传失败的从头重新上传。- 发送状态未知:
_local.status_unknown为true的失败消息可能已经发出(重试期间应用调整了规则),可以提示“发送状态未知”。SDK 在下一次同步后核对,确实已经发出的自动换成正式消息。 - 常见的失败原因:
user_muted(被禁言)、group_muted(群禁言:reason为member表示本人被禁言,all表示群主开启了全员禁言,details.muted_until为到期时间)、not_group_member(已不在群中)、user_blocked(对方拒收)、not_friend(应用要求好友才能聊天)、message_rejected(内容未通过审核,reason为app_rejected时message是你的服务端给出的提示)、not_found(对方不存在或已删除)、payload_too_large(超过消息大小上限)。全部错误码见事件与错误和服务端的错误码。
发送图片
把用户选择的文件直接交给 send(),SDK 处理、上传后再发送:
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); // 调用时的检查不通过,如服务端没有开通文件服务
}
});- 上传中:消息立即出现在列表中,
_local.status为'uploading',_local.progress为 0 到 1 的上传进度,可以用URL.createObjectURL(file)先显示本地预览。上传完成后自动发送。 - 处理:图片的长边缩小到 2048 像素以内,重新编码为 JPEG(PNG、WebP 输出 PNG,保留透明),同时去掉拍摄地点等元数据;GIF 不处理。设置
original: true发送原图,只去掉元数据。 - 自动改为文件:超过 20 MB 或 5000 万像素的图片、浏览器无法解码的格式(如 HEIC),自动改为文件消息发送。
body中的url、thumbnail_url、width、height、size、format由 SDK 填写,你给出的同名字段被忽略;可以在body中附加你自己的字段。- 自动改为文件消息:语音、视频、图片不能按请求的类型发送时(格式不被服务端接受、图片过大或无法解码),SDK 自动改为文件消息发送,消息的
type随之变为file。 - 续传:上传未完成时页面被刷新的,SDK 在页面重新打开后继续上传(50 MB 以内的文件);更大的标为失败(
send_abandoned,reason为attachment_lost),请用户重新选择文件。
单独上传文件(不发送消息)、下载和显示图片,见文件。
发送语音
录音用 im.files.recordVoice(),结束后得到录音和时长:
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(秒),SDK 向上取整,至少为 1。语音文件不能超过 5 MB。浏览器不支持录音时以 unsupported 拒绝;用户取消时调用 recorder.cancel()。
发送视频
await im.messages.send({ conversation_key: conversationKey }, { type: 'video', file });SDK 读取视频的时长和宽高,截取第一帧作为封面另外上传,都写入 body。服务端不接受的视频格式自动改为文件消息发送。
发送文件
await im.messages.send({ conversation_key: conversationKey }, { type: 'file', file, body: { name: '季度报告.pdf' } });body.name 是显示的文件名,省略时取 File 的文件名。文件大小不能超过应用的上传上限(运行配置的 max_upload_bytes),超过时以 local_validation(reason 为 too_large,details.max_bytes 为上限)拒绝。
发送已有地址的附件
文件已经上传过(如用 im.files.upload() 上传、或者你自己的文件服务器上的地址)时,直接给出 body,不再上传:
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 必填,宽高、大小、时长必须是非负整数。应用开启了“只允许本服务的文件地址”(media_url_only)时,外部地址会被拒绝,见消息格式。
发送位置
await im.messages.send({ conversation_key: conversationKey }, {
type: 'location',
body: { latitude: 39.9087, longitude: 116.3975, name: '天安门', address: '北京市东城区' },
});纬度在 -90 到 90 之间,经度在 -180 到 180 之间。
发送自定义消息
custom 消息的 body 是任意 JSON 对象,结构由你定义,如订单卡片、名片、业务通知:
await im.messages.send({ conversation_key: conversationKey }, {
type: 'custom',
body: { card: 'order', order_id: '8812', title: '你的订单已发货' },
});字符串中不能有控制字符(包括换行)。用户在客户端也能发出看起来像“转账”“订单”的卡片,接收方只应信任 _local.trusted 为 true 的这类消息,它们由你的服务端发出。
@ 提及
群消息可以 @ 成员或全体成员。正文中的“@张三”由你自己拼写,SDK 只提交 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 },
});- 只用于群聊,单聊中以
local_validation(reason为mentions_not_allowed)拒绝; - 一条消息最多 @ 20 人,不是群成员的会被忽略;
- 普通成员 @ 全体成员以
permission_denied(reason为mention_all_denied)拒绝。
被 @ 的人在会话列表中看到“有人 @ 我”,见会话。
引用回复
await im.messages.send({ conversation_key: conversationKey }, { type: 'text', body: { text: '同意' } }, {
reply_to: { seq: quoted.seq! },
});只给出被引用消息的序号。被引用的消息必须在同一会话中、没有被撤回,否则服务端返回 invalid_argument(reason 为 invalid_reply)。
其他选项
| 选项 | 说明 |
|---|---|
ext | 你自定义的扩展字段(JSON 对象),原样保存和返回,如业务 ID、转发来源 |
need_receipt | 要求群已读回执,只用于群聊,见群聊的已读回执 |
exclude_from_unread | 不计入接收者的未读数,适合不需要提醒的业务消息 |
push | { disabled: true } 不给接收者发送离线推送 |
waitUntil | 'queued'(默认)写入发送队列后返回;'sent' 发出后返回 |
一条消息的类型、body、ext、mentions 和 reply_to 合计不能超过应用的大小上限(运行配置的 max_message_body_bytes,默认 5 KB)。
给还没有会话的人发消息
用 to_user 直接发给一个用户,或者先用 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: '你好,我是张三' } });两人之间还没有会话时,消息先归在草稿会话 draft:lisi 中,这时消息的 conversation_id 为 null。第一条消息发出后服务端创建会话,SDK 把草稿会话换成正式会话:已打开的列表自动切换,它的 conversation_key 变为新的会话 ID,messages.changed 事件带上 renamed_from(原来的草稿键)。对方不存在、已删除或就是本人时,消息发送失败(not_found 或 invalid_argument)。
转发
forward() 把一条消息的类型和内容作为新消息发给别人,附件直接沿用原来的地址:
await im.messages.forward(message, { to_user: 'wangwu' }, { ext: { forwarded_from: message.message_id } });群提示、通话记录、已撤回和已擦除的消息不能转发,以 local_validation 拒绝。附件按上传时间过期,过期后转发出去的消息同样下载不了。合并转发请用 custom 消息自行定义。
只推在线的消息
“对方正在录音”、白板笔迹这类实时信令不需要保存,用 sendOnline() 发送:只推给接收者此刻在线的设备,不保存、不计入未读,离线的设备收不到,之后也拉取不到。
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);
});- 只能是
custom类型,不进发送队列,失败不重试:没有网络时直接以错误拒绝。 - 接收方的
message.online事件已按“发送者 +client_msg_id”去重,消息不出现在消息列表中。 - “正在输入”请用
im.conversations.typing(),见会话。
接收新消息
打开着的消息列表、会话列表和未读数都会自动更新,通常不需要处理新消息的事件。需要提醒用户(弹出浏览器通知、播放提示音)时监听 message.received:
im.on('message.received', ({ message, alert }) => {
if (alert) console.log('新消息', message.conversation_id, message.seq);
});- 只对通过长连接实时收到的、别人发来的消息发出;本人在其他设备发出的消息、离线期间的消息(由同步补齐)不发出。
- 同一浏览器的每个标签页都会收到这个事件,
alert为true的标签页负责提醒;载荷中的notify是 SDK 按推送设置和免打扰给出的提醒判断。用法见提醒、推送设置与举报。
要监听某个会话的消息变化(新消息、撤回、表情回应、发送状态),可以用 messages.changed 事件:
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);
});本人发出的消息换成正式消息时,同一个事件中 pending 带有它的 client_msg_id、upserted 带有它的新序号,可以据此保持界面上的滚动位置。
撤回
if (im.messages.canRecall(message)) {
await im.messages.recall(message.conversation_id, message.seq!);
}canRecall() 按服务端的规则判断是否显示“撤回”菜单:
- 群提示、通话记录、系统消息不能撤回;还在发送队列中的消息不能撤回(用
discard()删除); - 单聊只能撤回本人的消息,在应用设置的撤回时限(运行配置的
recall_window_seconds,默认 120 秒)内; - 群聊中本人的消息在时限内可以撤回;群主可以随时撤回任何人的消息,管理员可以撤回普通成员的消息;
- 已离开的群中不能撤回,服务端以
permission_denied(read_only_membership)拒绝。canRecall()不检查这一点,请同时判断会话的state.membership是否为member。
撤回后双方的消息都变为已撤回:recalled 为 { by, role, at },内容被清空,用 renderRecall() 显示“你撤回了一条消息”“张三撤回了一条消息”“张三撤回了李四的一条消息”(群主或管理员撤回别人的)。由你的服务端或内容安全撤回的,role 为 server 或 platform,显示“一条消息已被撤回”。超过时限时服务端以 permission_denied(reason 为 recall_window_expired)拒绝,没有权限为 recall_denied。
编辑
Web SDK 不提供编辑消息。你的服务端可以编辑 text 和 custom 消息(见撤回、编辑与置顶),编辑后客户端的消息列表自动更新为新的内容,edited 为 { at, count },可以显示“已编辑”。
删除消息
deleteForMe() 删除本人的这些消息,对方和其他群成员不受影响,本人的其他设备同步删除:
await im.messages.deleteForMe(conversationId, [101, 102, 105]);一次最多 100 条。不提供双向删除,需要让对方也看不到的请撤回。发送队列中、还没发出的消息用 discard() 删除。
表情回应
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);- 需要应用开启表情回应(运行配置的
message_reaction_enabled),否则以permission_denied(reason为reaction_disabled)拒绝。 - 表情的
key由你的客户端定义(如thumbs_up、heart),各端保持一致;每条消息最多max_reactions_per_message种,满了以limit_exceeded(reaction_limit)拒绝。 - 群提示、已撤回的消息不能回应;已离开的群中不能回应,也不能查看回应名单(
read_only_membership)。 - 返回值的
changed为false表示本来就是这个状态。别人的回应通过长连接实时更新到消息列表中。
置顶消息
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 ?? '加载中');
}- 单聊双方都可以置顶;群聊只有群主和管理员可以,否则以
permission_denied(reason为pin_denied)拒绝。 - 每个会话最多置顶
max_pinned_messages_per_conversation条,满了以limit_exceeded(pinned_message_limit)拒绝。 - 置顶列表在打开消息列表时从服务端刷新。正文还没取回的置顶消息
message为null,取回后快照更新。点击置顶消息时用jumpTo(item.seq)定位。
已读回执
单聊
单聊的已读状态不需要单独查询:会话状态中的 peer_read_seq 是对方已读到的序号,本人发出的、seq 不大于它的消息都显示“已读”,对方标记已读时实时更新。
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 ? '已读' : '未读');
}
}应用没有开启单聊已读回执(运行配置的 single_read_ack_enabled)时不显示已读状态。请按配置判断,不要按 peer_read_seq 是否为 null 判断。
群聊
群消息的已读回执只对发送时设置了 need_receipt: true 的消息有效,只有发送者本人、在仍是群成员时可以查看。需要应用开启群已读回执(group_read_ack_enabled),否则发送时以 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():SDK 把同一会话 50 毫秒内的调用合并,去掉 10 秒内查过的,按每批 20 条请求,适合在消息滚动进入视野时逐条调用。某一条不能查看的(不是本人发的、没有要求回执)在结果中为{ seq, code },不影响其他条。本人已离开群时以permission_denied(receipt_denied)拒绝。getReceipt()同步读取内存中最近的已读人数,没有查过时为undefined。- 500 人以内的群,成员读到消息时服务端推送新的人数,SDK 更新后发出
receipts.changed;更大的群只能主动查询。 - 查询回执、回执名单和表情回应名单合计每个用户每分钟 60 次,滚动时不要对每条消息单独发起请求。
查找消息
按序号取回:引用、置顶、通知中提到的消息不在列表中时,用 lookup() 从服务端取回(同时保存到本地):
const { items, missing } = await im.messages.lookup(conversationId, [3, 57]);
// missing:不存在、已过期或本人看不到的序号搜索本地消息:服务端不提供消息搜索。searchLocal() 在本浏览器已保存的文本消息中按关键词查找(不区分大小写),按时间倒序:
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! });
}只能找到本地保存过的消息(打开过、同步过的会话)。可以用 conversation_id 只在一个会话中查找。
显示辅助
@deeprespond/im-web/render 提供拼出显示文字的函数,@deeprespond/im-web/locale/zh-CN 提供中文文案表。它们都是可选的,你也可以完全自己实现。
import { conversationTitle, displayName, renderCallRecord, renderRecall, renderTip, summarize } from '@deeprespond/im-web/render';| 函数 | 用途 | 示例输出 |
|---|---|---|
displayName(im, username, opts?) | 用户的显示名:好友备注、群昵称(给出 group_id 时)、昵称、用户名中第一个非空的 | 李四 |
conversationTitle(im, view) | 会话的标题:单聊为对方的显示名,群聊为群名 | 产品讨论组 |
summarize(message, opts) | 会话列表中最后一条消息的摘要 | [图片]、[文件] 报告.pdf |
renderTip(message, opts) | 群提示的文字 | 张三邀请李四、王五加入了群聊 |
renderCallRecord(message, opts) | 通话记录的文字 | 视频通话 通话时长 03:12、未接来电 |
renderRecall(message, opts) | 撤回提示 | 你撤回了一条消息 |
显示名
displayName(im, username, { group_id }) 返回一个用户在界面上显示的名字,按顺序取第一个非空的:你给他设置的好友备注、他在这个群中的群昵称(给出 group_id、且本地已有这个群的成员资料时)、他的昵称、用户名。不在群里显示时省略 group_id。
import { displayName } from '@deeprespond/im-web/render';
const inChat = displayName(im, 'lisi');
const inGroup = displayName(im, 'lisi', { group_id: '99582580415791104' });displayName()同步返回。本地没有这个用户的资料时先返回用户名,同时在后台获取,获取后发出users.changed事件,重新渲染即可显示昵称。好友备注、群昵称、群名变化时同样发出对应的事件(friends.changed、groupMembers.changed、groups.changed)。nameOf参数用来把用户名转为显示名,通常写成(u) => displayName(im, u, { group_id })。self是本人的用户名:群提示和撤回提示中的本人显示为“你”,通话记录按本人是不是发起方显示“已取消”或“对方已取消”。- 已删除的用户(用户名为
null)显示为“已删除的用户”。不认识的群提示显示“[群提示]”,不认识的消息类型的摘要为“[消息]”。
替换文案
文案来自 zhCN 表。每个函数都接受 locale 参数,替换其中的部分条目,或整体换成其他语言:
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]'替换某一组(如 summary、tip、recall、call)时,请先展开 zhCN 中的这一组再改写,没有给出的条目沿用中文。文案中的 {name} 等占位符按原样保留。
describeError(err) 按错误码给出中文提示,见事件与错误。
接口参考
im.messages.open()
打开一个会话的消息列表。每次调用返回一个新的列表,不再使用时调用它的 dispose()。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_key | string | 是 | 会话键:会话 ID、群 ID,或草稿会话的 draft:{username} |
opts.around_seq | number | 否 | 定位到这个序号附近 |
返回值:MessageList,见 MessageList。
可能的错误:同步抛出 not_signed_in、local_validation(reason 为 required)。
im.messages.send()
发送一条消息:检查后写入发送队列,带文件的先上传。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
to | SendTarget | 是 | 发给谁,见 SendTarget |
content | OutgoingContent | 是 | 消息的类型和内容,见 OutgoingContent |
opts | SendOptions | 否 | 见 SendOptions |
返回值:Promise<MessageView>。默认为写入发送队列后的本地消息(seq 为 null);waitUntil: 'sent' 时为发出后的正式消息。
可能的错误:
- 调用时:
local_validation(reason为required、control_characters、invalid_value、invalid_type、too_large、mentions_not_allowed、receipt_not_allowed、too_many_items、unknown_type、not_found,details.field指出字段)、permission_denied(mention_all_denied、read_ack_disabled)、unsupported(media_disabled,服务端没有开通文件服务)、invalid_state(peer_deleted,单聊的对方已删除)、not_signed_in; waitUntil: 'sent'时还有发送失败的错误,与message.sendFailed的error相同。
im.messages.resend()
重发一条失败的消息。不是失败状态的什么也不做。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
client_msg_id | string | 是 | 失败消息的 client_msg_id |
返回值:Promise<void>,重新进入发送队列后兑现。
可能的错误:local_validation(reason 为 not_found,发送队列中没有这条)。
im.messages.discard()
删除发送队列中的一条消息(失败的或还在等待的),取消没完成的上传。只影响本地。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
client_msg_id | string | 是 | 消息的 client_msg_id |
返回值:Promise<void>。
im.messages.sendOnline()
发送只推在线的 custom 消息。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
to | SendTarget | 是 | 发给谁 |
content.body | Record<string, unknown> | 是 | 消息体 |
content.ext | Record<string, unknown> | 否 | 扩展字段 |
返回值:Promise<OnlineResult>,见 OnlineResult。
可能的错误:local_validation、network_error,以及服务端拒绝发送的错误(与普通消息相同)。
im.messages.forward()
把一条消息的类型和内容作为新消息发送。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
message | MessageView | 是 | 要转发的消息 |
to | SendTarget | 是 | 发给谁 |
opts | SendOptions | 否 | 同 send() |
返回值:Promise<MessageView>,同 send()。
可能的错误:local_validation(reason 为 unknown_type,群提示和通话记录;invalid_body,已撤回、已擦除;url_not_allowed,应用开启 media_url_only 而附件地址不允许),以及 send() 的错误。
im.messages.recall()
撤回一条消息。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 会话 ID |
seq | number | 是 | 消息的序号 |
返回值:Promise<{ changed: boolean }>,changed 为 false 表示已经撤回过。
可能的错误:permission_denied(recall_window_expired、recall_denied、read_only_membership)、not_found。
im.messages.canRecall()
同步判断本人能否撤回这条消息,用于决定是否显示撤回入口。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
message | MessageView | 是 | 消息 |
返回值:boolean。
im.messages.deleteForMe()
删除本人的消息(仅自己)。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 会话 ID |
seqs | number[] | 是 | 消息的序号,最多 100 个;空数组时什么也不做 |
返回值:Promise<void>。
可能的错误:local_validation(reason 为 too_many_items)、not_found。
im.messages.react()
添加表情回应。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 会话 ID |
seq | number | 是 | 消息的序号 |
key | string | 是 | 表情的标识 |
返回值:Promise<{ changed: boolean }>。
可能的错误: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 为 required,没有给出 key)。
im.messages.unreact()
取消本人的表情回应。参数和返回值同 react()。
im.messages.reactionUsers()
查询回应某种表情的人,翻页。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 会话 ID |
seq | number | 是 | 消息的序号 |
key | string | 是 | 表情的标识 |
page.cursor | string | null | 否 | 上一页返回的 next_cursor |
page.limit | number | 否 | 每页条数,默认 20,最大 100 |
返回值:Promise<{ items: unknown[]; next_cursor: string | null }>。items 的每一项为 { username, nickname, avatar_url, reacted_at },已删除的用户 username 为 null。
可能的错误:permission_denied(read_only_membership)、rate_limited、not_found。
im.messages.setPinned()
置顶或取消置顶一条消息。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 会话 ID |
seq | number | 是 | 消息的序号 |
pinned | boolean | 是 | true 置顶,false 取消 |
返回值:Promise<{ changed: boolean }>。
可能的错误:permission_denied(pin_denied、message_recalled、read_only_membership)、limit_exceeded(pinned_message_limit)、not_found。
im.messages.lookup()
按序号从服务端取回消息,同时保存到本地。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 会话 ID |
seqs | number[] | 是 | 序号,超过 100 个时 SDK 分批请求 |
返回值:Promise<{ items: MessageView[]; missing: number[] }>,missing 为不存在、已过期或本人看不到的序号。
可能的错误:not_found(会话不存在或本人看不到)、network_error。
im.messages.searchLocal()
在本地保存的文本消息中查找。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
keyword | string | 是 | 关键词,按子串匹配,不区分大小写 |
conversation_id | string | 否 | 只在这个会话中查找 |
limit | number | 否 | 最多返回的条数,默认 50 |
返回值:Promise<MessageView[]>,按时间倒序。
可能的错误:local_validation(reason 为 required)、storage_error。
im.messages.receipts()
查询群消息的已读人数。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 群会话 ID |
seqs | number[] | 是 | 本人发送的、要求回执的消息的序号 |
返回值:Promise<ReceiptCount[]>,与 seqs 一一对应,见 ReceiptCount。
可能的错误:permission_denied(receipt_denied,本人已离开群)、rate_limited。
im.messages.receiptUsers()
查询一条群消息已读或未读的人,翻页。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 群会话 ID |
seq | number | 是 | 消息的序号 |
p.status | 'read' | 'unread' | 是 | 已读或未读的人 |
p.cursor | string | null | 否 | 上一页返回的 next_cursor |
p.limit | number | 否 | 每页条数,默认 20,最大 100 |
返回值:Promise<{ items: unknown[]; next_cursor: string | null }>。items 的每一项为 { username, nickname, avatar_url }。
可能的错误:permission_denied(receipt_denied)、rate_limited、not_found。
im.messages.getReceipt()
同步读取内存中最近一次得到的已读人数。变化时发出 receipts.changed。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 群会话 ID |
seq | number | 是 | 消息的序号 |
返回值:ReceiptCount | undefined。
MessageList
im.messages.open() 返回的消息列表。
| 成员 | 说明 |
|---|---|
conversation_key | 只读,列表的会话键;草稿会话换成正式会话后变为新的会话 ID |
getSnapshot() | 当前的快照,见 MessageListSnapshot |
subscribe(listener) | 快照变化时调用 listener(不带参数),返回取消订阅的函数 |
loadOlder() | 加载更早的 50 条,返回 Promise<void>;没有更早的或正在加载时立即返回 |
loadNewer() | 从定位处向下加载,返回 Promise<void>;已在最新处时立即返回 |
loadGap(gap) | 加载一个 skipped 空洞,返回 Promise<void>;rejoin 空洞什么也不做 |
jumpTo(seq) | 定位到这个序号附近,返回 Promise<void> |
markRead() | 把已读位置推进到最新一条,合并发送 |
setViewing({ atBottom?, visible? }) | 报告用户是否停在最新处、列表是否显示;省略的字段不变 |
dispose() | 释放列表,之后 loadOlder() 等方法以 invalid_state(reason 为 disposed)拒绝 |
加载失败时方法不拒绝,错误放在快照的 error 中。
render 子路径
从 @deeprespond/im-web/render 导入。client 参数为 createDRClient() 创建的客户端,传入其他对象时抛出 TypeError。
| 函数 | 参数 | 返回值 |
|---|---|---|
displayName(client, username, opts?) | username:string | null;opts.group_id:取这个群中的群昵称;opts.locale | string |
conversationTitle(client, view, opts?) | view:ConversationView;opts.locale | string |
summarize(message, opts) | message:至少有 type、body、sender、sender_type、recalled;opts.nameOf:必填;opts.self;opts.locale | string,文本取原文(连续空白合并为一个空格),撤回的为撤回提示,通话记录给出 self 时为通话的文字 |
renderTip(message, opts) | message:群提示;opts.nameOf:必填;opts.self;opts.locale | string |
renderCallRecord(message, opts) | message:通话记录;opts.self:必填;opts.locale | string |
renderRecall(message, opts) | message:已撤回的消息;opts.self、opts.nameOf:必填;opts.locale | string,没有撤回时为空字符串 |
nameOf 的类型为 (username: string) => string,locale 的类型为 Partial<Locale>。
locale/zh-CN 子路径
从 @deeprespond/im-web/locale/zh-CN 导入。
| 导出 | 说明 |
|---|---|
zhCN | 中文文案表:errors(错误提示)、summary(摘要)、recall(撤回提示)、tip(群提示)、call(通话记录),以及 you、deletedUser、unnamedGroup 等条目 |
Locale | 类型,typeof zhCN |
describeError(err, locale?) | 按错误码和原因给出提示文字,见事件与错误 |
formatTemplate(template, values) | 把文案中的 {name} 等占位符替换为 values 中的值 |
数据结构
Message
服务端的消息对象,字段的含义见消息格式。
| 字段 | 类型 | 说明 |
|---|---|---|
conversation_id | string | 会话 ID |
conversation_type | 'single' | 'group' | 单聊或群聊 |
seq | number | 在会话中的序号 |
message_id | string | 全应用唯一的消息 ID |
client_msg_id | string | null | 发送时的去重 ID |
sender | string | null | 发送者的用户名;系统消息、发送者已删除时为 null |
sender_type | 'user' | 'system' | 用户或系统 |
recipient | string | null | 单聊的接收者 |
type | string | 消息类型:text、image、voice、video、file、location、custom、tip、call,之后可能增加 |
body | Record<string, unknown> | null | 消息体;已撤回、已擦除为 null |
ext | Record<string, unknown> | null | 扩展字段 |
mentions | { usernames: string[] | null; all: boolean } | null | @ 的人 |
reply_to | { seq: number; sender: string | null } | null | 引用的消息 |
need_receipt | boolean | 是否要求群已读回执 |
exclude_from_unread | boolean | 是否不计入未读 |
reactions | Array<{ key, count, users, reacted? }> | null | 表情回应:每种表情一项,users 为前 3 个回应的人(已删除的为 null) |
pinned | { by: string | null; at: string } | null | 置顶信息 |
edited | { at: string; count: number } | null | 编辑信息 |
recalled | { by: string | null; role: string; at: string } | null | 撤回信息,role 为 sender、owner、admin、server、platform |
erased | boolean | 是否已被擦除 |
via | 'client' | 'openapi' | 'console' | 'system' | 写入方式 |
created_at | string | 发送时间 |
message_version | number | 消息的变更版本号 |
MessageView
消息列表、send() 返回的消息:Message 加上本地状态。
| 字段 | 类型 | 说明 |
|---|---|---|
| Message 的全部字段 | 见上表 | |
seq、message_id、created_at | number | null、string | null、string | null | 发送队列中的消息为 null |
conversation_id | string | 发到草稿会话(还没有服务端会话的单聊)、还没发出的消息为 null |
_local.status | 'sent' | 'queued' | 'uploading' | 'sending' | 'failed' | 已发出、等待发送、上传中、发送中、失败 |
_local.progress | number | 可选,上传进度 0 到 1,只在 uploading 时有 |
_local.error | DRError | 可选,失败的原因 |
_local.status_unknown | boolean | 可选,失败的消息可能已经发出 |
_local.reacted | Record<string, boolean> | 可选,本人是否回应过各种表情 |
_local.unsupported | boolean | 不认识的消息类型 |
_local.trusted | boolean | via 不是 client,即不是用户在客户端发出的 |
MessageListSnapshot
| 字段 | 类型 | 说明 |
|---|---|---|
items | readonly MessageView[] | 按序号升序,发送队列中的排在最后 |
gaps | readonly Gap[] | 没有加载的范围,见 Gap |
hasOlder | boolean | 上面还有更早的消息 |
hasNewer | boolean | 从某条消息定位打开、还没翻到最新处 |
loading | 'none' | 'initial' | 'older' | 'newer' | 加载状态 |
pinned | readonly PinnedItem[] | 置顶消息,见 PinnedItem |
error | DRError | 可选,最近一次加载的错误 |
Gap
| 字段 | 类型 | 说明 |
|---|---|---|
from_seq | number | 起始序号(含) |
to_seq | number | 结束序号(含) |
kind | 'skipped' | 'rejoin' | skipped 离线补齐时跳过的,可以加载;rejoin 重新入群之前看不到的,不能加载 |
PinnedItem
| 字段 | 类型 | 说明 |
|---|---|---|
seq | number | 消息的序号 |
pinned | { by: string | null; at: string } | 置顶的人(由服务端置顶时为 null)和时间 |
message | MessageView | null | 消息,正文还没取回时为 null |
SendTarget
以下三种之一:
| 形式 | 说明 |
|---|---|
{ conversation_key: string } | 会话键,包括草稿会话的 draft:{username} |
{ to_user: string } | 对方的用户名 |
{ to_group: string } | 群 ID |
OutgoingContent
type | 其他字段 | 说明 |
|---|---|---|
'text' | body: { text: string } | 文本 |
'image' | file: Blob;original?: boolean;body? | 由 SDK 处理、上传的图片;original 发送原图 |
'voice' | file: Blob;body: { duration_seconds: number } | 由 SDK 上传的语音,时长必填 |
'video' | file: Blob;body? | 由 SDK 上传的视频 |
'file' | file: Blob;body?: { name?: string } | 由 SDK 上传的文件 |
'image'、'voice'、'video'、'file' | body: Record<string, unknown> | 已有地址的附件,body.url 必填 |
'location' | body: { latitude, longitude, name?, address? } | 位置 |
'custom' | body: Record<string, unknown> | 自定义消息 |
SendOptions
| 字段 | 类型 | 说明 |
|---|---|---|
ext | Record<string, unknown> | 扩展字段 |
mentions | { usernames?: string[]; all?: boolean } | @ 的人,只用于群聊 |
reply_to | { seq: number } | 引用的消息 |
need_receipt | boolean | 要求群已读回执,只用于群聊 |
exclude_from_unread | boolean | 不计入接收者的未读数 |
push | { disabled: boolean } | 是否关闭这条消息的离线推送 |
waitUntil | 'queued' | 'sent' | send() 何时返回,默认 'queued' |
ReceiptCount
以下两种之一:
| 形式 | 说明 |
|---|---|
{ seq: number; read_count: number; unread_count: number } | 已读和未读的人数 |
{ seq: number; code: string; details?: Record<string, unknown> } | 这一条不能查看,code 为错误码,如 permission_denied(receipt_denied)、not_found |
OnlineMessage
message.online 事件中的消息。
| 字段 | 类型 | 说明 |
|---|---|---|
message_id | string | 消息 ID,只用于排查问题 |
client_msg_id | string | 发送方生成的 ID |
conversation_type | 'single' | 'group' | 单聊或群聊 |
conversation_id | string | null | 会话 ID,两人之间还没有会话时为 null |
sender | string | null | 发送者 |
sender_type | string | user 或 system |
recipient | string | null | 单聊的接收者 |
group_id | string | null | 群聊的群 ID |
type | string | 总是 custom |
body | Record<string, unknown> | 消息体 |
ext | Record<string, unknown> | null | 扩展字段 |
via | string | 写入方式 |
created_at | string | 发送时间 |
OnlineResult
| 字段 | 类型 | 说明 |
|---|---|---|
message_id | string | 消息 ID,只用于排查问题 |
client_msg_id | string | SDK 生成的 ID |
created_at | string | 发送时间 |
online_only | true | 总是 true |
