Skip to content

消息与会话 API

1. 消息发送与状态

api-id用途主要输入返回
message.send_text发送文字conversationID, 可选 clientMessageID, textXHIMSendReceipt
message.send_custom发送任意标准/自定义消息conversationID, 可选 clientMessageID, XHIMOutgoingMessageXHIMSendReceipt
message.retry重试一条失败的 Outbox 消息clientMessageIDXHIMSendReceipt
message.cancel取消一条尚未被服务端接受的消息clientMessageIDXHIMSendReceipt

sendTexttext/plain@1 的便利接口。其他类型统一通过 XHIMOutgoingMessage

字段约束
contentType稳定命名空间,例如 com.customer.order.card
contentVersion该类型自己的正整数版本
payload不可变二进制载荷;不能放本地路径或临时下载 URL
fallbackText旧客户端、通知和会话预览可安全显示的文本
deliveryPolicy默认 durable;可设为服务端权威的阅后即焚策略

成功返回表示消息意图已被本地可靠 Outbox 接受,不等于对方已经收到。最终状态 从消息查询结果的 statemessageStateChanged 事件判断。

消息详情页的 Renderer 选择顺序是:

  1. 根据 contentType + contentVersion 找到强类型或业务自定义解码器;
  2. 解码 payload 并渲染图片、视频、语音、文件、位置、名片或业务卡片;
  3. 仅在类型未知、版本未知或解码失败时使用 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
)
kotlin
val secret = XHIMOutgoingMessage(
    contentType = "text/plain",
    contentVersion = 1,
    payload = "阅后即焚的消息".encodeToByteArray(),
    fallbackText = "[阅后即焚消息]",
    deliveryPolicy = XHIMMessageDeliveryPolicy.BurnAfterRead(30_000),
)
client.sendMessage(conversationId, secret)
ts
await client.sendCustomMessage(conversationId, {
  contentType: 'text/plain',
  contentVersion: 1,
  payload: new TextEncoder().encode('阅后即焚的消息'),
  fallbackText: '[阅后即焚消息]',
  deliveryPolicy: { kind: 'burnAfterRead', milliseconds: 30_000 }
})

其他端使用同一语义,不需要自己拼协议字段:

平台阅后即焚策略
WindowsXHIMMessageDeliveryPolicy.BurnAfterRead(30_000)
HarmonyOS{ kind: 'burnAfterRead', milliseconds: 30_000 }
FlutterXHIMMessageDeliveryPolicy.burnAfterRead(Duration(seconds: 30))
Electron{ kind: 'burnAfterRead', milliseconds: 30_000 }
Web{ kind: 'burnAfterRead', milliseconds: 30_000 }

C ABI 使用 xhim_v1_message_input_t 尾部的 lifecycle_kindburn_after_read_ms。这是加法扩展:旧版结构体或 lifecycle_kind == 0 继续解释为持久消息。草稿不携带生命周期;策略只在真正发送时生效。

当前“选择文件→持久上传任务→自动入队”的媒体便利接口仍默认为 durable。需要阅后即焚媒体时,先完成媒体上传并取得服务端 MediaRef, 再用携带 deliveryPolicysendMessage 发送该媒体信封。不要把本地 路径放进消息 payload。

1.2 位置、红包、转账和企业卡片

这些都是标准强类型消息,接收端根据 contentType 解码,不能按 fallbackText 当普通文字显示:

内容类型Apple 解码器用途
xhim.message.location@1XHIMLocationContent经纬度、地点名称和地址
xhim.message.red-packet@1XHIMRedPacketContent红包业务单据引用
xhim.message.transfer@1XHIMTransferContent转账业务单据引用
xhim.message.enterprise-card@1XHIMEnterpriseCardContent审批、任务、公告、日程
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)
}

位置卡片不强制购买方接入第三方地图 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
)

接收端用 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
)

管理员发送 @所有人 时使用 mentionAll: true;此时用户 ID 列表可以为空:

swift
let outgoing = try XHIMStandardMessageFactory.mention(
    text: "@所有人 今天 18:00 前提交周报",
    mentionedUserIDs: [],
    mentionAll: true,
    displayFallback: "@所有人 今天 18:00 前提交周报"
)

接收端应解码后判断当前账号是否被提醒,不要解析显示文字:

swift
let mention = try XHIMMentionContent.decode(message: message)
if mention.mentions(userID: currentUserID) {
    showMentionIndicator()
}
renderText(mention.displayText)

服务端会从经过校验的 Protobuf payload 派生提醒对象。被指定或被 mentionAll 命中的成员,即使该会话已开启消息免打扰,仍会收到这一条离线 Push;未命中的其他成员继续遵守免打扰。会话未读数和 Tab 红色角标仍按服务端 已读水位计算,不得因为 notificationsMuted 在客户端过滤未读数。

UIKit 输入栏建议把选择结果保存为带 userID 的原子 attributed token。用户 删除 token 的任意一部分时应删除整个 token 和对应 ID,避免界面文字与实际提醒 对象分离。XHIM UIKit Demo 的参考实现位于 Features/Chats/XHChatMention.swiftChatComposerView.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, expectedRevisionXHIMMessageVisibilityMutation

expectedRevision == 0 表示创建型/未指定 CAS 基线,其他值表示只在服务端当前 revision 与之相等时执行。发生并发冲突时重新查询目标消息或可见性,再让用户 决定是否重试;不要自动不断增加条件绕过冲突。

编辑和撤回会保留稳定消息身份,mutationKind 分别变为 editTextrecall。业务 UI 应根据模型显示“已编辑”或“已撤回”,不要自行删除时间线项。

撤回提示推荐使用无头像、无气泡的居中系统样式。当前用户撤回文字时,宿主可在 发起撤回前将原文保存在当前账号私有草稿中,显示“你撤回了一条消息 重新编辑”; 点击只恢复输入框。服务端撤回 payload 不保留原文,非文字消息不支持重新编辑。

2.1 服务端系统事件

群生命周期事件使用 Server 独占的 xhim.system.event@1

kind语义默认文案示例
groupCreated创建群并邀请初始成员A邀请B、C加入群聊
membersInvited邀请成员A邀请B加入群聊
memberRemoved移除成员B已被A移出群聊
memberLeft成员主动退出A已经退出群聊
groupDismissed群解散(预留)由产品策略决定

事件 payload 包含 kindactortargets 和服务端生成的 displayText。 Apple 接收端调用:

swift
let event = try XHIMSystemEventContent.decode(message: message)
renderCenteredSystemText(event.displayText)

普通 message.send 禁止该 content type,防止客户端伪造群事件。C++ Core 不 解释系统事件,只负责可靠传输、离线同步、顺序和持久化;各平台 SDK 根据同一 协议 schema 提供强类型解码。

2.1 复制、引用、逐条转发、合并转发和多选

这些能力分为“SDK 消息合同”和“宿主 UI 操作”两层:

  • 复制只读取可显示文字并写入系统剪贴板,不需要调用 Core;
  • 引用使用 XHIMStandardMessageFactory.quote(...),只保存原消息的 serverMessageID、发送者和不超过 4096 UTF-8 字节的安全摘要;
  • 逐条转发把原消息不可变的 contentTypecontentVersionpayloadfallbackText 重新封装为 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
)

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
}

转发仍走普通可靠 Outbox,因此没有单独的“转发成功”回调。sendMessage 成功只 表示新消息已入队;最终状态仍由 messageStateChanged 后重查消息确认。引用和 合并转发只保存必要快照,源消息后续编辑、撤回或仅自己删除不会重写已发送快照。

建议长按菜单按“复制、编辑/重试/取消、删除、收藏、撤回、转发、引用、多选” 排序。菜单应挂在气泡/卡片内容视图上:使用系统 context menu 时以该视图生成 targeted preview;使用图标宫格时以该视图作为 popover 的 sourceView/sourceRect。不要使用 TableView 整行菜单,否则会高亮整块 Cell。 私聊、群聊、角色和撤回时限属于上层权限策略,可以在这套基础消息状态策略之外 再做过滤。

3. 单条、分页和搜索

api-id用途主要输入返回
message.get按客户端消息 ID 获取单条clientMessageIDXHIMMessage
message.list读取本地会话消息页conversationID, 可选 cursor, limitXHIMMessagePage
message.list_after读取锚点之后的消息conversationID, anchorClientMessageID, limitXHIMMessagePage
message.context读取锚点前后上下文conversationID, anchorClientMessageID, olderLimit, newerLimitXHIMMessagePage
message.insert_local插入仅本设备可见的时间线消息conversationID, senderUserID, XHIMOutgoingMessageXHIMLocalMessageResult
message.set_local_extension写入不同步的本地扩展字段clientMessageID, bytesXHIMLocalMessageResult
message.get_local_metadata读取消息及本地扩展字段clientMessageIDXHIMLocalMessageResult
message.delete_local只在当前设备隐藏一条消息clientMessageIDXHIMLocalMessageResult
message.delete_batch_local一次隐藏多条本地消息1...100 个不重复 clientMessageIDXHIMLocalMessageResult
message.delete_all_local只在当前设备隐藏全部/某会话消息可选 conversationIDXHIMLocalMessageResult
message.batch_get_local按服务端消息 ID 批量读取本地投影conversationID, serverMessageIDs[XHIMMessage]
message.history按服务端序号读取历史conversationID, 可选 continuation, limitXHIMMessageHistoryPage
message.search搜索本地消息投影XHIMMessageSearchQuery, 可选 cursor, limitXHIMMessagePage

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;
  • nextCursorcontinuation 为空表示结束;
  • cursor 只在同一账号、同一查询条件下复用;
  • 收到投影变更后刷新第一页,不能假定旧 cursor 是实时订阅;
  • limit 必须处于各 Facade 声明的范围;聊天 UI 推荐首屏 40、向上翻页 20, 会话列表推荐 50;
  • message.list 返回最新优先的分页结果;聊天 UI 按 localOrder 升序显示, 旧页插入顶部时必须保持当前可视位置;
  • 初次展示应无动画定位到最新消息,禁止先渲染最老记录再动画滚到底部。

4. 会话列表与单聊

api-id用途主要输入返回
conversation.list读取本地会话页可选 cursor, limitXHIMConversationPage
conversation.direct获取或创建稳定单聊会话peerUserIDXHIMDirectConversation
conversation.clear为当前账号清空到指定序号conversationID, throughServerSequence, mutationID, expectedRevisionXHIMConversationViewMutation
conversation.hide隐藏会话当前视图conversationID, mutationID, expectedRevisionXHIMConversationViewMutation
conversation.set_folder创建、更新、隐藏或删除会话分组folder 字段、会话 ID、mutationID, expectedRevisionXHIMConversationFolder
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 视图操作,不会替其他成员删除 消息。clearConversationthroughServerSequence 必须来自已查询消息或会话 高水位。

会话分组是当前账号私有、跨设备同步的组织方式;服务端不会让其他成员看到分组 名称。分组中的会话 ID 必须去重;服务端会转换为确定性排序的权威快照, 客户端不得把成员数组的输入顺序当作产品排序。会话内置顶消息属于会话 治理数据,使用 server message ID 和 revision CAS;被删除、撤回或对当前账号 不可见的消息不能继续作为有效置顶项。两类变更都通过 Sync 投影通知页面重查。

5. 已读、未读和对端已读

api-id用途主要输入返回
conversation.mark_read提交一个会话的已读水位conversationID, throughServerSequenceXHIMConversationReadReceipt
conversation.mark_all_read幂等标记全部会话已读mutationID, maxChangedReadsXHIMMarkAllConversationsReadResult
conversation.hide_all原子隐藏当前全部非空会话mutationIDXHIMHideAllConversationsResult
conversation.total_unread查询本地总未读64 位整数
conversation.peer_reads查询成员已读投影conversationID, limitXHIMConversationPeerReadPage
group_message.mark_read批量提交群消息已读conversationID, 1~100 个 serverMessageIDXHIMConversationReadReceipt / Flutter void
group_message.readers按群消息查已读成员conversationID, 1~100 个 serverMessageID, peerLimitXHIMGroupMessageReadPage
group_message.moderate_member_history群主/管理员治理某成员的历史消息conversationID, targetUserID, throughServerSequence, mutationID, expectedRevisionXHIMGroupMemberMessageModerationResult

已读水位只前进不后退。聊天页进入前台并确定用户确实看见消息后,再提交当前 最大 serverSequence。收到 conversationReadChangedconversationChanged 后重查会话,而不是在 UI 中直接对未读数做加减。

markAllConversationsRead 的结果可能因保护上限只带一部分 changedReads; changedReadsTruncated == truelocalProjectionComplete == false 时刷新整个 会话列表。

markGroupMessagesReadgroupMessageReaders 是现有权威水位的高层 Facade,不会另建一套“逐消息已读表”。SDK 先按服务端消息 ID 从当前账号的本地投影解析序号:

  1. 批量上报取所有目标中最大 serverSequence,调用单调的 markConversationRead;因此该序号之前的消息也必然已读。
  2. 读者查询只读一次 conversationPeerReads,对每条消息筛选 readServerSequence >= message.serverSequence 的成员。
  3. 当前用户不在 peer readers 内;readCount 是对方/其他群成员人数。
  4. 任一消息尚未进入本地投影或没有服务端序号时整批报错; 先同步/拉取历史,不得用缺失项计算偏小的已读数。

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, expectedRevisionXHIMConversationPreference
conversation.set_message_retention原子设置当前账号的滚动消息保留窗口conversationID, retentionSeconds, mutationID, expectedRevisionXHIMConversationPreference
conversation.set_draft保存账号隔离本地草稿conversationID, XHIMOutgoingMessageXHIMConversationDraft
conversation.get_draft读取草稿conversationIDXHIMConversationDraft
conversation.clear_draft清除草稿conversationIDXHIMConversationDraft

偏好是当前账号私有、可同步的服务端权威状态,使用 mutation ID 和 exact revision CAS。一次 set_preference 是对 isPinnednotificationsMutedapplicationExtensionJSON 的完整替换,不存在 “未传就保留旧扩展”的部分替换语义。重试同一个语义必须复用 mutationIDexpectedRevision;复用 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)
    }
)

页面销毁时取消监听 token,但不要因为一个页面消失就关闭账号级 Client。 事件的完整平台写法和线程要求见事件与回调

XHIM 客户端 SDK 与服务端文档