Messages
This page covers im.messages: showing a conversation's messages, sending every kind of message, receiving new ones, and recalls, deletes, reactions, pinned messages and read receipts. It ends with the display helpers in package:deeprespond_im_flutter/render.dart. For the conversation list, see Conversations; for picking, processing and uploading files, see Files and images; for more ways to build list widgets, see Using it in Flutter UI.
A typical flow: when the user taps a conversation, open its message list with im.messages.open(conversationKey) and rebuild from snapshots with StreamBuilder. When the user sends, call im.messages.send(). The message appears at the end of the list right away and is replaced by the server's message once sent. New messages, recalls and reactions from others update the list automatically; you don't merge anything yourself.
Message format (types, body fields, seq and message_id) is the same as on the server; see Message format.
| You want to | See |
|---|---|
| Write a chat screen | Show the message list, Show one message |
| Scroll back through history, or jump to a message from search or a notification | Load older messages, Jump to a message |
| Send text, images, voice, video, files, locations | Send messages |
| Handle failed sends and sending offline | Send queue, Failures and resending |
| Mention someone, quote a reply, forward | Mentions, Quote replies, Forward |
| Alert the user to new messages while the app is in the foreground | Receive new messages |
| Recall, delete, react, pin | Recall, Delete messages, Reactions, Pinned messages |
| Show "Read" or how many group members have read a message | Read receipts |
| Show nicknames, message previews, group notice text | Display helpers |
Show the message list
im.messages.open() returns a message list (MessageList) that keeps itself up to date. snapshot is the current snapshot; stream emits the current snapshot on subscribe and a new one on every change:
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)'}'),
);
},
);
},
);
}- Snapshot.
itemsis in ascendingseqorder, with queued messages not yet sent at the end.gapsare ranges in between that aren't loaded (see Gaps).hasOldermeans there are older messages above.pinnedis the conversation's pinned messages.loadingisMessageListLoading.initial(opening),older,newerornone.erroris set when loading failed. - Identifying messages. Sent messages are identified by
seq(their number within the conversation). Messages still in the send queue havenullseq,messageIdandcreatedAt; identify them byclientMsgId. - Objects are reused. If nothing changed,
snapshotreturns the same object. On a change you get a new snapshot in which unchanged messages keep their objects, so you can useidenticalto decide which items to rebuild. Snapshots and their lists can't be modified. - Dispose. Always call
dispose()when you stop showing the list. Otherwise the SDK keeps filling it in and keeps treating the conversation as being viewed. You can open several lists for the same conversation; they share local data. On sign-out or switching users, the SDK first replaces the snapshot with an empty one, then disposes the list. MessageListisn't aLiveListand can't be passed toLiveListBuilder; subscribe tostreamwithStreamBuilder.
Local first
On open, the SDK first shows the latest 50 messages stored on the device, then fetches anything that arrived while offline in the background and updates the snapshot. So:
- Conversations opened before show up immediately after an app restart, and offline too.
- On a new device there's nothing stored the first time, so
loadingisMessageListLoading.initialuntil the latest page arrives. - While the list is open, new messages found by sync are filled in right away, with no user action.
- Messages older than the app's message retention period aren't shown, even if they're still stored on the device.
Tell the SDK whether the user is looking
A conversation is "being viewed" when its message list is open, the user is at the latest message, the list is on screen, and the app is in the foreground. New messages in a conversation being viewed don't count toward the local unread count; if the client was created with autoMarkRead, the SDK also marks them read.
The SDK handles the app going to the background and back by itself. After opening, the list is assumed to be at the latest message and on screen. Tell the SDK when the user scrolls up through history (_onScroll in the example above), or when the chat page is covered by another page or its tab is switched away:
// 在聊天页面上打开群资料页:盖住期间不算在看
list.setViewing(visible: false);
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => groupInfoPage()));
list.setViewing(visible: true);list.markRead() moves the conversation's read position to the latest message, the same as im.conversations.markRead(); the SDK batches the requests.
Load older messages
When the user scrolls to the top and hasOlder is true, call loadOlder(). Each call loads 50 messages:
final s = list.snapshot;
if (s.hasOlder && s.loading == MessageListLoading.none) await list.loadOlder();Older messages stored on the device are read directly; the rest come from the server. If loading fails the method doesn't throw; the snapshot's error is set, and you can tell the user and call it again.
Jump to a message
When coming from a search result, a pinned message, a quote, a mention or a tapped push notification, open the list positioned at a message:
// 打开会话并定位到第 1024 条(取它前后各约 25 条)
final around = im.messages.open(conversationKey, aroundSeq: 1024);
// 已打开的列表中跳转
await list.jumpTo(1024);
// 定位后向下翻到最新
if (list.snapshot.hasNewer) await list.loadNewer();After jumping, the list is no longer at the latest message: hasNewer is true and queued messages are hidden for now. Call loadNewer() as the user scrolls down; once at the latest message, the list behaves normally again. To go straight back to the latest message, dispose the list and open it again without aroundSeq.
The seq of a message that mentions the user is mentionSeq in the conversation state; see Conversations. For the conversation and seq a tapped push notification carries, see Push notifications.
Gaps
Each item in gaps is a range of seq numbers that isn't loaded (inclusive, fromSeq to toSeq). Show a placeholder at that position:
skipped: after a long time offline, a conversation had many new messages and the SDK only fetched the latest ones, skipping the middle. Show "View earlier messages" and callloadGap(gap)when tapped. Don't treat it as "no messages".rejoin: the user left a group and joined again; they can't see messages from between the two memberships. Show a divider such as "Messages before you rejoined". It can't be loaded.
void onGapTap(Gap gap) {
if (gap.kind == 'skipped') unawaited(list.loadGap(gap));
}hasOlder is false when it reaches a skipped gap; the user taps the gap to keep loading upward.
Show one message
Each item in the list is a MessageView: the server's message object plus the SDK's local state local. Display it by 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.
senderis the sender's username; show it withdisplayName()(friend remark, group nickname, nickname). WhensenderTypeissystemit's a system message (group notices, group messages sent as the system) andsenderisnull. WhensenderTypeisuserandsenderisnull, the sender has been deleted; show "Deleted user". - Unknown types.
local.unsupportedistrue. Show "This message isn't supported in this version"; don't drop it. - Trusted messages.
local.trustedistruewhen the message wasn't sent by a user from a client (it was written by your server, the console or the system). Senders decide what goes intocustomcontent, so only trust cards about money or order status when the message istrusted. - Recalled and erased. When
recalledisn'tnullorerasedistrue,body,ext,mentionsandreplyToarenullandreactionsis empty. Show a placeholder. - Edited. When
editedisn'tnullyou can show "Edited"; see Editing. - Quotes.
replyTois{seq, sender}. Look up the quoted message byseqin the list, and fetch ones that aren't there withim.messages.lookup(). If the original was recalled, show "Original message was recalled". - Attachments. The
body['url']of image, voice, video and file messages is a file URL that can't be passed straight toImage.network. Show images withDRImagefromdeeprespond_im_flutter, or download withim.files.download(); see Files and images:
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);- Time.
createdAtis the server's send time (a UTCDateTime). Convert it withtoLocal()and format it with a date library such asintl; the SDK doesn't format times. Queued messages havenullcreatedAt; you can show them as "Just now". - New server fields can be read from
m.rawwithout waiting for an SDK upgrade.
Send messages
im.messages.send(to, content, options: ...) sends a message. to says who it goes to:
to | Description |
|---|---|
SendTarget.conversation(conversationKey) | An open conversation: one-to-one, group or draft |
SendTarget.user('lisi') | A user, even if there's no conversation yet |
SendTarget.group(groupId) | A group |
Create content with one of the OutgoingContent constructors; see OutgoingContent.
Send text
final local = await im.messages.send(SendTarget.conversation(conversationKey), const OutgoingContent.text('你好'));
print(local.local.status); // queuedsend() returns the local message as soon as it's in the send queue. It appears at the end of the message list at the same time, with status queued (waiting) or sending. Once sent, that item is replaced by the server's message (with seq, messageId and createdAt) and its status becomes sent.
If you need to wait until it's sent (for example, to navigate afterward), set waitUntilSent: true. send() then returns the sent message, and throws if sending finally fails:
final sent = await im.messages.send(
SendTarget.group(groupId),
const OutgoingContent.text('会议纪要已上传'),
options: const SendOptions(waitUntilSent: true),
);
print('${sent.seq} ${sent.messageId}');On the call, the SDK first checks everything that doesn't need the network (type, required fields, control characters in strings, size, mention rules). If a check fails it throws right away and the message doesn't enter the send queue. Text messages can't have empty text; newlines are allowed.
Send queue
- Sending offline. You can send with no network or while the connection is down. The message waits in the send queue with status
queuedand goes out once online. The queue is stored in the device's database, so sending continues after the app is closed and restarted. - Background. The system may suspend the app soon after it goes to the background. Unsent messages continue when the app returns to the foreground or next starts.
- Order. Messages in one conversation go out one at a time in the order sent; each waits until the previous one is sent or finally fails. Different conversations send in parallel.
- Rate. The SDK sends at most 4 messages per second per conversation and 8 per second overall, so sending many in a row doesn't hit the server's rate limits.
- Not sent for a long time. Messages still unsent 48 hours after creation stop being retried and are marked failed (
send_abandoned,reasonexpired), leaving the user to decide whether to resend. Change the time withoutbox: OutboxOptions(maxAge: ...)when creating the client. - Before signing out.
im.hasPendingWorkistruewhile there are unsent messages or unfinished uploads, so you can warn the user:
if (im.hasPendingWork && !await confirm(context, '还有消息没有发出,确定退出吗?')) return;
await im.auth.logout();Failures and resending
On network errors, timeouts and temporary server failures, the SDK retries the same message automatically (after 1, 2, 4, 8, 16 and 30 seconds); resubmitting never creates a duplicate. If the network is available and it keeps failing for 2 minutes, it's marked failed. Messages the server rejects (the user is muted, the recipient blocks them, content moderation fails and so on) are marked failed right away and not retried.
Failed messages stay in the list with local.status failed and the reason in local.error, and a MessageSendFailed event is emitted. The user can resend or delete it:
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()keeps the originalclientMsgId, which the server uses to de-duplicate. Messages with attachments whose upload failed are uploaded again from the start.- Unknown send status. A failed message with
local.statusUnknowntruemay actually have been sent (the app changed its rules while it was retrying). You can show "Send status unknown". The SDK checks after the next sync and swaps in the server's message if it was sent. - Common reasons:
user_muted(the user is muted),group_muted(muted in the group:reasonmembermeans the user is muted,allmeans the owner muted everyone;details['muted_until']is when it ends),not_group_member(no longer in the group),user_blocked(the recipient blocks the user),not_friend(the app requires being friends to chat),message_rejected(content moderation failed; whenreasonisapp_rejected,messageis the text your server returned),not_found(the recipient doesn't exist or was deleted),payload_too_large(over the message size limit). For all error codes, see Events and errors and the server's Error codes.
Send images
Pass the image's local path (for example from an image picker or the camera) to send() as a DRFile. The SDK processes and uploads it, then sends:
try {
await im.messages.send(SendTarget.conversation(conversationKey), OutgoingContent.image(DRFile(pickedPath)));
} on DRException catch (e) {
showToast(e.code); // 调用时的检查不通过,如服务端没有开通文件服务(unsupported)
}- While uploading. The message appears in the list right away with
local.statusuploadingand upload progress from 0 to 1 inlocal.progress. You can show a local preview withImage.file()meanwhile. It's sent automatically once the upload finishes. - Processing. Images are compressed by default (the long side scaled to 2048 pixels or less, rotated upright by orientation), and metadata such as location is removed. HEIC becomes JPEG; GIFs are left alone.
OutgoingContent.image(file, original: true)sends the original, only removing metadata. For compression settings and rules, see Files and images. - Sent as a file instead. Images over 20 MB or 50 megapixels, formats that can't be processed, and voice or video in formats the server doesn't accept are sent as file messages instead, and the message's
typebecomesfile. - The SDK fills in
url,thumbnail_url,width,height,sizeandformatinbody. - Keeping attachments. Attachments up to 200 MB are copied and kept by the SDK when you call
send(), so you can delete the original (such as the image picker's temporary file) oncesend()returns, and uploads continue after a restart. For larger files the SDK only records the path; keep the original until the message is sent, or it fails (send_abandoned,reasonattachment_lost). Change the limit withoutbox: OutboxOptions(persistAttachmentMaxBytes: ...)when creating the client. - For content in memory (such as a screenshot), use
DRFile.fromBytes(bytes, name: 'screenshot.png').
To upload a file without sending a message, or to download and show images, see Files and images.
Send voice
Record with VoiceRecorder from deeprespond_im_flutter. When it stops you get the recording file and its duration (AAC in an M4A container, playable on every platform):
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() is the same as OutgoingContent.voice(DRFile(recording.path, mimeType: 'audio/mp4'), duration: recording.duration). Voice messages must have a duration; the SDK rounds it up to whole seconds, at least 1. Voice files can't exceed 5 MB. VoiceRecorder only supports iOS and Android and throws unsupported on desktop. On iOS, describe microphone use in Info.plist (NSMicrophoneUsageDescription); see Overview and installation.
Send video
await im.messages.send(SendTarget.conversation(conversationKey), OutgoingContent.video(DRFile(videoPath)));The SDK reads the video's duration, width and height, takes the first frame as a cover and uploads it separately, and writes all of it into body. Video formats the server doesn't accept are sent as file messages instead.
Send files
await im.messages.send(SendTarget.conversation(conversationKey), OutgoingContent.file(DRFile(filePath), name: '季度报告.pdf'));name is the file name shown; if omitted it's taken from the path. The file can't exceed the app's upload limit (runtime config max_upload_bytes); larger files throw local_validation (reason too_large, details['max_bytes'] is the limit). A path that doesn't exist throws local_validation (reason not_found).
Send attachments that are already uploaded
If the file is already uploaded (for example with im.files.upload(), or it's on your own file server), give its body directly with OutgoingContent.uploaded() and nothing is uploaded:
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,
}),
);The type is one of image, voice, video and file. url is required; width, height, size and duration must be non-negative integers. If the app only allows this service's file URLs (media_url_only), external URLs are rejected; see Message format.
Send a location
await im.messages.send(
SendTarget.conversation(conversationKey),
const OutgoingContent.location(latitude: 39.9087, longitude: 116.3975, name: '天安门', address: '北京市东城区'),
);Latitude must be between -90 and 90 and longitude between -180 and 180; otherwise it throws local_validation (reason invalid_value).
Send custom messages
The body of a custom message is any JSON object whose structure you define, such as an order card, a contact card or a business notice:
await im.messages.send(
SendTarget.conversation(conversationKey),
const OutgoingContent.custom({'card': 'order', 'order_id': '8812', 'title': '你的订单已发货'}),
);Strings can't contain control characters (including newlines). Users can also send cards from the client that look like "transfer" or "order" cards, so recipients should only trust such messages when local.trusted is true, meaning your server sent them.
Mentions
Group messages can mention members or everyone. Write the "@Name" text in the message yourself; the SDK only submits 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),
);- Only for group chats; in one-to-one chats it throws
local_validation(reasonmentions_not_allowed). - Up to 20 people per message; more throws
local_validation(reasontoo_many_items). Users who aren't group members are ignored. - A regular member mentioning everyone throws
permission_denied(reasonmention_all_denied).
Mentioned users see "Someone mentioned you" in the conversation list; see Conversations.
Quote replies
await im.messages.send(
SendTarget.conversation(conversationKey),
const OutgoingContent.text('同意'),
options: SendOptions(replyToSeq: quoted.seq),
);Only the quoted message's seq is sent. The quoted message must be in the same conversation and not recalled; otherwise the server returns invalid_argument (reason invalid_reply).
Other options
Other fields of SendOptions:
| Option | Description |
|---|---|
ext | Your own extension fields (a JSON object), stored and returned as is, such as a business ID or where a message was forwarded from |
needReceipt | Ask for group read receipts; group chats only. See Group read receipts |
excludeFromUnread | Don't count toward the recipient's unread count; good for business messages that don't need an alert |
pushDisabled | When true, no offline push is sent to recipients. Default null, which follows the server's default |
waitUntilSent | false (default) returns once queued; true returns once sent |
A message's type, body, ext, mentions and reply_to together can't exceed the app's size limit (runtime config max_message_body_bytes); larger messages throw local_validation (reason too_large).
Message someone you have no conversation with
Send directly to a user with SendTarget.user(), or open the one-to-one chat first with 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('你好,我是张三'));When two users don't have a conversation yet, the message goes into the draft conversation draft:lisi, and its conversationId is null. Once the first message is sent, the server creates the conversation and the SDK replaces the draft with it: open lists switch over automatically, their conversationKey becomes the new conversation ID, and the MessagesChanged event carries renamedFrom (the old draft key). If the recipient doesn't exist, was deleted, or is the user themselves, the send fails (not_found or invalid_argument).
Forward
OutgoingContent.forward() sends a message's type and content to someone else as a new message, reusing the original attachment URLs:
await im.messages.send(
SendTarget.user('wangwu'),
OutgoingContent.forward(message),
options: SendOptions(ext: {'forwarded_from': message.messageId}),
);Group notices, call records, and recalled or erased messages can't be forwarded and throw local_validation. Attachments expire based on when they were uploaded, so forwarded copies can't be downloaded once they expire. For merged forwarding of several messages, define your own custom message.
Online-only messages
Real-time signals such as "the other person is recording" or whiteboard strokes don't need to be stored. Send them with sendOnline(): they go only to the recipient's devices online right now, aren't stored or counted as unread, and devices that are offline never get them, not even later.
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}');
});- Only the
customtype. They don't enter the send queue and aren't retried on failure: with no network the call throws right away. - The recipient's
MessageOnlineevents are already de-duplicated by sender andclientMsgId, and the messages don't appear in the message list. - For "typing…", use
im.conversations.typing(); see Conversations.
Receive new messages
Open message lists, the conversation list and unread counts all update automatically, so you usually don't need to handle new-message events. To alert the user while the app is in the foreground (an in-app banner, a sound), listen for MessageReceived:
im.on<MessageReceived>().listen((e) {
if (e.notify.notify) {
showToast('${e.notify.title}:${e.notify.body}');
}
});- It's only emitted for messages from others received live over the connection. It isn't emitted for messages the user sent from other devices, or for messages from while offline (filled in by sync).
notifyis the SDK's decision on whether to alert, based on push settings, Do Not Disturb and mentions. Whennotifyisfalse,reasonsays why (such asmutedordnd). See Alerts, Do Not Disturb and reports.- When the app is in the background or closed, new messages are announced by system push notifications; see Push notifications.
To follow message changes in a conversation (new messages, recalls, reactions, send status), use the MessagesChanged event:
im.on<MessagesChanged>().listen((e) {
// upserted:新增或变化的消息序号;removed:删除的序号;pending:发送队列中变化的 clientMsgId
print('${e.conversationKey} ${e.upserted} ${e.removed} ${e.pending} ${e.renamedFrom}');
});When a message the user sent is replaced by the server's message, the same event carries its clientMsgId in pending and its new seq in upserted, which you can use to keep the scroll position.
Recall
if (im.messages.canRecall(message)) {
await im.messages.recall(message.conversationId!, message.seq!);
}canRecall() applies the server's rules to decide whether to show a "Recall" menu item:
- Group notices, call records and system messages can't be recalled. Messages still in the send queue can't be recalled either (delete them with
discard()). - In one-to-one chats, users can only recall their own messages, within the app's recall window (runtime config
recall_window_seconds, 120 seconds by default). - In groups, users can recall their own messages within the window. The owner can recall anyone's messages at any time, and admins can recall regular members' messages.
- Messages can't be recalled in a group the user has left; the server rejects with
permission_denied(read_only_membership).canRecall()doesn't check this, so also check that the conversation'sstate.membershipismember.
After a recall the message becomes recalled for everyone: recalled is {by, role, at} and the content is cleared. Use renderRecall() to show "You recalled a message", "Zhang San recalled a message" or "Zhang San recalled a message from Li Si" (when the owner or an admin recalled someone else's). When your server or content moderation recalled it, role is server or platform; show "A message was recalled". Past the window, the server rejects with permission_denied (reason recall_window_expired); without permission, recall_denied.
Editing
The Flutter SDK doesn't edit messages. Your server can edit text and custom messages (see Recall, edit and pin). After an edit, message lists on the client update to the new content automatically and edited is {at, count}, so you can show "Edited".
Delete messages
deleteForMe() deletes these messages for the user only. The other person and other group members aren't affected, and the user's other devices delete them too:
await im.messages.deleteForMe(conversationId, [101, 102, 105]);Up to 100 messages at a time. There's no delete-for-everyone; to stop others seeing a message, recall it. Delete queued messages that haven't been sent with discard().
Reactions
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}');- The app must turn on reactions (runtime config
message_reaction_enabled); otherwise calls throwpermission_denied(reasonreaction_disabled). - Your client defines reaction
keys (such asthumbs_uporheart); keep them the same on every platform. Each message can have up tomax_reactions_per_messagekinds; beyond that, calls reject withlimit_exceeded(reaction_limit). - Group notices and recalled messages can't be reacted to. In a group the user has left, they can't react or view who reacted (
read_only_membership). - A return of
falsemeans it was already in that state. Others' reactions update the message list live over the connection.
Pinned messages
await im.messages.setPinned(message.conversationId!, message.seq!, true);
// 消息列表的快照中有这个会话的置顶消息,按置顶时间倒序
for (final item in list.snapshot.pinned) {
print('${item.seq} ${item.pinnedBy} ${item.message?.body ?? '加载中'}');
}- In one-to-one chats both people can pin. In groups only the owner and admins can; others get
permission_denied(reasonpin_denied). - Each conversation can have up to
max_pinned_messages_per_conversationpinned messages; beyond that, calls reject withlimit_exceeded(pinned_message_limit). - The pinned list is refreshed from the server when the message list opens. Pinned messages whose content hasn't been fetched yet have
nullmessage, and the snapshot updates once it arrives. When a pinned message is tapped, go to it withjumpTo(item.seq).
Read receipts
One-to-one
You don't need to query read status in one-to-one chats. peerReadSeq in the conversation state is how far the other person has read: messages the user sent with seq up to it show as "Read". It updates live when the other person marks read (the conversation list and ConversationsChanged events update with it).
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 ? '已读' : '未读'}');
}
}When the app hasn't turned on one-to-one read receipts (runtime config single_read_ack_enabled), don't show read status. Decide from the config, not from whether peerReadSeq is null.
Group
Group read receipts only work for messages sent with needReceipt: true, and only the sender can see them, while still a group member. The app must turn on group read receipts (group_read_ack_enabled); otherwise sending throws 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(): the SDK merges calls for the same conversation within 50 milliseconds, skips messages queried in the last 10 seconds, and requests 20 at a time, so you can call it for each message as it scrolls into view. Messages that can't be viewed (not sent by the user, or no receipt requested) come back with onlyseqandcode, without affecting the others. If the user has left the group, it throwspermission_denied(receipt_denied).getReceipt()synchronously reads the latest read count held in memory;nullif never queried.- In groups of up to 500 members, the server pushes new counts when members read your message, and the SDK updates them and emits
ReceiptsChanged. In larger groups you can only query. - Queries for receipts, receipt user lists and reaction user lists together are limited to 60 per user per minute, so don't make a separate request for each message while scrolling.
Find messages
By seq. When a message mentioned by a quote, a pin or a notification isn't in the list, fetch it from the server with lookup() (it's also stored on the device):
final result = await im.messages.lookup(conversationId, [3, 57]);
print(result.items.map((m) => m.seq));
print(result.missing); // 不存在、已过期或本人看不到的序号Search local messages. The server doesn't search messages. searchLocal() finds text messages stored on the device by keyword (case-insensitive), newest first:
final results = await im.messages.searchLocal(keyword: '季度报告', limit: 20);
// 用户点击一条结果时,打开会话并定位到这条消息
MessageList onResultTap(MessageView m) => im.messages.open(m.conversationId!, aroundSeq: m.seq);It can only find messages stored on the device (from conversations that were opened or synced). Pass conversationId to search one conversation.
Display helpers
package:deeprespond_im_flutter/render.dart provides functions that build display text, plus a table of Chinese strings. They're all optional; you can write your own instead.
import 'package:deeprespond_im_flutter/render.dart';| Function | Use | Example output |
|---|---|---|
displayName(im, username, groupId: ...) | A user's display name: the first non-empty of friend remark, group nickname (when groupId is given), nickname, username | 李四 |
conversationTitle(im, view) | A conversation's title: the other person's display name for one-to-one chats, the group name for groups | 产品讨论组 |
summarize(message, nameOf: ...) | The preview of the last message in the conversation list | [图片], [文件] 报告.pdf |
renderTip(message, nameOf: ...) | Group notice text | 张三邀请李四、王五加入了群聊 |
renderCallRecord(message, self: ...) | Call record text | 视频通话 通话时长 03:12, 未接来电 |
renderRecall(message, self: ..., nameOf: ...) | Recall notice | 你撤回了一条消息 |
describeError(error) | A Chinese message for an error code | 你已被禁言 |
Display names
displayName(im, username, groupId: groupId) returns the name to show for a user: the first non-empty of the friend remark the user set, their group nickname in this group (when groupId is given and the group's member profiles are on the device), their nickname, and their username. Leave out groupId outside groups.
import 'package:deeprespond_im_flutter/render.dart';
final inChat = displayName(im, 'lisi');
final inGroup = displayName(im, 'lisi', groupId: '99582580415791104');displayName()returns synchronously. If the user's profile isn't on the device, it returns the username first and fetches the profile in the background, then emitsUsersChanged; rebuild to show the nickname. Changes to friend remarks, group nicknames and group names emit their events too (FriendsChanged,GroupMembersChanged,GroupsChanged).- The
nameOfparameter turns usernames into display names, usually(u) => displayName(im, u, groupId: groupId). selfis the user's own username: the user shows as "你" (you) in group notices and recall notices, and call records show "已取消" (cancelled) or "对方已取消" (cancelled by the other person) depending on who started the call.- Deleted users (
nullusername) show as "已删除的用户" (deleted user). Unknown group notices show "[群提示]" and unknown message types are summarized as "[消息]".
Replace strings
Strings come from zhCN (of type DRLocale). Every function takes a named locale parameter. Use copyWith() to replace some entries, or switch to another language entirely:
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]'The groups in copyWith() (errors, summary, recall, tip, call) are merged by key, and entries you don't give keep their Chinese text. Placeholders such as {name} are kept as is; formatTemplate(template, values) fills them in. For describeError(error, locale: ...), see Events and errors.
API reference
Methods of im.messages. Except for open(), canRecall() and getReceipt(), they all return a Future and throw DRException on failure: not_signed_in when not signed in, and network_error or timeout for network errors. For error codes, see Events and errors and the server's Error codes.
im.messages.open()
Opens a conversation's message list. Each call returns a new list; call its dispose() when you're done.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationKey | String | Yes | Conversation key: conversation ID, group ID, or draft:{username} for a draft conversation |
aroundSeq | int? | No | Named. Position the list around this seq |
Returns: MessageList; see MessageList.
Errors: throws not_signed_in or local_validation (reason required) synchronously.
im.messages.send()
Sends a message: checks it and puts it in the send queue, processing and uploading any file first.
| Parameter | Type | Required | Description |
|---|---|---|---|
to | SendTarget | Yes | Who it goes to; see SendTarget |
content | OutgoingContent | Yes | Message type and content; see OutgoingContent |
options | SendOptions | No | Named; see SendOptions |
Returns: Future<MessageView>. By default, the local message once queued (seq is null); with waitUntilSent: true, the server's message once sent.
Errors:
- On the call:
local_validation(reasonrequired,control_characters,invalid_value,invalid_body,too_large,mentions_not_allowed,receipt_not_allowed,too_many_items,unknown_type,url_not_allowedornot_found;details['field']names the field),permission_denied(mention_all_denied,read_ack_disabled),unsupported(media_disabled: the server has no file service),invalid_state(peer_deleted: the other person in a one-to-one chat was deleted),not_signed_in. - With
waitUntilSent: true, also send failures, the same aserrorinMessageSendFailed.
im.messages.resend()
Resends a failed message. Does nothing if the message isn't failed.
| Parameter | Type | Required | Description |
|---|---|---|---|
clientMsgId | String | Yes | The failed message's clientMsgId |
Returns: Future<void>, completing once it's back in the send queue.
Errors: local_validation (reason not_found: no such message in the send queue).
im.messages.discard()
Deletes a message from the send queue (failed or still waiting) and cancels its unfinished upload. Only affects this device.
| Parameter | Type | Required | Description |
|---|---|---|---|
clientMsgId | String | Yes | The message's clientMsgId |
Returns: Future<void>.
im.messages.sendOnline()
Sends an online-only custom message.
| Parameter | Type | Required | Description |
|---|---|---|---|
to | SendTarget | Yes | Who it goes to |
body | Map<String, Object?> | Yes | Named. Message body |
ext | Map<String, Object?>? | No | Named. Extension fields |
Returns: Future<OnlineResult>; see OnlineResult.
Errors: local_validation, network_error, and the server's send rejections (the same as for regular messages).
im.messages.recall()
Recalls a message.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Conversation ID |
seq | int | Yes | The message's seq |
Returns: Future<bool>; false if it was already recalled.
Errors: permission_denied (recall_window_expired, recall_denied, read_only_membership), not_found.
im.messages.canRecall()
Synchronously checks whether the user can recall this message, to decide whether to show the recall option.
| Parameter | Type | Required | Description |
|---|---|---|---|
message | MessageView | Yes | The message |
Returns: bool.
im.messages.deleteForMe()
Deletes messages for the user only.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Conversation ID |
seqs | List<int> | Yes | Message seqs, up to 100; an empty list does nothing |
Returns: Future<void>.
Errors: local_validation (reason too_many_items), not_found.
im.messages.react()
Adds a reaction.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Conversation ID |
seq | int | Yes | The message's seq |
key | String | Yes | Reaction key |
Returns: Future<bool>; false if the user had already reacted.
Errors: permission_denied (reaction_disabled, message_recalled, read_only_membership), limit_exceeded (reaction_limit), invalid_argument (invalid_reaction_key), user_blocked, not_friend, local_validation (reason required: empty key).
im.messages.unreact()
Removes the user's reaction. Same parameters as react(); returns Future<bool>, false if the user hadn't reacted.
im.messages.reactionUsers()
Lists the users who reacted with a key, page by page.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Conversation ID |
seq | int | Yes | The message's seq |
key | String | Yes | Reaction key |
cursor | String? | No | Named. nextCursor from the previous page |
limit | int? | No | Named. Items per page; default 20, max 100 |
Returns: Future<DRPage<ReactionUser>>. Each item in items has username, nickname, avatarUrl and reactedAt; deleted users have null username. There's no next page when nextCursor is null.
Errors: permission_denied (read_only_membership), rate_limited, not_found.
im.messages.setPinned()
Pins or unpins a message.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Conversation ID |
seq | int | Yes | The message's seq |
pinned | bool | Yes | true to pin, false to unpin |
Returns: Future<bool>; false if it was already in that state.
Errors: permission_denied (pin_denied, message_recalled, read_only_membership), limit_exceeded (pinned_message_limit), not_found.
im.messages.lookup()
Fetches messages from the server by seq and stores them on the device.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Conversation ID |
seqs | List<int> | Yes | seqs; more than 100 are requested in batches |
Returns: Future<LookupResult>: items (List<MessageView>) are the messages found, and missing (List<int>) are seqs that don't exist, have expired or the user can't see.
Errors: not_found (the conversation doesn't exist or the user can't see it), network_error.
im.messages.searchLocal()
Searches text messages stored on the device. All parameters are named.
| Parameter | Type | Required | Description |
|---|---|---|---|
keyword | String | Yes | Keyword, matched as a case-insensitive substring |
conversationId | String? | No | Only search this conversation |
limit | int | No | Maximum results; default 50 |
Returns: Future<List<MessageView>>, newest first.
Errors: local_validation (reason required: empty keyword; invalid_value: limit less than 1), storage_error.
im.messages.receipts()
Queries read counts for group messages.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Group conversation ID |
seqs | List<int> | Yes | seqs of messages the user sent with receipts requested |
Returns: Future<List<ReceiptCount>>, one per item in seqs; see ReceiptCount.
Errors: permission_denied (receipt_denied: the user has left the group), rate_limited.
im.messages.receiptUsers()
Lists the users who have or haven't read a group message, page by page.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Group conversation ID |
seq | int | Yes | The message's seq |
status | ReceiptStatus | Yes | Named. ReceiptStatus.read for readers, ReceiptStatus.unread for those who haven't read |
cursor | String? | No | Named. nextCursor from the previous page |
limit | int? | No | Named. Items per page; default 20, max 100 |
Returns: Future<DRPage<ReceiptUser>>. Each item in items has username, nickname and avatarUrl.
Errors: permission_denied (receipt_denied), rate_limited, not_found.
im.messages.getReceipt()
Synchronously reads the latest read count held in memory. ReceiptsChanged is emitted when it changes.
| Parameter | Type | Required | Description |
|---|---|---|---|
conversationId | String | Yes | Group conversation ID |
seq | int | Yes | The message's seq |
Returns: ReceiptCount?; null if never queried.
MessageList
The message list returned by im.messages.open().
| Member | Description |
|---|---|
conversationKey | The list's conversation key; becomes the new conversation ID once a draft is replaced by a real conversation |
snapshot | The current snapshot; see MessageListSnapshot |
stream | Stream<MessageListSnapshot>: emits the current snapshot on subscribe, then a new one on every change; closes when disposed |
loadOlder() | Loads the 50 older messages; returns Future<void>. Returns immediately when there are no older messages or a load is in progress |
loadNewer() | Loads downward from a jumped-to position; returns Future<void>. Returns immediately when already at the latest message |
loadGap(gap) | Loads a skipped gap; returns Future<void>. Does nothing for rejoin gaps |
jumpTo(seq) | Positions the list around this seq; returns Future<void> |
markRead() | Moves the read position to the latest message; requests are batched |
setViewing({bool? atBottom, bool? visible}) | Reports whether the user is at the latest message and whether the list is on screen; omitted values don't change |
dispose() | Disposes the list; afterward loadOlder() and the other methods throw invalid_state (reason disposed) |
disposed | Whether the list is disposed |
Load methods don't throw on failure; the error goes in the snapshot's error.
Events
| Event | Fields | Description |
|---|---|---|
MessagesChanged | conversationKey, upserted (List<int>), removed (List<int>), pending (List<String>), renamedFrom (String?) | A conversation's messages changed |
MessageReceived | message (MessageView), notify (NotifyDecision) | A new message from someone else arrived live |
MessageSendFailed | clientMsgId, conversationKey, error (DRException) | Sending finally failed |
MessageOnline | message (OnlineMessage) | An online-only message arrived |
ReceiptsChanged | conversationId, seqs (List<int>) | Read counts of group messages changed |
NotifyDecision has notify (bool, whether to alert), title and body (the alert's title and text), and reason (why not to alert: dnd, quiet_hours, muted, not_mentioned, excluded, self, tip, call or not_loaded).
render.dart
Import from package:deeprespond_im_flutter/render.dart. The im parameter is the client created by DRClient.create(); the message parameter is a MessageView or Message.
| Function | Parameters | Returns |
|---|---|---|
displayName(im, username, {groupId, locale}) | username: String?; groupId: use the group nickname in this group | String |
conversationTitle(im, view, {locale}) | view: ConversationView | String |
summarize(message, {required nameOf, self, locale}) | message: needs at least type, body, sender, sender_type and recalled | String. Text messages give their text (runs of whitespace collapsed to one space), recalled messages give the recall notice, and call records give the call text when self is given |
renderTip(message, {required nameOf, self, locale}) | message: a group notice | String |
renderCallRecord(message, {required self, locale}) | message: a call record | String |
renderRecall(message, {required self, required nameOf, locale}) | message: a recalled message | String; empty if not recalled |
describeError(error, {locale}) | error: DRException | String, a message chosen by error code and reason |
formatTemplate(template, values) | values: Map<String, String> | String, with placeholders such as {name} replaced by values from values |
nameOf is of type NameOf, which is String Function(String username). locale is of type DRLocale and defaults to zhCN.
| Export | Description |
|---|---|
zhCN | The Chinese string table: errors (error messages), summary (previews), recall (recall notices), tip (group notices), call (call records), plus you, deletedUser, unnamedGroup, system and mutedUntil |
DRLocale | The type of string tables; copyWith() replaces entries, merging each group by key |
Types
Each type is a typed view of the server's JSON: common fields have camelCase getters, and the raw JSON is in raw (unmodifiable). Times are UTC DateTimes.
Message
The server's message object; for field meanings, see Message format.
| Field | Type | Description |
|---|---|---|
conversationId | String | Conversation ID |
conversationType | String | single or group |
seq | int | Number within the conversation |
messageId | String | Message ID, unique across the app |
clientMsgId | String? | De-duplication ID set when sending |
sender | String? | The sender's username; null for system messages or when the sender was deleted |
senderType | String | user or system |
recipient | String? | The recipient in a one-to-one chat |
type | String | Message type: text, image, voice, video, file, location, custom, tip, call; more may be added |
body | Map<String, Object?>? | Message body; null when recalled or erased |
ext | Map<String, Object?>? | Extension fields |
mentions | Map<String, Object?>? | Who is mentioned: usernames, all |
replyTo | Map<String, Object?>? | The quoted message: seq, sender |
needReceipt | bool | Whether group read receipts were requested |
excludeFromUnread | bool | Whether it's left out of unread counts |
reactions | List<Object?> | Reactions: one {key, count, users, reacted} per key, where users are the first 3 users who reacted (null for deleted users). Empty list if none |
pinned | Map<String, Object?>? | Pin info: by, at |
edited | Map<String, Object?>? | Edit info: at, count |
recalled | Map<String, Object?>? | Recall info: by, role, at. role is sender, owner, admin, server or platform |
erased | bool | Whether it was erased |
via | String | How it was written: client, openapi, console, system |
createdAt | DateTime? | Send time |
messageVersion | int | Change version of the message |
MessageView
Messages in the message list and returned by send(): the fields of Message plus local state local. MessageView has no excludeFromUnread getter; read it from raw['exclude_from_unread'] when needed.
| Field | Type | Description |
|---|---|---|
| Fields of Message | See the table above | |
seq, messageId, createdAt | int?, String?, DateTime? | null for messages in the send queue |
conversationId | String? | null for unsent messages to a draft conversation (a one-to-one chat with no server conversation yet) |
local.status | String | sent, queued (waiting), uploading, sending or failed |
local.progress | double? | Upload progress from 0 to 1; only while uploading |
local.error | DRException? | Why sending failed |
local.statusUnknown | bool | A failed message may actually have been sent |
local.reacted | Map<String, bool> | Whether the user reacted with each key |
local.unsupported | bool | Unknown message type |
local.trusted | bool | via isn't client, so it wasn't sent by a user from a client |
MessageListSnapshot
| Field | Type | Description |
|---|---|---|
items | List<MessageView> | Ascending by seq, with queued messages last |
gaps | List<Gap> | Ranges not loaded; see Gap |
hasOlder | bool | There are older messages above |
hasNewer | bool | Opened at a message and not yet scrolled to the latest |
loading | MessageListLoading | none, initial, older, newer |
pinned | List<PinnedItem> | Pinned messages; see PinnedItem |
error | DRException? | The error from the last load |
Gap
| Field | Type | Description |
|---|---|---|
fromSeq | int | First seq (inclusive) |
toSeq | int | Last seq (inclusive) |
kind | String | skipped: skipped while catching up after being offline, can be loaded. rejoin: from before the user rejoined, can't be loaded |
PinnedItem
| Field | Type | Description |
|---|---|---|
seq | int | The message's seq |
pinnedBy | String? | Who pinned it; null when your server did |
pinnedAt | DateTime? | When it was pinned |
message | MessageView? | The message; null until its content is fetched |
SendTarget
| Constructor | Description |
|---|---|
SendTarget.conversation(String conversationKey) | A conversation key, including draft:{username} for draft conversations |
SendTarget.user(String username) | The recipient's username; becomes a draft conversation if there's no conversation yet |
SendTarget.group(String groupId) | Group ID |
OutgoingContent
| Constructor | Description |
|---|---|
OutgoingContent.text(String text) | Text; can't be empty |
OutgoingContent.image(DRFile file, {bool original = false}) | An image the SDK processes and uploads; original sends the original |
OutgoingContent.voice(DRFile file, {required Duration duration}) | Voice the SDK uploads; the duration is rounded up to whole seconds (at least 1) |
OutgoingContent.video(DRFile file) | A video the SDK processes and uploads |
OutgoingContent.file(DRFile file, {String? name}) | A file the SDK uploads; name is the file name shown |
OutgoingContent.uploaded(String type, Map<String, Object?> body) | An attachment that already has a URL; type is image, voice, video or file, and body['url'] is required |
OutgoingContent.location({required double latitude, required double longitude, String? name, String? address}) | A location |
OutgoingContent.custom(Map<String, Object?> body) | A custom message |
OutgoingContent.forward(MessageView message) | Forward a message |
DRFile
| Constructor | Description |
|---|---|
DRFile(String path, {String? mimeType, String? name}) | Path of a local file |
DRFile.fromBytes(Uint8List bytes, {required String name, String? mimeType}) | Content in memory; the SDK writes it to a file before uploading |
SendOptions
Create with const SendOptions(...); all fields are optional named parameters.
| Field | Type | Description |
|---|---|---|
ext | Map<String, Object?>? | Extension fields |
mentionUsernames | List<String>? | Users to mention; group chats only, up to 20 |
mentionAll | bool | Mention everyone; group chats only. Default false |
replyToSeq | int? | seq of the quoted message |
needReceipt | bool | Request group read receipts; group chats only. Default false |
excludeFromUnread | bool | Don't count toward recipients' unread counts. Default false |
pushDisabled | bool? | true turns off offline push for this message; null follows the default |
waitUntilSent | bool | Whether send() waits until the message is sent. Default false |
ReceiptCount
| Field | Type | Description |
|---|---|---|
seq | int | The message's seq |
readCount | int? | Number who have read it; null when it can't be viewed |
unreadCount | int? | Number who haven't; null when it can't be viewed |
code | String? | Error code when it can't be viewed, such as permission_denied (receipt_denied) or not_found |
details | Map<String, Object?>? | Details when it can't be viewed |
OnlineMessage
The message in a MessageOnline event.
| Field | Type | Description |
|---|---|---|
messageId | String | Message ID; only for troubleshooting |
clientMsgId | String | ID generated by the sender |
conversationType | String | single or group |
conversationId | String? | Conversation ID; null when the two users have no conversation yet |
sender | String? | Sender |
senderType | String | user or system |
recipient | String? | The recipient in a one-to-one chat |
groupId | String? | Group ID in a group chat |
type | String | Always custom |
body | Map<String, Object?> | Message body |
ext | Map<String, Object?>? | Extension fields |
via | String | How it was written |
createdAt | DateTime? | Send time |
OnlineResult
| Field | Type | Description |
|---|---|---|
messageId | String | Message ID; only for troubleshooting |
clientMsgId | String | ID generated by the SDK |
createdAt | DateTime? | Send time |
onlineOnly | bool | Always true |
