主题
消息与会话 API
1. 消息发送与状态
| api-id | 用途 | 主要输入 | 返回 |
|---|---|---|---|
message.send_text | 发送文字 | conversationID, 可选 clientMessageID, text | XHIMSendReceipt |
message.send_custom | 发送任意标准/自定义消息 | conversationID, 可选 clientMessageID, XHIMOutgoingMessage | XHIMSendReceipt |
message.retry | 重试一条失败的 Outbox 消息 | clientMessageID | XHIMSendReceipt |
message.cancel | 取消一条尚未被服务端接受的消息 | clientMessageID | XHIMSendReceipt |
sendText 是 text/plain@1 的便利接口。其他类型统一通过 XHIMOutgoingMessage:
| 字段 | 约束 |
|---|---|
contentType | 稳定命名空间,例如 com.customer.order.card |
contentVersion | 该类型自己的正整数版本 |
payload | 不可变二进制载荷;不能放本地路径或临时下载 URL |
fallbackText | 旧客户端、通知和会话预览可安全显示的文本 |
deliveryPolicy | 默认 durable;可设为服务端权威的阅后即焚策略 |
成功返回表示消息意图已被本地可靠 Outbox 接受,不等于对方已经收到。最终状态 从消息查询结果的 state 和 messageStateChanged 事件判断。
消息详情页的 Renderer 选择顺序是:
- 根据
contentType + contentVersion找到强类型或业务自定义解码器; - 解码 payload 并渲染图片、视频、语音、文件、位置、名片或业务卡片;
- 仅在类型未知、版本未知或解码失败时使用
fallbackText降级。
不能因为 fallbackText 非空就把消息当成文字。媒体工厂会有意写入 [图片]、 [视频] 等摘要,以便通知和旧客户端可读;它不改变实际消息类型。
clientMessageID 省略时由 SDK 生成。业务需要跨进程幂等重试时,应自行生成 UUID,并在每次重试中复用同一个值。
1.1 阅后即焚
XHIM 的阅后即焚不依赖单台设备的本地计时器删除。发送时的不可变策略会进入 SQLite Outbox 和协议字段;服务端观察到已读水位后安排过期,再通过同步事件 让各设备收敛。有效时长为 1 秒到 30 天。
swift
let secret = XHIMOutgoingMessage(
contentType: "text/plain",
contentVersion: 1,
payload: Data("阅后即焚的消息".utf8),
fallbackText: "[阅后即焚消息]",
deliveryPolicy: .burnAfterRead(milliseconds: 30_000)
)
_ = try await client.sendMessage(
conversationID: conversationID,
message: secret
)1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
kotlin
val secret = XHIMOutgoingMessage(
contentType = "text/plain",
contentVersion = 1,
payload = "阅后即焚的消息".encodeToByteArray(),
fallbackText = "[阅后即焚消息]",
deliveryPolicy = XHIMMessageDeliveryPolicy.BurnAfterRead(30_000),
)
client.sendMessage(conversationId, secret)1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
ts
await client.sendCustomMessage(conversationId, {
contentType: 'text/plain',
contentVersion: 1,
payload: new TextEncoder().encode('阅后即焚的消息'),
fallbackText: '[阅后即焚消息]',
deliveryPolicy: { kind: 'burnAfterRead', milliseconds: 30_000 }
})1
2
3
4
5
6
7
2
3
4
5
6
7
其他端使用同一语义,不需要自己拼协议字段:
| 平台 | 阅后即焚策略 |
|---|---|
| Windows | XHIMMessageDeliveryPolicy.BurnAfterRead(30_000) |
| HarmonyOS | { kind: 'burnAfterRead', milliseconds: 30_000 } |
| Flutter | XHIMMessageDeliveryPolicy.burnAfterRead(Duration(seconds: 30)) |
| Electron | { kind: 'burnAfterRead', milliseconds: 30_000 } |
| Web | { kind: 'burnAfterRead', milliseconds: 30_000 } |
C ABI 使用 xhim_v1_message_input_t 尾部的 lifecycle_kind 和 burn_after_read_ms。这是加法扩展:旧版结构体或 lifecycle_kind == 0 继续解释为持久消息。草稿不携带生命周期;策略只在真正发送时生效。
当前“选择文件→持久上传任务→自动入队”的媒体便利接口仍默认为 durable。需要阅后即焚媒体时,先完成媒体上传并取得服务端 MediaRef, 再用携带 deliveryPolicy 的 sendMessage 发送该媒体信封。不要把本地 路径放进消息 payload。
1.2 位置、红包、转账和企业卡片
这些都是标准强类型消息,接收端根据 contentType 解码,不能按 fallbackText 当普通文字显示:
| 内容类型 | Apple 解码器 | 用途 |
|---|---|---|
xhim.message.location@1 | XHIMLocationContent | 经纬度、地点名称和地址 |
xhim.message.red-packet@1 | XHIMRedPacketContent | 红包业务单据引用 |
xhim.message.transfer@1 | XHIMTransferContent | 转账业务单据引用 |
xhim.message.enterprise-card@1 | XHIMEnterpriseCardContent | 审批、任务、公告、日程 |
swift
switch message.contentType {
case XHIMLocationContent.contentType:
let location = try XHIMLocationContent.decode(message: message)
renderLocation(
latitude: location.latitude,
longitude: location.longitude,
title: location.name,
address: location.address
)
case XHIMRedPacketContent.contentType:
renderRedPacket(try XHIMRedPacketContent.decode(message: message))
case XHIMTransferContent.contentType:
renderTransfer(try XHIMTransferContent.decode(message: message))
case XHIMEnterpriseCardContent.contentType:
renderWorkflow(try XHIMEnterpriseCardContent.decode(message: message))
default:
renderFallback(message.fallbackText)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
位置卡片不强制购买方接入第三方地图 SDK。iOS/macOS 可以用系统 MapKit 的 MKMapSnapshotter 按经纬度生成静态地图,并用 MKMapItem.openInMaps 打开 系统地图;这不需要腾讯/高德地图 Key。只有需要跨平台统一 POI 搜索、路线规划、 导航样式或指定地图数据供应商时,才由宿主注入第三方地图实现。
红包和转账消息不承载支付能力,金额必须用 amountMinor(例如人民币“分”) 表示,不能使用浮点数。packetID / transferID 只引用购买方钱包服务中已经 鉴权、幂等和审计的业务单据,消息里的状态只是发送时快照。
1.3 Markdown 消息
Markdown 使用跨端一致的 text/markdown@1 标准信封。它只是消息内容格式, 不会改变 Outbox、同步、撤回、搜索或离线读取语义:
swift
let markdown = try XHIMStandardMessageFactory.markdown(
source: "## 发布说明\n\n- 已完成灰度验证",
plainTextFallback: "发布说明:已完成灰度验证"
)
_ = try await client.sendMessage(
conversationID: conversationID,
message: markdown
)1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
接收端用 XHIMMarkdownContent.decode(message:)(Android/HarmonyOS 为 decode,Windows 为 Decode)读取源文本。Flutter、Electron 和 Web 同样 提供标准工厂与解码器。SDK 不渲染 Markdown;宿主必须使用禁用原始 HTML、 脚本 URL 和未授权远程资源的安全 Renderer。Renderer 不可用或解码失败时, 直接显示 fallbackText,不得把源文本交给 WebView 执行。
1.4 群聊 @成员与 @所有人
@ 必须使用 xhim.message.mention@1,不能只发送一段看起来含有 @昵称 的 普通文本。显示名可以重复或变更,通知路由必须依赖稳定的内部 userID:
swift
let outgoing = try XHIMStandardMessageFactory.mention(
text: "@小博 请查看发布计划",
mentionedUserIDs: [member.userID],
mentionAll: false,
displayFallback: "@小博 请查看发布计划"
)
_ = try await client.sendMessage(
conversationID: groupConversationID,
message: outgoing
)1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
管理员发送 @所有人 时使用 mentionAll: true;此时用户 ID 列表可以为空:
swift
let outgoing = try XHIMStandardMessageFactory.mention(
text: "@所有人 今天 18:00 前提交周报",
mentionedUserIDs: [],
mentionAll: true,
displayFallback: "@所有人 今天 18:00 前提交周报"
)1
2
3
4
5
6
2
3
4
5
6
接收端应解码后判断当前账号是否被提醒,不要解析显示文字:
swift
let mention = try XHIMMentionContent.decode(message: message)
if mention.mentions(userID: currentUserID) {
showMentionIndicator()
}
renderText(mention.displayText)1
2
3
4
5
2
3
4
5
服务端会从经过校验的 Protobuf payload 派生提醒对象。被指定或被 mentionAll 命中的成员,即使该会话已开启消息免打扰,仍会收到这一条离线 Push;未命中的其他成员继续遵守免打扰。会话未读数和 Tab 红色角标仍按服务端 已读水位计算,不得因为 notificationsMuted 在客户端过滤未读数。
UIKit 输入栏建议把选择结果保存为带 userID 的原子 attributed token。用户 删除 token 的任意一部分时应删除整个 token 和对应 ID,避免界面文字与实际提醒 对象分离。XHIM UIKit Demo 的参考实现位于 Features/Chats/XHChatMention.swift 与 ChatComposerView.swift。
2. 编辑、撤回和仅自己删除
| api-id | 用途 | 主要输入 | 返回 |
|---|---|---|---|
message.edit_text | 编辑文字消息 | conversationID, serverMessageID, mutationID, expectedRevision, text | 更新后的 XHIMMessage |
message.recall | 撤回消息 | conversationID, serverMessageID, mutationID, expectedRevision | 更新后的 XHIMMessage |
message.delete_for_self | 仅当前账号删除 | conversationID, serverMessageID, mutationID, expectedRevision | XHIMMessageVisibilityMutation |
expectedRevision == 0 表示创建型/未指定 CAS 基线,其他值表示只在服务端当前 revision 与之相等时执行。发生并发冲突时重新查询目标消息或可见性,再让用户 决定是否重试;不要自动不断增加条件绕过冲突。
编辑和撤回会保留稳定消息身份,mutationKind 分别变为 editText 和 recall。业务 UI 应根据模型显示“已编辑”或“已撤回”,不要自行删除时间线项。
撤回提示推荐使用无头像、无气泡的居中系统样式。当前用户撤回文字时,宿主可在 发起撤回前将原文保存在当前账号私有草稿中,显示“你撤回了一条消息 重新编辑”; 点击只恢复输入框。服务端撤回 payload 不保留原文,非文字消息不支持重新编辑。
2.1 服务端系统事件
群生命周期事件使用 Server 独占的 xhim.system.event@1:
| kind | 语义 | 默认文案示例 |
|---|---|---|
groupCreated | 创建群并邀请初始成员 | A邀请B、C加入群聊 |
membersInvited | 邀请成员 | A邀请B加入群聊 |
memberRemoved | 移除成员 | B已被A移出群聊 |
memberLeft | 成员主动退出 | A已经退出群聊 |
groupDismissed | 群解散(预留) | 由产品策略决定 |
事件 payload 包含 kind、actor、targets 和服务端生成的 displayText。 Apple 接收端调用:
swift
let event = try XHIMSystemEventContent.decode(message: message)
renderCenteredSystemText(event.displayText)1
2
2
普通 message.send 禁止该 content type,防止客户端伪造群事件。C++ Core 不 解释系统事件,只负责可靠传输、离线同步、顺序和持久化;各平台 SDK 根据同一 协议 schema 提供强类型解码。
2.1 复制、引用、逐条转发、合并转发和多选
这些能力分为“SDK 消息合同”和“宿主 UI 操作”两层:
- 复制只读取可显示文字并写入系统剪贴板,不需要调用 Core;
- 引用使用
XHIMStandardMessageFactory.quote(...),只保存原消息的serverMessageID、发送者和不超过 4096 UTF-8 字节的安全摘要; - 逐条转发把原消息不可变的
contentType、contentVersion、payload和fallbackText重新封装为XHIMOutgoingMessage,再对目标会话调用sendMessage;不能把媒体本地路径写进新消息; - 合并转发把最多 100 条消息转换为
XHIMForwardItem,再调用XHIMStandardMessageFactory.mergedForward(...); - 多选是 UI 状态。选中项必须按
clientMessageID保存,列表刷新或向上分页后 按 ID 恢复选择,不能依赖会变化的IndexPath。
swift
// 引用回复
let quote = try XHIMStandardMessageFactory.quote(
sourceServerMessageID: sourceServerMessageID,
sourceSenderUserID: source.senderUserID,
sourcePreview: sourcePreview,
text: replyText
)
_ = try await client.sendMessage(
conversationID: conversationID,
message: quote
)
// 逐条转发
let forwarded = XHIMOutgoingMessage(
contentType: source.contentType,
contentVersion: source.contentVersion,
payload: source.payload,
fallbackText: source.fallbackText
)
_ = try await client.sendMessage(
conversationID: targetConversationID,
message: forwarded
)
// 合并转发
let items = selectedMessages.prefix(100).map {
XHIMForwardItem(
senderUserID: $0.senderUserID,
senderDisplayName: resolvedName(for: $0.senderUserID),
sentAtMilliseconds:
$0.serverSentAtMilliseconds ??
$0.localCreatedAtMilliseconds,
contentType: $0.contentType,
contentVersion: $0.contentVersion,
payload: $0.payload,
fallbackText: $0.fallbackText
)
}
let merged = try XHIMStandardMessageFactory.mergedForward(
title: "聊天记录",
items: Array(items)
)
_ = try await client.sendMessage(
conversationID: targetConversationID,
message: merged
)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Apple 接收端使用公开强类型解码器,不要手写 Protobuf 字段:
swift
switch message.contentType {
case XHIMQuoteContent.contentType:
let quote = try XHIMQuoteContent.decode(message: message)
renderReply(text: quote.text, sourcePreview: quote.sourcePreview)
case XHIMMergedForwardContent.contentType:
let merged = try XHIMMergedForwardContent.decode(message: message)
showForwardDetail(title: merged.title, items: merged.items)
default:
break
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
转发仍走普通可靠 Outbox,因此没有单独的“转发成功”回调。sendMessage 成功只 表示新消息已入队;最终状态仍由 messageStateChanged 后重查消息确认。引用和 合并转发只保存必要快照,源消息后续编辑、撤回或仅自己删除不会重写已发送快照。
建议长按菜单按“复制、编辑/重试/取消、删除、收藏、撤回、转发、引用、多选” 排序。菜单应挂在气泡/卡片内容视图上:使用系统 context menu 时以该视图生成 targeted preview;使用图标宫格时以该视图作为 popover 的 sourceView/sourceRect。不要使用 TableView 整行菜单,否则会高亮整块 Cell。 私聊、群聊、角色和撤回时限属于上层权限策略,可以在这套基础消息状态策略之外 再做过滤。
3. 单条、分页和搜索
| api-id | 用途 | 主要输入 | 返回 |
|---|---|---|---|
message.get | 按客户端消息 ID 获取单条 | clientMessageID | XHIMMessage |
message.list | 读取本地会话消息页 | conversationID, 可选 cursor, limit | XHIMMessagePage |
message.list_after | 读取锚点之后的消息 | conversationID, anchorClientMessageID, limit | XHIMMessagePage |
message.context | 读取锚点前后上下文 | conversationID, anchorClientMessageID, olderLimit, newerLimit | XHIMMessagePage |
message.insert_local | 插入仅本设备可见的时间线消息 | conversationID, senderUserID, XHIMOutgoingMessage | XHIMLocalMessageResult |
message.set_local_extension | 写入不同步的本地扩展字段 | clientMessageID, bytes | XHIMLocalMessageResult |
message.get_local_metadata | 读取消息及本地扩展字段 | clientMessageID | XHIMLocalMessageResult |
message.delete_local | 只在当前设备隐藏一条消息 | clientMessageID | XHIMLocalMessageResult |
message.delete_batch_local | 一次隐藏多条本地消息 | 1...100 个不重复 clientMessageID | XHIMLocalMessageResult |
message.delete_all_local | 只在当前设备隐藏全部/某会话消息 | 可选 conversationID | XHIMLocalMessageResult |
message.batch_get_local | 按服务端消息 ID 批量读取本地投影 | conversationID, serverMessageIDs | [XHIMMessage] |
message.history | 按服务端序号读取历史 | conversationID, 可选 continuation, limit | XHIMMessageHistoryPage |
message.search | 搜索本地消息投影 | XHIMMessageSearchQuery, 可选 cursor, limit | XHIMMessagePage |
messages 用于常规本地时间线和游标翻页。getMessageHistory 使用稳定服务端 序号 continuation,适合清空/隐藏视图规则和历史回溯。两种 continuation 都是 SDK 所有,不允许用页码或时间戳代替。
XHIMMessageSearchQuery 可按文本、会话、发送者、内容类型和时间范围组合过滤。 搜索结果仍是 XHIMMessage,未知自定义类型也保留原 payload 与 fallback。
3.1 设备本地消息和本地删除
message.insert_local 用于导入旧记录、本地提示或宿主业务卡片。它直接写入 当前账号的加密 SQLite 投影,不会创建 Outbox,不会经过 WebSocket/HTTP, 也不会在其他设备出现。clientMessageID 由 SDK 生成时以 xhim-local: 开头; 各端 Facade 接受宿主自带的非空后缀并自动加上该保留前缀,防止与可同步 消息冲突。直接使用 C ABI 时必须传完整的 xhim-local: 命名空间 ID。
localExtension 是 SDK 不解析的二进制字段,只存在当前设备,不参与 payload 哈希、协议编码或服务端审核。它适合放置渲染状态、导入来源或宿主索引, 不能放凭证、明文密钥或只存这一份的业务数据。
message.delete_local / message.delete_all_local 使用本地 tombstone 隐藏数据: 不向服务端发送删除,不改变其他设备,不能代替 message.delete_for_self。 删除后会产生 messageLocallyDeleted 投影失效事件,页面应重查当前时间线, 不要仅按 IndexPath 直接删数组。deletedCount 是本次实际新增隐藏的条数。
message.context 一次返回锚点之前、锚点本身和之后的稳定窗口,适合引用跳转; message.list_after 返回紧随锚点的可见消息并按时间升序排列,适合继续追赶新 消息。如需继续,以本页最后一条的 clientMessageID 再次调用。批量本地删除在 同一 SQLite 事务内验证全部 ID 后提交,不会出现只删一半的状态。
Browser Web Lite 使用按 appId/accountId/selfUserId 隔离的 IndexedDB 投影。 同步事件和 cursor 在同一个 transaction 中提交;写失败时 cursor 不前进。Web 公开 localMessages、会话分组和会话置顶消息读取,但仍不把 IndexedDB 冒充 原生 SQLCipher:浏览器密钥与磁盘加密强度取决于浏览器和操作系统。
message.batch_get_local 专用于引用跳转、合并转发预览和已知消息卡片回填:
- 一次传入 1...100 个非空且不重复的
serverMessageID; - 结果严格保持输入顺序;本地尚未同步、已被当前账号删除或不属于该会话的 ID 会被省略,调用方可用返回消息的
serverMessageID计算未命中集合; - 查询只读取当前账号 Epoch 下的加密 SQLite 权威投影,不发网络请求,也不返回 cursor;如需补齐未同步历史,先调用
message.history,再重试本地批量查询; - Browser Web Lite 当前没有 SQLite Core,因此不提供这个本地 API。不要把它伪装成 服务端批量查询;Electron、HarmonyOS 和 Flutter 原生端均通过同一 Core 实现。
分页通用规则:
- 首次调用传空 cursor / continuation;
nextCursor或continuation为空表示结束;- cursor 只在同一账号、同一查询条件下复用;
- 收到投影变更后刷新第一页,不能假定旧 cursor 是实时订阅;
limit必须处于各 Facade 声明的范围;聊天 UI 推荐首屏 40、向上翻页 20, 会话列表推荐 50;message.list返回最新优先的分页结果;聊天 UI 按localOrder升序显示, 旧页插入顶部时必须保持当前可视位置;- 初次展示应无动画定位到最新消息,禁止先渲染最老记录再动画滚到底部。
4. 会话列表与单聊
| api-id | 用途 | 主要输入 | 返回 |
|---|---|---|---|
conversation.list | 读取本地会话页 | 可选 cursor, limit | XHIMConversationPage |
conversation.direct | 获取或创建稳定单聊会话 | peerUserID | XHIMDirectConversation |
conversation.clear | 为当前账号清空到指定序号 | conversationID, throughServerSequence, mutationID, expectedRevision | XHIMConversationViewMutation |
conversation.hide | 隐藏会话当前视图 | conversationID, mutationID, expectedRevision | XHIMConversationViewMutation |
conversation.set_folder | 创建、更新、隐藏或删除会话分组 | folder 字段、会话 ID、mutationID, expectedRevision | XHIMConversationFolder |
conversation.list_folders | 读取当前账号的会话分组 | 无 | [XHIMConversationFolder] |
conversation.set_pinned_message | 置顶或取消置顶会话内消息 | conversationID, serverMessageID, pinned, CAS 字段 | XHIMConversationPinnedMessage |
conversation.list_pinned_messages | 读取会话内有效置顶消息 | conversationID | [XHIMConversationPinnedMessage] |
请优先用 directConversation(peerUserID) 获取单聊 ID,不要在客户端拼接 alice_bob。服务端返回的 participant 和 conversation ID 才是跨端合同。
清空和隐藏是当前账号的 Server-authoritative 视图操作,不会替其他成员删除 消息。clearConversation 的 throughServerSequence 必须来自已查询消息或会话 高水位。
会话分组是当前账号私有、跨设备同步的组织方式;服务端不会让其他成员看到分组 名称。分组中的会话 ID 必须去重;服务端会转换为确定性排序的权威快照, 客户端不得把成员数组的输入顺序当作产品排序。会话内置顶消息属于会话 治理数据,使用 server message ID 和 revision CAS;被删除、撤回或对当前账号 不可见的消息不能继续作为有效置顶项。两类变更都通过 Sync 投影通知页面重查。
5. 已读、未读和对端已读
| api-id | 用途 | 主要输入 | 返回 |
|---|---|---|---|
conversation.mark_read | 提交一个会话的已读水位 | conversationID, throughServerSequence | XHIMConversationReadReceipt |
conversation.mark_all_read | 幂等标记全部会话已读 | mutationID, maxChangedReads | XHIMMarkAllConversationsReadResult |
conversation.hide_all | 原子隐藏当前全部非空会话 | mutationID | XHIMHideAllConversationsResult |
conversation.total_unread | 查询本地总未读 | 无 | 64 位整数 |
conversation.peer_reads | 查询成员已读投影 | conversationID, limit | XHIMConversationPeerReadPage |
group_message.mark_read | 批量提交群消息已读 | conversationID, 1~100 个 serverMessageID | XHIMConversationReadReceipt / Flutter void |
group_message.readers | 按群消息查已读成员 | conversationID, 1~100 个 serverMessageID, peerLimit | XHIMGroupMessageReadPage |
group_message.moderate_member_history | 群主/管理员治理某成员的历史消息 | conversationID, targetUserID, throughServerSequence, mutationID, expectedRevision | XHIMGroupMemberMessageModerationResult |
已读水位只前进不后退。聊天页进入前台并确定用户确实看见消息后,再提交当前 最大 serverSequence。收到 conversationReadChanged 或 conversationChanged 后重查会话,而不是在 UI 中直接对未读数做加减。
markAllConversationsRead 的结果可能因保护上限只带一部分 changedReads; changedReadsTruncated == true 或 localProjectionComplete == false 时刷新整个 会话列表。
markGroupMessagesRead 和 groupMessageReaders 是现有权威水位的高层 Facade,不会另建一套“逐消息已读表”。SDK 先按服务端消息 ID 从当前账号的本地投影解析序号:
- 批量上报取所有目标中最大
serverSequence,调用单调的markConversationRead;因此该序号之前的消息也必然已读。 - 读者查询只读一次
conversationPeerReads,对每条消息筛选readServerSequence >= message.serverSequence的成员。 - 当前用户不在 peer readers 内;
readCount是对方/其他群成员人数。 - 任一消息尚未进入本地投影或没有服务端序号时整批报错; 先同步/拉取历史,不得用缺失项计算偏小的已读数。
peerReadsTruncated == true 表示命中了参与者上限,当前 readCount 只是已投影的下界;大群应增大 peerLimit 或对产品界面显示 “1000+”,不要把截断结果当成精确总数。
moderateGroupMemberMessages 不物理删除消息,而是写入服务端权威的 sender-watermark 墓碑:只隐藏目标成员发送且 serverSequence <= throughServerSequence 的消息。throughServerSequence = 0 会在服务端事务中固定为当前会话高水位,之后新发的消息仍正常 可见。历史、搜索、未读、会话摘要以及迟到/重放的旧消息都使用同一 墓碑过滤,不得只在当前 UI 删除 Cell。
权限矩阵是服务端强制合同:普通成员无权操作;管理员只能治理普通 成员;群主可治理管理员和普通成员,但群主不能被治理。重试必须复用 同一 mutationID,已有墓碑继续向后扩展时携带返回的 revision, 服务端会拒绝回退水位和过期 CAS。
hideAllConversations 不是客户端遍历会话逐个调用隐藏接口。服务端在一个事务中 把每个非空会话隐藏到各自的最新 serverSequence,并写入幂等回执和同步事件。 结果只返回变更数量,因此 localProjectionComplete 固定为 false;收到 Sync 事件后从本地投影刷新列表。隐藏后到达的新消息序列高于隐藏水位时,会话自动重新 出现。该操作只改变当前账号的视图,不删除消息,也不影响其他成员。
6. 会话偏好与本地草稿
| api-id | 用途 | 主要输入 | 返回 |
|---|---|---|---|
conversation.set_preference | 原子全量替换置顶、免打扰和会话业务扩展 | conversationID, isPinned, notificationsMuted, applicationExtensionJSON, mutationID, expectedRevision | XHIMConversationPreference |
conversation.set_message_retention | 原子设置当前账号的滚动消息保留窗口 | conversationID, retentionSeconds, mutationID, expectedRevision | XHIMConversationPreference |
conversation.set_draft | 保存账号隔离本地草稿 | conversationID, XHIMOutgoingMessage | XHIMConversationDraft |
conversation.get_draft | 读取草稿 | conversationID | XHIMConversationDraft |
conversation.clear_draft | 清除草稿 | conversationID | XHIMConversationDraft |
偏好是当前账号私有、可同步的服务端权威状态,使用 mutation ID 和 exact revision CAS。一次 set_preference 是对 isPinned、 notificationsMuted 和 applicationExtensionJSON 的完整替换,不存在 “未传就保留旧扩展”的部分替换语义。重试同一个语义必须复用 mutationID 和 expectedRevision;复用 mutation ID 却改变任何一个 偏好值会返回冲突。
applicationExtensionJSON 是最大 16 KiB、最大嵌套 64 层的 UTF-8 JSON object;数组、标量、null、非法 UTF-8、超限或带尾随内容都会被 拒绝。空 bytes/空字符串表示清除。服务端会返回规范化后的 JSON,因此客户端 只能比较语义,不能假设响应原始字节与请求完全相同。扩展中不得放置 Token、密钥、密码、消息正文或其他审计日志不应记录的秘密。
messageRetentionSeconds 是账号私有策略:0 关闭今后的自动过期, 否则只允许 60...315360000 秒。Server 把过期结果收敛为既有 ConversationView.clearedThroughServerSequence 权威水位,因此历史、搜索、 未读、会话摘要和迟到重放都使用同一过滤。这不会物理删除 共享消息,不影响其他成员;放宽或关闭策略也不会复活已清理历史。 读取结果中缺少字段代表旧 Core/Server 未投影,权威 0 才代表关闭。
Native C ABI 为保持已发布会话 snapshot 的连续数组步长,不直接 扩展 xhim_v1_conversation_snapshot_t。在线列表、搜索与离线 page 使用同下标 application_extensions 并行数组;本地精确批量则从 item 的 conversation_application_extension_json 读取。这些 byte view 都遵循原 callback/page 的借用生命期,Facade 必须及时深拷贝。 写入或清除该扩展时,Native Wrapper 必须调用 capability-fenced xhim_v1_client_set_conversation_preference_with_extension;旧 Core 不存在 该符号时应明确返回 unsupported,不得退回可能忽略新尾字段的 历史入口。
草稿是本机、当前账号的本地状态,不上传、不产生聊天消息,也不要把密码、 Token 或支付敏感信息放入草稿 payload。
7. 典型聊天页
swift
eventToken = client.addEventListener { [weak self] event in
switch event {
case .messageUpserted(let change),
.messageStateChanged(let change):
if change.conversationID == conversationID {
self?.reloadFirstPage()
}
default:
break
}
}
client.messages(
conversationID: conversationID,
limit: 50,
onSuccess: { page in
self.messages = page.messages
},
onFailure: { error in
self.showError(error.message)
}
)
client.sendText(
conversationID: conversationID,
text: composer.text,
onSuccess: { _ in
composer.clear()
},
onFailure: { error in
self.showError(error.message)
}
)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
页面销毁时取消监听 token,但不要因为一个页面消失就关闭账号级 Client。 事件的完整平台写法和线程要求见事件与回调。