消息
本页介绍用 im.messages 显示一个会话的消息、发送各种类型的消息、接收新消息,以及撤回、删除、表情回应、置顶和已读回执;最后是 package:deeprespond_im/render.dart 中的显示辅助函数。会话列表见会话,选择、处理和上传文件的细节见文件与图片,列表组件的更多写法见在 Flutter 界面中使用。
典型用法:用户点击会话时用 im.messages.open(conversationKey) 打开消息列表,用 StreamBuilder 按快照重建;用户发送时调用 im.messages.send(),消息立即出现在列表末尾,发出后换成服务端的正式消息;对方的新消息、撤回、表情回应都会自动更新到列表中,不需要自己合并。
消息的格式(类型、body 的字段、seq 和 message_id)与服务端相同,见消息格式。
显示消息列表
im.messages.open() 返回一个会持续更新的消息列表(MessageList)。snapshot 是当前的快照,stream 在订阅时先发出当前快照、之后每次变化发出新快照:
import 'dart:async';
import 'package:deeprespond_im/deeprespond_im.dart';
import 'package:flutter/material.dart';
class ChatPage extends StatefulWidget {
const ChatPage({super.key, required this.im, required this.conversationKey});
final DRClient im;
final String conversationKey;
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late final MessageList _list = widget.im.messages.open(widget.conversationKey);
final _scroll = ScrollController();
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
}
@override
void dispose() {
_scroll.dispose();
_list.dispose(); // 离开这个会话时一定要释放
super.dispose();
}
// 列表是倒序的(reverse: true),偏移量接近 0 就是停在最新处
void _onScroll() {
final atBottom = _scroll.offset < 40;
_list.setViewing(atBottom: atBottom);
if (atBottom) _list.markRead();
final s = _list.snapshot;
if (_scroll.position.extentAfter < 300 && s.hasOlder && s.loading == MessageListLoading.none) {
unawaited(_list.loadOlder());
}
}
@override
Widget build(BuildContext context) => StreamBuilder<MessageListSnapshot>(
stream: _list.stream,
initialData: _list.snapshot,
builder: (context, s) {
final snapshot = s.data ?? _list.snapshot;
final items = snapshot.items;
if (items.isEmpty && snapshot.loading == MessageListLoading.initial) return const Center(child: CircularProgressIndicator());
final me = widget.im.auth.currentUser?.username;
return ListView.builder(
controller: _scroll,
reverse: true,
itemCount: items.length,
itemBuilder: (context, i) {
final m = items[items.length - 1 - i];
final mine = m.sender == me;
final status = m.local.status; // sent / queued / uploading / sending / failed
return Align(
key: ValueKey(m.seq != null ? 's${m.seq}' : 'p${m.clientMsgId}'),
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
child: Text('${m.body?['text'] ?? '[${m.type}]'}${status == 'sent' ? '' : '($status)'}'),
);
},
);
},
);
}- 快照:
items按序号升序排列,发送队列中还没发出的消息排在最后;gaps是中间暂时没有加载的范围(见空洞);hasOlder表示上面还有更早的消息;pinned是这个会话的置顶消息;loading为MessageListLoading.initial(正在打开)、older、newer或none;加载出错时error不为null。 - 识别消息:已发出的消息用
seq(会话内的序号)识别;还在发送队列中的消息seq、messageId、createdAt为null,用clientMsgId识别。 - 对象不变:没有变化时
snapshot返回同一个对象;有变化时换成新的快照,其中没有变化的消息沿用原来的对象,可以用identical判断哪些条目需要重建。快照和其中的列表都不能修改。 - 释放:不再显示时一定要调用
dispose(),否则 SDK 会继续为它补齐消息,这个会话也会一直被当作“正在被看”。同一个会话可以同时打开多个列表,它们共用本地数据。退出登录、切换用户时 SDK 先把快照换成空的,然后释放列表。 MessageList不是LiveList,不能交给LiveListBuilder,请用StreamBuilder订阅stream。
本地优先
打开时 SDK 先显示本机保存的最新 50 条消息,再在后台向服务端补齐离线期间的新消息,补齐后快照更新。所以:
- 打开过的会话在 App 重新启动后、离线时也能立即显示;
- 新设备上第一次打开时本机没有消息,
loading为MessageListLoading.initial,取回最新一页后显示; - 列表打开期间,同步发现这个会话有新消息时立即补齐,不需要用户操作;
- 超过应用消息保留期的消息不再显示,即使本机还保存着。
报告用户是否在看
一个会话“正在被看”是指:它的消息列表打开着、用户停在最新处、这个列表在界面上显示,并且 App 在前台。正在被看的会话收到新消息时不计入本地的未读数;创建客户端时开启了 autoMarkRead 的,SDK 还会自动标记已读。
App 切到后台、回到前台由 SDK 自动处理。列表打开后默认按“停在最新处、正在显示”处理;用户向上翻看历史(上面示例中的 _onScroll),或者聊天页面被别的页面盖住、所在的标签页被切走时,请告诉 SDK:
// 在聊天页面上打开群资料页:盖住期间不算在看
list.setViewing(visible: false);
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => groupInfoPage()));
list.setViewing(visible: true);list.markRead() 把这个会话的已读位置推进到最新一条,与 im.conversations.markRead() 相同,SDK 合并发送。
加载更早的消息
用户滚动到顶部、hasOlder 为 true 时调用 loadOlder(),每次加载 50 条:
final s = list.snapshot;
if (s.hasOlder && s.loading == MessageListLoading.none) await list.loadOlder();本机有更早的消息时直接读取,没有的再向服务端获取。加载失败时方法不抛出,快照的 error 不为 null,可以提示用户后再次调用。
跳转到某条消息
从搜索结果、置顶消息、引用、“有人 @ 我”或点击推送通知进入时,打开列表并定位到某条消息:
// 打开会话并定位到第 1024 条(取它前后各约 25 条)
final around = im.messages.open(conversationKey, aroundSeq: 1024);
// 已打开的列表中跳转
await list.jumpTo(1024);
// 定位后向下翻到最新
if (list.snapshot.hasNewer) await list.loadNewer();定位后列表不再停在最新处,hasNewer 为 true,发送队列中的消息暂不显示;用户向下滚动时调用 loadNewer(),翻到最新后恢复正常。要直接回到最新处,可以释放这个列表,再不带 aroundSeq 重新打开。
“有人 @ 我”的消息序号在会话状态的 mentionSeq 中,见会话;点击推送通知带来的会话和序号见离线推送。
空洞
gaps 中的每一项是消息列表中一段没有加载的序号范围(闭区间 fromSeq 到 toSeq),在序号的相应位置显示一个提示:
skipped:长时间离线后,一个会话积累了很多新消息,SDK 只补齐最新的一部分,中间的跳过了。显示“查看更早的消息”,用户点击时调用loadGap(gap)加载。不能把它当作“没有消息”。rejoin:退群后又重新入群,两次入群之间的消息本人看不到,显示“以下为你重新加入群聊之前的消息”这类分隔,不能加载。
void onGapTap(Gap gap) {
if (gap.kind == 'skipped') unawaited(list.loadGap(gap));
}hasOlder 遇到 skipped 空洞时为 false,由用户点击空洞继续向上加载。
显示一条消息
列表中的每一条是 MessageView:服务端的消息对象,加上 SDK 的本地状态 local。按 type 显示:
import 'package:deeprespond_im/deeprespond_im.dart';
import 'package:deeprespond_im/render.dart';
String describe(DRClient im, MessageView m) {
final me = im.auth.currentUser?.username ?? '';
final groupId = m.conversationType == 'group' ? m.conversationId : null;
String nameOf(String u) => displayName(im, u, groupId: groupId);
if (m.recalled != null) return renderRecall(m, self: me, nameOf: nameOf);
if (m.erased) return '[消息已删除]';
if (m.local.unsupported) return '[当前版本不支持此消息]';
final body = m.body ?? const <String, Object?>{};
switch (m.type) {
case 'text':
return '${body['text']}';
case 'tip':
return renderTip(m, nameOf: nameOf, self: me);
case 'call':
return renderCallRecord(m, self: me);
case 'custom':
// 涉及资金、订单的卡片只信任不是由用户在客户端发出的
return m.local.trusted ? '[卡片] ${body['title'] ?? ''}' : '[消息]';
default:
return '[${m.type}]';
}
}- 发送者:
sender为发送者的用户名,用displayName()显示(好友备注、群昵称、昵称)。senderType为system的是系统消息(群提示、以系统身份发送的群消息),sender为null;senderType为user而sender为null的,发送者已被删除,显示“已删除的用户”。 - 不认识的类型:
local.unsupported为true,显示“当前版本不支持此消息”,不要丢弃。 - 可信的消息:
local.trusted为true表示消息不是用户在客户端发出的(由你的服务端、控制台或系统写入)。custom的内容由发送方决定,涉及资金、订单状态的卡片只应信任trusted的消息。 - 已撤回、已擦除:
recalled不为null或erased为true时,body、ext、mentions、replyTo都为null,reactions为空,显示相应的提示。 - 已编辑:
edited不为null时可以显示“已编辑”,见编辑。 - 引用:
replyTo为{seq, sender},被引用的消息按序号在列表中查找,不在列表中的用im.messages.lookup()取回;原消息已撤回的显示“原消息已撤回”。 - 附件:图片、语音、视频、文件消息的
body['url']是文件地址,不能直接交给Image.network,要用deeprespond_im_flutter的DRImage显示图片,或者用im.files.download()下载,见文件与图片:
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
final url = (m.body?['thumbnail_url'] ?? m.body?['url']) as String;
final image = Image(image: DRImage(url, client: im), width: 160, fit: BoxFit.cover);- 时间:
createdAt为服务端的发送时间(UTC 的DateTime),用toLocal()转为本地时间后用intl等日期库格式化。SDK 不提供时间的格式化。发送队列中的消息createdAt为null,可以显示为“刚刚”。 - 服务端新增的字段:可以从
m.raw读取,不必等 SDK 升级。
发送消息
im.messages.send(to, content, options: ...) 发送一条消息。to 指定发给谁:
to | 说明 |
|---|---|
SendTarget.conversation(conversationKey) | 发到一个已打开的会话,单聊、群聊和草稿会话都可以 |
SendTarget.user('lisi') | 发给一个用户,还没有会话也可以 |
SendTarget.group(groupId) | 发到一个群 |
content 用 OutgoingContent 的各个构造函数创建,见 OutgoingContent。
发送文本
final local = await im.messages.send(SendTarget.conversation(conversationKey), const OutgoingContent.text('你好'));
print(local.local.status); // queuedsend() 在消息写入发送队列后立即返回本地消息,它同时出现在消息列表的末尾,状态为 queued(等待发送)或 sending(发送中)。发出后,列表中的这条换成服务端返回的正式消息(带 seq、messageId、createdAt),状态为 sent。
需要等发出后再继续的(如发送后跳转页面),设置 waitUntilSent: true,send() 在发出后返回正式消息,最终失败时抛出错误:
final sent = await im.messages.send(
SendTarget.group(groupId),
const OutgoingContent.text('会议纪要已上传'),
options: const SendOptions(waitUntilSent: true),
);
print('${sent.seq} ${sent.messageId}');调用时 SDK 先检查不依赖网络的部分(类型、必填字段、字符串中的控制字符、大小、@ 的规则),不通过的直接抛出错误,消息不会进入发送队列。文本消息的 text 不能为空,可以包含换行。
发送队列
- 离线发送:没有网络、长连接断开时也可以发送,消息保存在发送队列中,状态为
queued,联网后自动发出。发送队列保存在本机的数据库中,App 被关闭、重新启动后继续发送。 - 后台:App 切到后台后,系统可能很快挂起 App;没发出的消息在回到前台或下次启动时继续发送。
- 顺序:同一会话中的消息按调用的顺序逐条发出,前一条发出或最终失败之前,后一条等待;不同会话的消息并行发送。
- 限速:SDK 按每个会话每秒 4 条、全部会话合计每秒 8 条发送,连续发送很多条时不会触发服务端的限流。
- 长时间没有发出:创建 48 小时后还没有发出的消息不再自动发送,标为失败(
send_abandoned,reason为expired),由用户决定是否重发。时长可以用创建客户端时的outbox: OutboxOptions(maxAge: ...)调整。 - 退出登录前:
im.hasPendingWork为true时还有没发出的消息或没完成的上传,可以提示用户:
if (im.hasPendingWork && !await confirm(context, '还有消息没有发出,确定退出吗?')) return;
await im.auth.logout();发送失败与重发
网络错误、超时和服务器暂时故障,SDK 用同一条消息自动重试(间隔 1、2、4、8、16、30 秒),重复提交不会产生两条消息;网络可用而连续失败满 2 分钟的标为失败。被服务端拒绝的(如被禁言、对方拒收、内容未通过审核)立即标为失败,不自动重试。
失败的消息留在列表中,local.status 为 failed,local.error 为失败的原因,同时发出 MessageSendFailed 事件。用户可以重发或删除它:
import 'package:deeprespond_im/render.dart';
im.on<MessageSendFailed>().listen((e) {
showToast(describeError(e.error)); // 如“你已被禁言”“对方拒收了你的消息”
});
// 用户点击“重发”
await im.messages.resend(failed.clientMsgId!);
// 用户点击“删除”(只删除本机的这条,没完成的上传一并取消)
await im.messages.discard(failed.clientMsgId!);resend()仍用原来的clientMsgId,服务端据此去重。带附件、上传失败的从头重新上传。- 发送状态未知:
local.statusUnknown为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(超过消息大小上限)。全部错误码见事件与错误处理和服务端的错误码。
发送图片
把图片的本地路径(如图片选择器、相机返回的文件)用 DRFile 交给 send(),SDK 处理、上传后再发送:
try {
await im.messages.send(SendTarget.conversation(conversationKey), OutgoingContent.image(DRFile(pickedPath)));
} on DRException catch (e) {
showToast(e.code); // 调用时的检查不通过,如服务端没有开通文件服务(unsupported)
}- 上传中:消息立即出现在列表中,
local.status为uploading,local.progress为 0 到 1 的上传进度,可以先用Image.file()显示本地预览。上传完成后自动发送。 - 处理:图片默认压缩(长边缩小到 2048 像素以内、按拍摄方向转正),同时去掉拍摄地点等元数据;HEIC 转为 JPEG;GIF 不处理。
OutgoingContent.image(file, original: true)发送原图,只去掉元数据。压缩的参数和处理规则见文件与图片。 - 自动改为文件消息:超过 20 MB 或 5000 万像素的图片、无法处理的格式,以及服务端不接受格式的语音、视频,SDK 自动改为文件消息发送,消息的
type随之变为file。 body中的url、thumbnail_url、width、height、size、format都由 SDK 填写。- 附件的保存:不超过 200 MB 的附件在调用
send()时复制一份由 SDK 保存,send()返回后就可以删除原文件(如图片选择器的临时文件),App 重新启动后照样上传;更大的只记下原文件的路径,发出之前请保留原文件,否则这条消息失败(send_abandoned,reason为attachment_lost)。上限可以用创建客户端时的outbox: OutboxOptions(persistAttachmentMaxBytes: ...)调整。 - 内存中的内容(如截图)可以用
DRFile.fromBytes(bytes, name: 'screenshot.png')。
单独上传文件(不发送消息)、下载和显示图片,见文件与图片。
发送语音
录音用 deeprespond_im_flutter 的 VoiceRecorder,结束后得到录音文件和时长(AAC,M4A 容器,各端都能播放):
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
// 用户按下“按住说话”:第一次会请求麦克风权限,被拒绝时抛出 permission_required
await VoiceRecorder.start();
// 用户松开
final recording = await VoiceRecorder.stop();
await im.messages.send(SendTarget.conversation(conversationKey), recording.toContent());
// 用户上滑取消
await VoiceRecorder.cancel();recording.toContent() 等同于 OutgoingContent.voice(DRFile(recording.path, mimeType: 'audio/mp4'), duration: recording.duration)。语音消息必须给出时长,SDK 向上取整为秒,至少为 1。语音文件不能超过 5 MB。VoiceRecorder 只支持 iOS 和 Android,桌面端抛出 unsupported;需要在 iOS 的 Info.plist 中写明麦克风的用途(NSMicrophoneUsageDescription),见概述与安装。
发送视频
await im.messages.send(SendTarget.conversation(conversationKey), OutgoingContent.video(DRFile(videoPath)));SDK 读取视频的时长和宽高,截取第一帧作为封面另外上传,都写入 body。服务端不接受的视频格式自动改为文件消息发送。
发送文件
await im.messages.send(SendTarget.conversation(conversationKey), OutgoingContent.file(DRFile(filePath), name: '季度报告.pdf'));name 是显示的文件名,省略时取路径中的文件名。文件大小不能超过应用的上传上限(运行配置的 max_upload_bytes),超过时抛出 local_validation(reason 为 too_large,details['max_bytes'] 为上限);路径不存在时抛出 local_validation(reason 为 not_found)。
发送已有地址的附件
文件已经上传过(如用 im.files.upload() 上传、或者你自己的文件服务器上的地址)时,用 OutgoingContent.uploaded() 直接给出 body,不再上传:
await im.messages.send(
SendTarget.conversation(conversationKey),
const OutgoingContent.uploaded('image', {
'url': 'https://im.example.com/media/v1/f/99606017553203200/5nOzK6E40hgYIKl80dYVLQ',
'width': 1080,
'height': 720,
}),
);类型为 image、voice、video、file 之一;url 必填,宽高、大小、时长必须是非负整数。应用开启了“只允许本服务的文件地址”(media_url_only)时,外部地址会被拒绝,见消息格式。
发送位置
await im.messages.send(
SendTarget.conversation(conversationKey),
const OutgoingContent.location(latitude: 39.9087, longitude: 116.3975, name: '天安门', address: '北京市东城区'),
);纬度在 -90 到 90 之间,经度在 -180 到 180 之间,否则抛出 local_validation(reason 为 invalid_value)。
发送自定义消息
custom 消息的 body 是任意 JSON 对象,结构由你定义,如订单卡片、名片、业务通知:
await im.messages.send(
SendTarget.conversation(conversationKey),
const OutgoingContent.custom({'card': 'order', 'order_id': '8812', 'title': '你的订单已发货'}),
);字符串中不能有控制字符(包括换行)。用户在客户端也能发出看起来像“转账”“订单”的卡片,接收方只应信任 local.trusted 为 true 的这类消息,它们由你的服务端发出。
@ 提及
群消息可以 @ 成员或全体成员。正文中的“@张三”由你自己拼写,SDK 只提交 mentions:
await im.messages.send(
SendTarget.group(groupId),
const OutgoingContent.text('@李四 @王五 请确认'),
options: const SendOptions(mentionUsernames: ['lisi', 'wangwu']),
);
// @ 全体成员(只有群主和管理员可以)
await im.messages.send(
SendTarget.group(groupId),
const OutgoingContent.text('@所有人 明天放假'),
options: const SendOptions(mentionAll: true),
);- 只用于群聊,单聊中抛出
local_validation(reason为mentions_not_allowed); - 一条消息最多 @ 20 人,超过时抛出
local_validation(reason为too_many_items),不是群成员的会被忽略; - 普通成员 @ 全体成员抛出
permission_denied(reason为mention_all_denied)。
被 @ 的人在会话列表中看到“有人 @ 我”,见会话。
引用回复
await im.messages.send(
SendTarget.conversation(conversationKey),
const OutgoingContent.text('同意'),
options: SendOptions(replyToSeq: quoted.seq),
);只给出被引用消息的序号。被引用的消息必须在同一会话中、没有被撤回,否则服务端返回 invalid_argument(reason 为 invalid_reply)。
其他选项
SendOptions 的其他字段:
| 选项 | 说明 |
|---|---|
ext | 你自定义的扩展字段(JSON 对象),原样保存和返回,如业务 ID、转发来源 |
needReceipt | 要求群已读回执,只用于群聊,见群聊的已读回执 |
excludeFromUnread | 不计入接收者的未读数,适合不需要提醒的业务消息 |
pushDisabled | true 时不给接收者发送离线推送;默认 null,按服务端的默认处理 |
waitUntilSent | false(默认)写入发送队列后返回;true 发出后返回 |
一条消息的类型、body、ext、mentions 和 reply_to 合计不能超过应用的大小上限(运行配置的 max_message_body_bytes),超过时抛出 local_validation(reason 为 too_large)。
给还没有会话的人发消息
用 SendTarget.user() 直接发给一个用户,或者先用 im.conversations.openSingle() 打开单聊:
final view = await im.conversations.openSingle('lisi');
final list = im.messages.open(view.conversationKey); // 还没有会话时为 draft:lisi
await im.messages.send(SendTarget.conversation(view.conversationKey), const OutgoingContent.text('你好,我是张三'));两人之间还没有会话时,消息先归在草稿会话 draft:lisi 中,这时消息的 conversationId 为 null。第一条消息发出后服务端创建会话,SDK 把草稿会话换成正式会话:已打开的列表自动切换,它的 conversationKey 变为新的会话 ID,MessagesChanged 事件带上 renamedFrom(原来的草稿键)。对方不存在、已删除或就是本人时,消息发送失败(not_found 或 invalid_argument)。
转发
OutgoingContent.forward() 把一条消息的类型和内容作为新消息发给别人,附件直接沿用原来的地址:
await im.messages.send(
SendTarget.user('wangwu'),
OutgoingContent.forward(message),
options: SendOptions(ext: {'forwarded_from': message.messageId}),
);群提示、通话记录、已撤回和已擦除的消息不能转发,抛出 local_validation。附件按上传时间过期,过期后转发出去的消息同样下载不了。合并转发请用 custom 消息自行定义。
只推在线的消息
“对方正在录音”、白板笔迹这类实时信令不需要保存,用 sendOnline() 发送:只推给接收者此刻在线的设备,不保存、不计入未读,离线的设备收不到,之后也拉取不到。
await im.messages.sendOnline(SendTarget.group(groupId), body: {'signal': 'whiteboard', 'stroke': [1, 2, 3, 4]});
im.on<MessageOnline>().listen((e) {
print('${e.message.sender} ${e.message.body}');
});- 只能是
custom类型,不进发送队列,失败不重试:没有网络时直接抛出错误。 - 接收方的
MessageOnline事件已按“发送者 +clientMsgId”去重,消息不出现在消息列表中。 - “正在输入”请用
im.conversations.typing(),见会话。
接收新消息
打开着的消息列表、会话列表和未读数都会自动更新,通常不需要处理新消息的事件。App 在前台时需要提醒用户(应用内横幅、提示音)的,监听 MessageReceived:
im.on<MessageReceived>().listen((e) {
if (e.notify.notify) {
showToast('${e.notify.title}:${e.notify.body}');
}
});- 只对通过长连接实时收到的、别人发来的消息发出;本人在其他设备发出的消息、离线期间的消息(由同步补齐)不发出。
notify是 SDK 按推送设置、免打扰和 @ 给出的提醒判断:notify为false时reason说明原因(如muted、dnd)。用法见提醒、免打扰与举报。- App 在后台或已被关闭时,新消息由系统的推送通知提醒,见离线推送。
要监听某个会话的消息变化(新消息、撤回、表情回应、发送状态),可以用 MessagesChanged 事件:
im.on<MessagesChanged>().listen((e) {
// upserted:新增或变化的消息序号;removed:删除的序号;pending:发送队列中变化的 clientMsgId
print('${e.conversationKey} ${e.upserted} ${e.removed} ${e.pending} ${e.renamedFrom}');
});本人发出的消息换成正式消息时,同一个事件中 pending 带有它的 clientMsgId、upserted 带有它的新序号,可以据此保持界面上的滚动位置。
撤回
if (im.messages.canRecall(message)) {
await im.messages.recall(message.conversationId!, 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。
编辑
Flutter SDK 不提供编辑消息。你的服务端可以编辑 text 和 custom 消息(见撤回、编辑与置顶),编辑后客户端的消息列表自动更新为新的内容,edited 为 {at, count},可以显示“已编辑”。
删除消息
deleteForMe() 删除本人的这些消息,对方和其他群成员不受影响,本人的其他设备同步删除:
await im.messages.deleteForMe(conversationId, [101, 102, 105]);一次最多 100 条。不提供双向删除,需要让对方也看不到的请撤回。发送队列中、还没发出的消息用 discard() 删除。
表情回应
final id = message.conversationId!;
final seq = message.seq!;
// 添加、取消
await im.messages.react(id, seq, 'thumbs_up');
await im.messages.unreact(id, seq, 'thumbs_up');
// 显示:每种表情的人数、前 3 个回应的人,以及本人是否回应过
for (final r in message.reactions) {
if (r is! Map) continue;
final key = r['key'] as String;
final mine = message.local.reacted[key] ?? r['reacted'] == true;
print('$key ${r['count']} ${r['users']} $mine');
}
// 回应这种表情的全部人(翻页)
final page = await im.messages.reactionUsers(id, seq, 'thumbs_up', limit: 50);
print('${page.items.map((u) => u.nickname)} ${page.nextCursor}');- 需要应用开启表情回应(运行配置的
message_reaction_enabled),否则抛出permission_denied(reason为reaction_disabled)。 - 表情的
key由你的客户端定义(如thumbs_up、heart),各端保持一致;每条消息最多max_reactions_per_message种,满了以limit_exceeded(reaction_limit)拒绝。 - 群提示、已撤回的消息不能回应;已离开的群中不能回应,也不能查看回应名单(
read_only_membership)。 - 返回
false表示本来就是这个状态。别人的回应通过长连接实时更新到消息列表中。
置顶消息
await im.messages.setPinned(message.conversationId!, message.seq!, true);
// 消息列表的快照中有这个会话的置顶消息,按置顶时间倒序
for (final item in list.snapshot.pinned) {
print('${item.seq} ${item.pinnedBy} ${item.message?.body ?? '加载中'}');
}- 单聊双方都可以置顶;群聊只有群主和管理员可以,否则以
permission_denied(reason为pin_denied)拒绝。 - 每个会话最多置顶
max_pinned_messages_per_conversation条,满了以limit_exceeded(pinned_message_limit)拒绝。 - 置顶列表在打开消息列表时从服务端刷新。正文还没取回的置顶消息
message为null,取回后快照更新。点击置顶消息时用jumpTo(item.seq)定位。
已读回执
单聊
单聊的已读状态不需要单独查询:会话状态中的 peerReadSeq 是对方已读到的序号,本人发出的、seq 不大于它的消息都显示“已读”,对方标记已读时实时更新(会话列表和 ConversationsChanged 事件随之更新)。
final view = await im.conversations.get(list.conversationKey);
final peerRead = view?.state?.peerReadSeq ?? 0;
final me = im.auth.currentUser?.username;
final enabled = im.config?.singleReadAckEnabled ?? false;
for (final m in list.snapshot.items) {
final seq = m.seq;
if (enabled && m.sender == me && seq != null) {
print('$seq ${seq <= peerRead ? '已读' : '未读'}');
}
}应用没有开启单聊已读回执(运行配置的 single_read_ack_enabled)时不显示已读状态。请按配置判断,不要按 peerReadSeq 是否为 null 判断。
群聊
群消息的已读回执只对发送时设置了 needReceipt: true 的消息有效,只有发送者本人、在仍是群成员时可以查看。需要应用开启群已读回执(group_read_ack_enabled),否则发送时抛出 permission_denied(read_ack_disabled)。
await im.messages.send(SendTarget.group(groupId), const OutgoingContent.text('请大家确认'), options: const SendOptions(needReceipt: true));
// 查询已读人数:可以逐条调用,SDK 合并请求
final counts = await im.messages.receipts(groupId, seqs);
for (final c in counts) {
if (c.readCount != null) {
print('${c.seq}:${c.readCount} 人已读,${c.unreadCount} 人未读');
} else {
print('${c.seq} 不能查看:${c.code}');
}
}
// 已读人数变化时(成员读到了你的消息)
im.on<ReceiptsChanged>().listen((e) {
for (final seq in e.seqs) {
print('$seq ${im.messages.getReceipt(e.conversationId, seq)?.readCount}');
}
});
// 已读、未读的人
final readers = await im.messages.receiptUsers(groupId, seqs.first, status: ReceiptStatus.read, limit: 50);
print('${readers.items.map((u) => u.username)} ${readers.nextCursor}');receipts():SDK 把同一会话 50 毫秒内的调用合并,去掉 10 秒内查过的,按每批 20 条请求,适合在消息滚动进入视野时逐条调用。某一条不能查看的(不是本人发的、没有要求回执)在结果中只有seq和code,不影响其他条。本人已离开群时抛出permission_denied(receipt_denied)。getReceipt()同步读取内存中最近的已读人数,没有查过时为null。- 500 人以内的群,成员读到消息时服务端推送新的人数,SDK 更新后发出
ReceiptsChanged;更大的群只能主动查询。 - 查询回执、回执名单和表情回应名单合计每个用户每分钟 60 次,滚动时不要对每条消息单独发起请求。
查找消息
按序号取回:引用、置顶、通知中提到的消息不在列表中时,用 lookup() 从服务端取回(同时保存到本机):
final result = await im.messages.lookup(conversationId, [3, 57]);
print(result.items.map((m) => m.seq));
print(result.missing); // 不存在、已过期或本人看不到的序号搜索本地消息:服务端不提供消息搜索。searchLocal() 在本机已保存的文本消息中按关键词查找(不区分大小写),按时间倒序:
final results = await im.messages.searchLocal(keyword: '季度报告', limit: 20);
// 用户点击一条结果时,打开会话并定位到这条消息
MessageList onResultTap(MessageView m) => im.messages.open(m.conversationId!, aroundSeq: m.seq);只能找到本机保存过的消息(打开过、同步过的会话)。可以用 conversationId 只在一个会话中查找。
显示辅助
package:deeprespond_im/render.dart 提供拼出显示文字的函数和中文文案表。它们都是可选的,你也可以完全自己实现。
import 'package:deeprespond_im/render.dart';| 函数 | 用途 | 示例输出 |
|---|---|---|
displayName(im, username, groupId: ...) | 用户的显示名:好友备注、群昵称(给出 groupId 时)、昵称、用户名中第一个非空的 | 李四 |
conversationTitle(im, view) | 会话的标题:单聊为对方的显示名,群聊为群名 | 产品讨论组 |
summarize(message, nameOf: ...) | 会话列表中最后一条消息的摘要 | [图片]、[文件] 报告.pdf |
renderTip(message, nameOf: ...) | 群提示的文字 | 张三邀请李四、王五加入了群聊 |
renderCallRecord(message, self: ...) | 通话记录的文字 | 视频通话 通话时长 03:12、未接来电 |
renderRecall(message, self: ..., nameOf: ...) | 撤回提示 | 你撤回了一条消息 |
describeError(error) | 按错误码给出中文提示 | 你已被禁言 |
显示名
displayName(im, username, groupId: groupId) 返回一个用户在界面上显示的名字,按顺序取第一个非空的:你给他设置的好友备注、他在这个群中的群昵称(给出 groupId、且本机已有这个群的成员资料时)、他的昵称、用户名。不在群里显示时省略 groupId。
import 'package:deeprespond_im/render.dart';
final inChat = displayName(im, 'lisi');
final inGroup = displayName(im, 'lisi', groupId: '99582580415791104');displayName()同步返回。本机没有这个用户的资料时先返回用户名,同时在后台获取,获取后发出UsersChanged事件,重新构建即可显示昵称。好友备注、群昵称、群名变化时同样发出对应的事件(FriendsChanged、GroupMembersChanged、GroupsChanged)。nameOf参数用来把用户名转为显示名,通常写成(u) => displayName(im, u, groupId: groupId)。self是本人的用户名:群提示和撤回提示中的本人显示为“你”,通话记录按本人是不是发起方显示“已取消”或“对方已取消”。- 已删除的用户(用户名为
null)显示为“已删除的用户”。不认识的群提示显示“[群提示]”,不认识的消息类型的摘要为“[消息]”。
替换文案
文案来自 zhCN(类型为 DRLocale)。每个函数都接受命名参数 locale,用 copyWith() 替换其中的部分条目,或整体换成其他语言:
import 'package:deeprespond_im/render.dart';
final en = zhCN.copyWith(
you: 'You',
deletedUser: 'Deleted user',
summary: {'image': '[Photo]', 'voice': '[Voice]', 'video': '[Video]', 'file': '[File] {name}'},
);
final text = summarize(
const MessageView({'type': 'image', 'body': <String, Object?>{}, 'sender': 'lisi', 'sender_type': 'user'}),
nameOf: (u) => u,
locale: en,
);
// '[Photo]'copyWith() 中的各组(errors、summary、recall、tip、call)按键合并,没有给出的条目沿用原来的中文。文案中的 {name} 等占位符按原样保留,formatTemplate(template, values) 可以替换占位符。describeError(error, locale: ...) 的用法见事件与错误处理。
接口参考
im.messages 的方法。除 open()、canRecall()、getReceipt() 之外都返回 Future,失败时抛出 DRException;没有登录时为 not_signed_in,网络错误为 network_error、timeout。错误码见事件与错误处理和服务端的错误码。
im.messages.open()
打开一个会话的消息列表。每次调用返回一个新的列表,不再使用时调用它的 dispose()。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationKey | String | 是 | 会话键:会话 ID、群 ID,或草稿会话的 draft:{username} |
aroundSeq | int? | 否 | 命名参数,定位到这个序号附近 |
返回值:MessageList,见 MessageList。
可能的错误:同步抛出 not_signed_in、local_validation(reason 为 required)。
im.messages.send()
发送一条消息:检查后写入发送队列,带文件的先处理、上传。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
to | SendTarget | 是 | 发给谁,见 SendTarget |
content | OutgoingContent | 是 | 消息的类型和内容,见 OutgoingContent |
options | SendOptions | 否 | 命名参数,见 SendOptions |
返回值:Future<MessageView>。默认为写入发送队列后的本地消息(seq 为 null);waitUntilSent: true 时为发出后的正式消息。
可能的错误:
- 调用时:
local_validation(reason为required、control_characters、invalid_value、invalid_body、too_large、mentions_not_allowed、receipt_not_allowed、too_many_items、unknown_type、url_not_allowed、not_found,details['field']指出字段)、permission_denied(mention_all_denied、read_ack_disabled)、unsupported(media_disabled,服务端没有开通文件服务)、invalid_state(peer_deleted,单聊的对方已删除)、not_signed_in; waitUntilSent: true时还有发送失败的错误,与MessageSendFailed的error相同。
im.messages.resend()
重发一条失败的消息。不是失败状态的什么也不做。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
clientMsgId | String | 是 | 失败消息的 clientMsgId |
返回值:Future<void>,重新进入发送队列后完成。
可能的错误:local_validation(reason 为 not_found,发送队列中没有这条)。
im.messages.discard()
删除发送队列中的一条消息(失败的或还在等待的),取消没完成的上传。只影响本机。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
clientMsgId | String | 是 | 消息的 clientMsgId |
返回值:Future<void>。
im.messages.sendOnline()
发送只推在线的 custom 消息。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
to | SendTarget | 是 | 发给谁 |
body | Map<String, Object?> | 是 | 命名参数,消息体 |
ext | Map<String, Object?>? | 否 | 命名参数,扩展字段 |
返回值:Future<OnlineResult>,见 OnlineResult。
可能的错误:local_validation、network_error,以及服务端拒绝发送的错误(与普通消息相同)。
im.messages.recall()
撤回一条消息。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 会话 ID |
seq | int | 是 | 消息的序号 |
返回值:Future<bool>,false 表示已经撤回过。
可能的错误:permission_denied(recall_window_expired、recall_denied、read_only_membership)、not_found。
im.messages.canRecall()
同步判断本人能否撤回这条消息,用于决定是否显示撤回入口。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
message | MessageView | 是 | 消息 |
返回值:bool。
im.messages.deleteForMe()
删除本人的消息(仅自己)。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 会话 ID |
seqs | List<int> | 是 | 消息的序号,最多 100 个;空列表时什么也不做 |
返回值:Future<void>。
可能的错误:local_validation(reason 为 too_many_items)、not_found。
im.messages.react()
添加表情回应。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 会话 ID |
seq | int | 是 | 消息的序号 |
key | String | 是 | 表情的标识 |
返回值:Future<bool>,false 表示本来就回应过。
可能的错误: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();返回 Future<bool>,false 表示本来就没有回应。
im.messages.reactionUsers()
查询回应某种表情的人,翻页。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 会话 ID |
seq | int | 是 | 消息的序号 |
key | String | 是 | 表情的标识 |
cursor | String? | 否 | 命名参数,上一页返回的 nextCursor |
limit | int? | 否 | 命名参数,每页条数,默认 20,最大 100 |
返回值:Future<DRPage<ReactionUser>>。items 的每一项有 username、nickname、avatarUrl、reactedAt,已删除的用户 username 为 null;nextCursor 为 null 时没有下一页。
可能的错误:permission_denied(read_only_membership)、rate_limited、not_found。
im.messages.setPinned()
置顶或取消置顶一条消息。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 会话 ID |
seq | int | 是 | 消息的序号 |
pinned | bool | 是 | true 置顶,false 取消 |
返回值:Future<bool>,false 表示本来就是这个状态。
可能的错误:permission_denied(pin_denied、message_recalled、read_only_membership)、limit_exceeded(pinned_message_limit)、not_found。
im.messages.lookup()
按序号从服务端取回消息,同时保存到本机。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 会话 ID |
seqs | List<int> | 是 | 序号,超过 100 个时 SDK 分批请求 |
返回值:Future<LookupResult>:items(List<MessageView>)为取到的消息,missing(List<int>)为不存在、已过期或本人看不到的序号。
可能的错误:not_found(会话不存在或本人看不到)、network_error。
im.messages.searchLocal()
在本机保存的文本消息中查找。参数都是命名参数。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
keyword | String | 是 | 关键词,按子串匹配,不区分大小写 |
conversationId | String? | 否 | 只在这个会话中查找 |
limit | int | 否 | 最多返回的条数,默认 50 |
返回值:Future<List<MessageView>>,按时间倒序。
可能的错误:local_validation(reason 为 required,关键词为空;invalid_value,limit 小于 1)、storage_error。
im.messages.receipts()
查询群消息的已读人数。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 群会话 ID |
seqs | List<int> | 是 | 本人发送的、要求回执的消息的序号 |
返回值:Future<List<ReceiptCount>>,与 seqs 一一对应,见 ReceiptCount。
可能的错误:permission_denied(receipt_denied,本人已离开群)、rate_limited。
im.messages.receiptUsers()
查询一条群消息已读或未读的人,翻页。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 群会话 ID |
seq | int | 是 | 消息的序号 |
status | ReceiptStatus | 是 | 命名参数,ReceiptStatus.read 已读的人,ReceiptStatus.unread 未读的人 |
cursor | String? | 否 | 命名参数,上一页返回的 nextCursor |
limit | int? | 否 | 命名参数,每页条数,默认 20,最大 100 |
返回值:Future<DRPage<ReceiptUser>>。items 的每一项有 username、nickname、avatarUrl。
可能的错误:permission_denied(receipt_denied)、rate_limited、not_found。
im.messages.getReceipt()
同步读取内存中最近一次得到的已读人数。变化时发出 ReceiptsChanged。
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversationId | String | 是 | 群会话 ID |
seq | int | 是 | 消息的序号 |
返回值:ReceiptCount?,没有查过时为 null。
MessageList
im.messages.open() 返回的消息列表。
| 成员 | 说明 |
|---|---|
conversationKey | 列表的会话键;草稿会话换成正式会话后变为新的会话 ID |
snapshot | 当前的快照,见 MessageListSnapshot |
stream | Stream<MessageListSnapshot>:订阅时先发出当前快照,之后每次变化发出新快照;释放后结束 |
loadOlder() | 加载更早的 50 条,返回 Future<void>;没有更早的或正在加载时立即返回 |
loadNewer() | 从定位处向下加载,返回 Future<void>;已在最新处时立即返回 |
loadGap(gap) | 加载一个 skipped 空洞,返回 Future<void>;rejoin 空洞什么也不做 |
jumpTo(seq) | 定位到这个序号附近,返回 Future<void> |
markRead() | 把已读位置推进到最新一条,合并发送 |
setViewing({bool? atBottom, bool? visible}) | 报告用户是否停在最新处、列表是否显示;省略的不变 |
dispose() | 释放列表,之后 loadOlder() 等方法抛出 invalid_state(reason 为 disposed) |
disposed | 是否已释放 |
加载失败时方法不抛出,错误放在快照的 error 中。
事件
| 事件 | 字段 | 说明 |
|---|---|---|
MessagesChanged | conversationKey、upserted(List<int>)、removed(List<int>)、pending(List<String>)、renamedFrom(String?) | 某个会话的消息变化 |
MessageReceived | message(MessageView)、notify(NotifyDecision) | 实时收到别人发来的新消息 |
MessageSendFailed | clientMsgId、conversationKey、error(DRException) | 发送最终失败 |
MessageOnline | message(OnlineMessage) | 收到只推在线的消息 |
ReceiptsChanged | conversationId、seqs(List<int>) | 群消息的已读人数变化 |
NotifyDecision 有 notify(bool,是否提醒)、title、body(提醒的标题和正文)、reason(不提醒的原因:dnd、quiet_hours、muted、not_mentioned、excluded、self、tip、call、not_loaded)。
render.dart
从 package:deeprespond_im/render.dart 导入。im 参数为 DRClient.create() 创建的客户端,message 参数为 MessageView 或 Message。
| 函数 | 参数 | 返回值 |
|---|---|---|
displayName(im, username, {groupId, locale}) | username:String?;groupId:取这个群中的群昵称 | String |
conversationTitle(im, view, {locale}) | view:ConversationView | String |
summarize(message, {required nameOf, self, locale}) | message:至少有 type、body、sender、sender_type、recalled | String,文本取原文(连续空白合并为一个空格),撤回的为撤回提示,通话记录给出 self 时为通话的文字 |
renderTip(message, {required nameOf, self, locale}) | message:群提示 | String |
renderCallRecord(message, {required self, locale}) | message:通话记录 | String |
renderRecall(message, {required self, required nameOf, locale}) | message:已撤回的消息 | String,没有撤回时为空字符串 |
describeError(error, {locale}) | error:DRException | String,按错误码和原因给出提示 |
formatTemplate(template, values) | values:Map<String, String> | String,把 {name} 等占位符替换为 values 中的值 |
nameOf 的类型为 NameOf,即 String Function(String username);locale 的类型为 DRLocale,默认 zhCN。
| 导出 | 说明 |
|---|---|
zhCN | 中文文案表:errors(错误提示)、summary(摘要)、recall(撤回提示)、tip(群提示)、call(通话记录),以及 you、deletedUser、unnamedGroup、system、mutedUntil |
DRLocale | 文案表的类型;copyWith() 替换其中的条目,各组按键合并 |
数据结构
各类型都是服务端 JSON 的类型化视图:常用字段有 camelCase 的 getter,原始的 JSON 在 raw 中(不可修改)。时间字段为 UTC 的 DateTime。
Message
服务端的消息对象,字段的含义见消息格式。
| 字段 | 类型 | 说明 |
|---|---|---|
conversationId | String | 会话 ID |
conversationType | String | single 或 group |
seq | int | 在会话中的序号 |
messageId | String | 全应用唯一的消息 ID |
clientMsgId | String? | 发送时的去重 ID |
sender | String? | 发送者的用户名;系统消息、发送者已删除时为 null |
senderType | String | user 或 system |
recipient | String? | 单聊的接收者 |
type | String | 消息类型:text、image、voice、video、file、location、custom、tip、call,之后可能增加 |
body | Map<String, Object?>? | 消息体;已撤回、已擦除为 null |
ext | Map<String, Object?>? | 扩展字段 |
mentions | Map<String, Object?>? | @ 的人:usernames、all |
replyTo | Map<String, Object?>? | 引用的消息:seq、sender |
needReceipt | bool | 是否要求群已读回执 |
excludeFromUnread | bool | 是否不计入未读 |
reactions | List<Object?> | 表情回应:每种表情一项 {key, count, users, reacted},users 为前 3 个回应的人(已删除的为 null);没有时为空列表 |
pinned | Map<String, Object?>? | 置顶信息:by、at |
edited | Map<String, Object?>? | 编辑信息:at、count |
recalled | Map<String, Object?>? | 撤回信息:by、role、at,role 为 sender、owner、admin、server、platform |
erased | bool | 是否已被擦除 |
via | String | 写入方式:client、openapi、console、system |
createdAt | DateTime? | 发送时间 |
messageVersion | int | 消息的变更版本号 |
MessageView
消息列表、send() 返回的消息:Message 的字段加上本地状态 local。MessageView 没有 excludeFromUnread 的 getter,需要时从 raw['exclude_from_unread'] 读取。
| 字段 | 类型 | 说明 |
|---|---|---|
| Message 的字段 | 见上表 | |
seq、messageId、createdAt | int?、String?、DateTime? | 发送队列中的消息为 null |
conversationId | String? | 发到草稿会话(还没有服务端会话的单聊)、还没发出的消息为 null |
local.status | String | sent(已发出)、queued(等待发送)、uploading(上传中)、sending(发送中)、failed(失败) |
local.progress | double? | 上传进度 0 到 1,只在 uploading 时有 |
local.error | DRException? | 失败的原因 |
local.statusUnknown | bool | 失败的消息可能已经发出 |
local.reacted | Map<String, bool> | 本人是否回应过各种表情 |
local.unsupported | bool | 不认识的消息类型 |
local.trusted | bool | via 不是 client,即不是用户在客户端发出的 |
MessageListSnapshot
| 字段 | 类型 | 说明 |
|---|---|---|
items | List<MessageView> | 按序号升序,发送队列中的排在最后 |
gaps | List<Gap> | 没有加载的范围,见 Gap |
hasOlder | bool | 上面还有更早的消息 |
hasNewer | bool | 从某条消息定位打开、还没翻到最新处 |
loading | MessageListLoading | none、initial、older、newer |
pinned | List<PinnedItem> | 置顶消息,见 PinnedItem |
error | DRException? | 最近一次加载的错误 |
Gap
| 字段 | 类型 | 说明 |
|---|---|---|
fromSeq | int | 起始序号(含) |
toSeq | int | 结束序号(含) |
kind | String | skipped 离线补齐时跳过的,可以加载;rejoin 重新入群之前看不到的,不能加载 |
PinnedItem
| 字段 | 类型 | 说明 |
|---|---|---|
seq | int | 消息的序号 |
pinnedBy | String? | 置顶的人,由服务端置顶时为 null |
pinnedAt | DateTime? | 置顶的时间 |
message | MessageView? | 消息,正文还没取回时为 null |
SendTarget
| 构造函数 | 说明 |
|---|---|
SendTarget.conversation(String conversationKey) | 会话键,包括草稿会话的 draft:{username} |
SendTarget.user(String username) | 对方的用户名,还没有会话时先作为草稿会话 |
SendTarget.group(String groupId) | 群 ID |
OutgoingContent
| 构造函数 | 说明 |
|---|---|
OutgoingContent.text(String text) | 文本,不能为空 |
OutgoingContent.image(DRFile file, {bool original = false}) | 由 SDK 处理、上传的图片;original 发送原图 |
OutgoingContent.voice(DRFile file, {required Duration duration}) | 由 SDK 上传的语音,时长向上取整为秒(至少 1) |
OutgoingContent.video(DRFile file) | 由 SDK 处理、上传的视频 |
OutgoingContent.file(DRFile file, {String? name}) | 由 SDK 上传的文件;name 为显示的文件名 |
OutgoingContent.uploaded(String type, Map<String, Object?> body) | 已有地址的附件,type 为 image、voice、video、file,body['url'] 必填 |
OutgoingContent.location({required double latitude, required double longitude, String? name, String? address}) | 位置 |
OutgoingContent.custom(Map<String, Object?> body) | 自定义消息 |
OutgoingContent.forward(MessageView message) | 转发一条消息 |
DRFile
| 构造函数 | 说明 |
|---|---|
DRFile(String path, {String? mimeType, String? name}) | 本地文件的路径 |
DRFile.fromBytes(Uint8List bytes, {required String name, String? mimeType}) | 内存中的内容,SDK 写入文件后上传 |
SendOptions
用 const SendOptions(...) 创建,字段都是可选的命名参数。
| 字段 | 类型 | 说明 |
|---|---|---|
ext | Map<String, Object?>? | 扩展字段 |
mentionUsernames | List<String>? | @ 的人,只用于群聊,最多 20 人 |
mentionAll | bool | @ 全体成员,只用于群聊,默认 false |
replyToSeq | int? | 引用的消息的序号 |
needReceipt | bool | 要求群已读回执,只用于群聊,默认 false |
excludeFromUnread | bool | 不计入接收者的未读数,默认 false |
pushDisabled | bool? | true 关闭这条消息的离线推送;null 按默认 |
waitUntilSent | bool | send() 是否等发出后再返回,默认 false |
ReceiptCount
| 字段 | 类型 | 说明 |
|---|---|---|
seq | int | 消息的序号 |
readCount | int? | 已读人数;不能查看时为 null |
unreadCount | int? | 未读人数;不能查看时为 null |
code | String? | 不能查看时的错误码,如 permission_denied(receipt_denied)、not_found |
details | Map<String, Object?>? | 不能查看时的详情 |
OnlineMessage
MessageOnline 事件中的消息。
| 字段 | 类型 | 说明 |
|---|---|---|
messageId | String | 消息 ID,只用于排查问题 |
clientMsgId | String | 发送方生成的 ID |
conversationType | String | single 或 group |
conversationId | String? | 会话 ID,两人之间还没有会话时为 null |
sender | String? | 发送者 |
senderType | String | user 或 system |
recipient | String? | 单聊的接收者 |
groupId | String? | 群聊的群 ID |
type | String | 总是 custom |
body | Map<String, Object?> | 消息体 |
ext | Map<String, Object?>? | 扩展字段 |
via | String | 写入方式 |
createdAt | DateTime? | 发送时间 |
OnlineResult
| 字段 | 类型 | 说明 |
|---|---|---|
messageId | String | 消息 ID,只用于排查问题 |
clientMsgId | String | SDK 生成的 ID |
createdAt | DateTime? | 发送时间 |
onlineOnly | bool | 总是 true |
