docs-matrix-spec/locales/zh-Hans/client-server-api/modules/receipts.md
2025-04-20 16:13:37 +08:00

7.9 KiB
Raw Blame History

回执

{{% changed-in v="1.4" %}} 新增了私有已读回执。

本模块增加了对回执的支持。回执是一种对事件的确认方式。该模块定义了用于表示用户已读至某一事件的 m.read 回执,以及用于相同目的但不会被其他用户察觉的 m.read.private 回执。主要来说,m.read.private 旨在清除通知,而不向他人公开已读状态。

为每个事件发送回执可能导致向主服务器发送大量流量。为了防止这一问题,回执采用“已读至”标记的方式实现。此标记表示确认适用于“至(含)”指定事件的所有事件。例如,将某事件标记为“已读”就意味着用户已经阅读了至此的所有事件。关于已读回执如何影响通知计数,请参见接收通知章节。

{{% added-in v="1.4" %}} 已读回执主要有三种形式:

  • 非线程化:表示已读至某事件的回执,与线程无关。这等同于线程功能引入前的已读回执。
  • 线程化,主时间线:表示对非特定线程事件的已读回执。用线程 ID main 标识。
  • 线程化,特定线程:表示在指定线程内的已读回执。用线程根事件的事件 ID 标识。

有关线程化回执的更多详细信息,请参阅下文

事件

{{% changed-in v="1.4" %}} 每个 user_idreceipt_type 和类别(非线程化,或 thread_id)三元组必须只关联一个 event_id

{{% event event="m.receipt" %}}

客户端行为

{{% changed-in v="1.4" %}} 修订以支持线程化已读回执。

/sync 接口中,回执列在指定房间的 ephemeral 事件数组下。新收到的回执是增量信息,用于更新已有的映射。客户端应依据 user_idreceipt_type 和(如有)thread_id 替换旧的已读回执。例如:

客户端收到 m.receipt
  user = @alice:example.com
  receipt_type = m.read
  event_id = $aaa:example.com
  thread_id = undefined

客户端收到另一个 m.receipt
  user = @alice:example.com
  receipt_type = m.read
  event_id = $bbb:example.com
  thread_id = main

此时客户端尚未替换任何确认。

客户端再次收到 m.receipt
  user = @alice:example.com
  receipt_type = m.read
  event_id = $ccc:example.com
  thread_id = undefined

客户端用新回执 $ccc:example.com 替换之前 $aaa:example.com 的确认,但不会替换 $bbb:example.com因为它属于线程。

客户端再次收到 m.receipt
  user = @alice:example.com
  receipt_type = m.read
  event_id = $ddd:example.com
  thread_id = main

现在客户端用新 $ddd:example.com 的确认替换旧的 $bbb:example.com。客户端不会替换 $ccc:example.com 的旧回执,因为它是未线程化的。

客户端应在确定事件已显示给用户时才发送已读回执。仅仅收到事件并不能确保用户已经看到。用户应当执行某些操作,如查看事件所处房间或关闭通知,事件方可计为“已读”。客户端不应为自己的用户发送已读回执。

与发送回执的规则类似,线程化回执应出现在线程上下文中。如果线程被折叠,客户端尚未向用户展示该事件(或任何相关已读回执)。一旦用户展开线程,就应发送线程化已读回执,并显示来自其他用户的每线程回执。

客户端可通过以下 HTTP API 与其用户相关的回执状态进行更新。

{{% http-api spec="client-server" api="receipts" %}}

私有已读回执

{{% added-in v="1.4" %}}

部分用户希望标记房间为已读,以清除其通知计数,但又不希望暴露自己已经阅读了特定消息。为此,客户端可以发送 m.read.private 回执,作用同 m.read,但不会向其他用户广播回执,从而实现只清除通知不公开已读状态。

服务器不得m.read.private 回执发送给除最初发送用户外的任何其他用户。

m.readm.read.private 两者间,决定最高已读至标记时会采用“更靠前”或“更近期”的回执。关于这对通知计数影响的更多信息,请参见通知章节。

如果客户端发送的 m.read 回执“落后”于 m.read.private 回执,其他用户会看到该变化,但发送用户的通知计数不会回退到那一时间点。尽管不常见,出现 m.read(公开)回执比 m.read.private 回执滞后几条消息的情况也是合法的。

线程化已读回执

{{% added-in v="1.4" %}}

如果客户端未使用线程功能,则只会发送“非线程化”已读回执,无论线程如何都影响整个房间。

线程化回执指的是带有 thread_id 的回执,其目标为线程根事件的事件 ID 或主时间线用 main

线程化引入了在同一房间中进行多次独立会话的概念,因此也对应有独立的已读回执和通知计数。某事件被认为“属于线程”,需满足以下任一条件:

  • rel_typem.thread,或
  • 在事件关系链上,其父事件通过 rel_typem.thread 的方式被关联到线程根。实现时不应无限级递归,建议最多递归 3 级以覆盖间接关系。

房间内未归属于某线程的事件视为主时间线中的事件。当用作线程引用(如回执和通知计数中),主时间线采用特殊线程 ID main

线程根本身被视作主时间线事件,通过非线程关系与线程根相关的事件也被视为主时间线事件。

以下是一个房间的 DAG 示例,虚线表示事件间关系,实线表示拓扑排序。

{{% diagram name="threaded-dag" alt="呈现包含线程关系的单一时间线的DAG图" %}}

该 DAG 可分解为 3 条线程化时间线,其中 AB 为线程根:

{{% diagram name="threaded-dag-threads" alt="呈现包含3条相关线程化时间线的 DAG 图" %}}

据此可说明:

  • I 上的线程化已读回执会标记 ABI 为已读。
  • E 上的线程化已读回执会标记 CE 为已读。
  • D 上的非线程化已读回执会标记 ABCD 为已读。

注意,仅用线程化回执将 A 标记为已读,并不会让 CEGH 也被标记为已读。线程 A 的时间线需在 H 上设置属于该线程的线程化回执才能做到。

上述 3 个例子的回执示例如下:

{
  "$I": {
    "m.read": {
      "@user:example.org": {
        "ts": 1661384801651,
        "thread_id": "main" // 因为 `I` 不在任何线程中,但回执为线程化回执
      }
    }
  },
  "$E": {
    "m.read": {
      "@user:example.org": {
        "ts": 1661384801651,
        "thread_id": "$A" // 因为 `E` 属于线程 `A`
      }
    }
  },
  "$D": {
    "m.read": {
      "@user:example.org": {
        "ts": 1661384801651
        // 无 `thread_id`,因为这是*非线程化*回执
      }
    }
  }
}

发送已读回执的条件在线程化与非线程化场景下适用方式一致。例如,当用户展开某线程时,客户端可能会为该线程事件发送私有已读回执。

服务器行为

出于高效性考虑,应将回执合并打包为按房间和线程分组的事件后再发送给客户端。

部分回执会作为类型为 m.receipt 的 EDU 跨联邦发送。该 EDU 格式为:

{
    <room_id>: {
        <receipt_type>: {
            <user_id>: { <内容(ts & thread_id, 当前支持)> }
        },
        ...
    },
    ...
}

这些均以增量方式相较此前已发送的回执推送。目前仅应使用一个 <receipt_type> m.readm.read.private 不得出现在联邦 m.receipt EDU 内。

安全性注意事项

回执是在事件图之外发送的,因此 m.receipt 事件内容不会进行完整性校验。