訊息
本頁介紹用 im.messages 顯示一個會話的訊息、傳送各種型別的訊息、接收新訊息,以及撤回、刪除、表情回應、置頂和已讀回執;最後是 package:deeprespond_im_flutter/render.dart 中的顯示輔助函式。會話列表見會話,選擇、處理和上傳檔案的細節見檔案與圖片,列表元件的更多寫法見在 Flutter 介面中使用。
典型用法:使用者點選會話時用 im.messages.open(conversationKey) 開啟訊息列表,用 StreamBuilder 按快照重建;使用者傳送時呼叫 im.messages.send(),訊息立即出現在列表末尾,發出後換成服務端的正式訊息;對方的新訊息、撤回、表情回應都會自動更新到列表中,不需要自己合併。
訊息的格式(型別、body 的欄位、seq 和 message_id)與服務端相同,見訊息格式。
| 要做什麼 | 看哪裡 |
|---|---|
| 寫一個聊天頁面 | 顯示訊息列表、顯示一條訊息 |
| 翻看歷史、從搜尋或通知跳到某條訊息 | 載入更早的訊息、跳轉到某條訊息 |
| 發文字、圖片、語音、影片、檔案、位置 | 傳送訊息 |
| 處理傳送失敗、離線傳送 | 傳送佇列、傳送失敗與重發 |
| @ 某人、引用回覆、轉發 | @ 提及、引用回覆、轉發 |
| App 在前臺時提醒新訊息 | 接收新訊息 |
| 撤回、刪除、表情回應、置頂 | 撤回、刪除訊息、表情回應、置頂訊息 |
| 顯示“已讀”、群訊息幾人已讀 | 已讀回執 |
| 顯示暱稱、訊息摘要、群提示的文字 | 顯示輔助 |
顯示訊息列表
im.messages.open() 返回一個會持續更新的訊息列表(MessageList)。snapshot 是當前的快照,stream 在訂閱時先發出當前快照、之後每次變化發出新快照:
import 'dart:async';
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.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_flutter/deeprespond_im_flutter.dart';
import 'package:deeprespond_im_flutter/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_flutter/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_flutter/render.dart 提供拼出顯示文字的函式和中文文案表。它們都是可選的,你也可以完全自己實現。
import 'package:deeprespond_im_flutter/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_flutter/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_flutter/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_flutter/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 |
