|
| 1 | +# MSC3771: Read receipts for threads |
| 2 | + |
| 3 | +## Background |
| 4 | + |
| 5 | +Currently, each room can only have a single receipt of each type per user. The |
| 6 | +read receipt ([`m.read`](https://spec.matrix.org/v1.3/client-server-api/#receipts) |
| 7 | +or [`m.read.private`](https://github.com/matrix-org/matrix-spec-proposals/pull/2285)) |
| 8 | +is used to sync the read status of a room across clients, to share with other |
| 9 | +users which events have been read, and is used by the homeserver to calculate the |
| 10 | +number of unread messages. |
| 11 | + |
| 12 | +Now that [MSC3440](https://github.com/matrix-org/matrix-doc/pull/3440) has merged |
| 13 | +to add support for threads, there are two ways to display messages: |
| 14 | + |
| 15 | +* *Unthreaded*: The traditional way of displaying messages before threads existed. |
| 16 | + All messages are just shown in the order they’re provided by the server as a |
| 17 | + single timeline[^1]. |
| 18 | +* *Threaded*: Taking into account the `m.thread` and other relations to separate |
| 19 | + a room DAG into multiple sub-timelines: |
| 20 | + * One timeline for each root message (I.e. the target of a thread relation) |
| 21 | + * One for messages which are not part of a thread: the main timeline. |
| 22 | + |
| 23 | +For an example room DAG (solid lines are show topological ordering, dotted lines |
| 24 | +show event relations): |
| 25 | + |
| 26 | +```mermaid |
| 27 | +flowchart RL |
| 28 | + I-->H |
| 29 | + H-->G |
| 30 | + G-->F |
| 31 | + F-->E |
| 32 | + E-->D |
| 33 | + D-->C |
| 34 | + C-->B |
| 35 | + B-->A |
| 36 | + C-.->|m.thread|A |
| 37 | + D-.->|m.thread|B |
| 38 | + E-.->|m.thread|A |
| 39 | + F-.->|m.thread|B |
| 40 | + G-.->|m.reaction|C |
| 41 | + H-.->|m.edit|E |
| 42 | +``` |
| 43 | + |
| 44 | +This can be separated into three threaded timelines: |
| 45 | + |
| 46 | +```mermaid |
| 47 | +flowchart RL |
| 48 | + subgraph "Main" timeline |
| 49 | + B-->A |
| 50 | + I-->B |
| 51 | + end |
| 52 | + subgraph Thread A timeline |
| 53 | + C-->A |
| 54 | + E-->C |
| 55 | + G-.->|m.reaction|C |
| 56 | + H-.->|m.edit|E |
| 57 | + end |
| 58 | + subgraph Thread B timeline |
| 59 | + D-->B |
| 60 | + F-->D |
| 61 | + end |
| 62 | +``` |
| 63 | + |
| 64 | +Due to this separation of messages into separate timelines a single read receipt |
| 65 | +per room causes missed (or flaky) notification counts and does not give an accurate |
| 66 | +representation of what messages have been read by people. |
| 67 | + |
| 68 | +Note that it is expected that some clients will continue to show only an unthreaded |
| 69 | +view of the room, either until they are able to support a threaded view or because |
| 70 | +they do not wish to incorporate threads. |
| 71 | + |
| 72 | +## Proposal |
| 73 | + |
| 74 | +This MSC proposes allowing a receipt per thread, as well as an unthreaded receipt. |
| 75 | +Thus, receipts are split into two categories, which this document calls "unthreaded" |
| 76 | +and "threaded". Threaded receipts are identified by the root message of the thread; |
| 77 | +additionally there is a special pseudo-thread for the main timeline. This allows marking |
| 78 | +the main timeline (a pseudo-thread) as read, without marking any actual threads (split |
| 79 | +off from the main timeline) as read. |
| 80 | + |
| 81 | +The most significant difference between threaded and unthreaded receipts is how |
| 82 | +they clear notifications: |
| 83 | + |
| 84 | +* Unthreaded receipts clear notifications just as they do today (i.e. |
| 85 | + "notifications prior to and including that event MUST be marked as read"). |
| 86 | +* Threaded receipts clear notifications in a similar way, but taking into account |
| 87 | + the thread the receipt is part of (i.e. "notifications generated from events |
| 88 | + with a thread relation matching the receipt’s thread ID prior to and including |
| 89 | + that event which are MUST be marked as read") |
| 90 | + |
| 91 | +Using the above diagrams with threaded read receipts on `E` and `I`; and an |
| 92 | +unthreaded read receipt on `D` would give: |
| 93 | + |
| 94 | +```mermaid |
| 95 | +flowchart RL |
| 96 | + subgraph "Main" timeline |
| 97 | + B-->A |
| 98 | + I-->B |
| 99 | + end |
| 100 | + subgraph Thread A timeline |
| 101 | + C-->A |
| 102 | + E-->C |
| 103 | + G-.->|m.reaction|C |
| 104 | + H-.->|m.edit|E |
| 105 | + end |
| 106 | + subgraph Thread B timeline |
| 107 | + D-->B |
| 108 | + F-->D |
| 109 | + end |
| 110 | +
|
| 111 | + classDef unthreaded fill:yellow,stroke:#333,stroke-width:2px |
| 112 | + classDef threaded fill:crimson,stroke:#333,stroke-width:2px |
| 113 | + classDef both fill:orange,stroke:#333,stroke-width:2px |
| 114 | +
|
| 115 | + %% An unthreaded read receipt on D marks A, B, C, D as read. |
| 116 | + class A,B,C both; |
| 117 | + class D unthreaded; |
| 118 | + %% Threaded read receipts on E and I mark C, E and A, B, I as |
| 119 | + %% read, respectively. |
| 120 | + class E,I threaded; |
| 121 | +``` |
| 122 | + |
| 123 | +As denoted by the colors: |
| 124 | + |
| 125 | +* The unthreaded read receipt on `D` would mark `A`, `B`, `C`, and `D` as read. |
| 126 | +* The threaded read receipt on `E` would mark `C` and `E` as read. |
| 127 | +* The threaded read receipt on `I` would mark `A`, `B`, and `I` as read. |
| 128 | + |
| 129 | +### Threaded receipts |
| 130 | + |
| 131 | +This MSC proposes allowing the same receipt type to exist multiple times in a room |
| 132 | +per user: |
| 133 | + |
| 134 | +* Once for the unthreaded timeline. |
| 135 | +* Once for the main timeline in the room. |
| 136 | +* Once per threaded timeline. |
| 137 | + |
| 138 | +No other changes to receipts are proposed, i.e. this still does not allow a caller |
| 139 | +to move their receipts backwards in a room. The relationship between `m.read` and |
| 140 | +`m.read.private` is not changed. |
| 141 | + |
| 142 | +The request body to the [`/receipt` endpoint](https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3roomsroomidreceiptreceipttypeeventid) |
| 143 | +gains the following optional fields: |
| 144 | + |
| 145 | +* `thread_id` (`string`): The thread that the receipt belongs to (i.e. the |
| 146 | + `event_id` contained within the `m.relates_to` of the event represented by |
| 147 | + `eventId`). |
| 148 | + |
| 149 | + A special value of `"main"` corresponds to the receipt being for the main |
| 150 | + timeline (i.e. events which are not part of a thread). |
| 151 | + |
| 152 | + If this field is not provided then the receipt applies to the unthreaded |
| 153 | + version of the room.[^2] |
| 154 | + |
| 155 | +The following conditions are errors and should be rejected with a `400` error |
| 156 | +with `errcode` of `M_INVALID_PARAM`: |
| 157 | + |
| 158 | +* A non-string `thread_id` (or empty) `thread_id` field. |
| 159 | +* Providing the `thread_id` properties for a receipt of type `m.fully_read`. |
| 160 | +* If the given `event_id` is not related to the `thread_id`. There may be multiple |
| 161 | + relations between events ((e.g. a `m.annotation` to `m.thread`), homeservers |
| 162 | + should apply a reasonable maximum number of relations to traverse when attempting |
| 163 | + to identify if an event is part of a thread. |
| 164 | + |
| 165 | + It is recommended that at least 3 relations are traversed when attempting to find |
| 166 | + a thread, implementations should be careful to not infinitely recurse.[^3] |
| 167 | + |
| 168 | +Given a threaded message: |
| 169 | + |
| 170 | +```json |
| 171 | +{ |
| 172 | + "event_id": "$thread_reply", |
| 173 | + "room_id": "!room:example.org", |
| 174 | + "content": { |
| 175 | + "m.relates_to": { |
| 176 | + "rel_type": "m.thread", |
| 177 | + "event_id": "$thread_root" |
| 178 | + } |
| 179 | + } |
| 180 | +} |
| 181 | +``` |
| 182 | + |
| 183 | +A client could mark this as read by sending a request: |
| 184 | + |
| 185 | +``` |
| 186 | +POST /_matrix/client/r0/rooms/!room:example.org/receipt/m.read/$thread_reply |
| 187 | +
|
| 188 | +{ |
| 189 | + "thread_id": "$thread_root" |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +And to send a receipt on the main timeline (e.g. on the root event): |
| 194 | + |
| 195 | +``` |
| 196 | +POST /_matrix/client/r0/rooms/!room:example.org/receipt/m.read/$thread_root |
| 197 | +
|
| 198 | +{ |
| 199 | + "thread_id": "main" |
| 200 | +} |
| 201 | +``` |
| 202 | + |
| 203 | +As it is today, not providing the `thread_id` field sends an unthreaded receipt: |
| 204 | + |
| 205 | +``` |
| 206 | +POST /_matrix/client/r0/rooms/!room:example.org/receipt/m.read/$thread_reply |
| 207 | +
|
| 208 | +{} |
| 209 | +``` |
| 210 | + |
| 211 | +### Receiving threaded receipts via `/sync`. |
| 212 | + |
| 213 | +The client would receive this as part of `/sync` response similar to other receipts: |
| 214 | + |
| 215 | +```json5 |
| 216 | +{ |
| 217 | + "content": { |
| 218 | + "$thread_reply": { |
| 219 | + "m.read": { |
| 220 | + "@rikj:jki.re": { |
| 221 | + "ts": 1436451550453, |
| 222 | + "thread_id": "$thread_root" // or "main" or absent |
| 223 | + } |
| 224 | + } |
| 225 | + } |
| 226 | + }, |
| 227 | + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", |
| 228 | + "type": "m.receipt" |
| 229 | +} |
| 230 | +``` |
| 231 | + |
| 232 | +If there is no `thread_id` field then the receipt applies to the unthreaded |
| 233 | +timeline. Clients may interpret this as applying only to the main timeline or |
| 234 | +as applying across the main timeline and all threaded timelines. |
| 235 | + |
| 236 | +### Sending threaded receipts over federation |
| 237 | + |
| 238 | +Homeservers should include a `thread_id` field for threaded receipts in the |
| 239 | +[Receipt Metadata](https://spec.matrix.org/v1.3/server-server-api/#receipts) when |
| 240 | +sending the `m.receipt` EDU over federation. Unthreaded receipts lack this field, |
| 241 | +as they do today. |
| 242 | + |
| 243 | +### Notifications |
| 244 | + |
| 245 | +[MSC3773](https://github.com/matrix-org/matrix-spec-proposals/pull/3773) discusses |
| 246 | +how notifications for threads are created and returned to the client, but does |
| 247 | +not provide a way to clear threaded notifications. |
| 248 | + |
| 249 | +A threaded read receipt (i.e. a `m.read` or `m.read.private` receipt with a `thread_id` |
| 250 | +property) should clear notifications for the matching thread following the |
| 251 | +[current rules](https://spec.matrix.org/v1.3/client-server-api/#receiving-notifications), |
| 252 | +but only clear notifications with a matching `thread_id` (as discussed in MSC3773). |
| 253 | +See the examples of the read receipts on `E` and `I` [above](#proposal). |
| 254 | + |
| 255 | +An unthreaded read receipt (i.e. a `m.read` or `m.read.private` receipt *without* |
| 256 | +a `thread_id`) should apply the [current rules](https://spec.matrix.org/v1.3/client-server-api/#receiving-notifications) |
| 257 | +and disregard thread information when clearing notifications. To re-iterate, this |
| 258 | +means it would clear any earlier notifications across *all* threads. This is |
| 259 | +illustrated by the read receipt on event `D` [above](#proposal). |
| 260 | + |
| 261 | +## Potential issues |
| 262 | + |
| 263 | +### Long-lived rooms |
| 264 | + |
| 265 | +For long-lived rooms or rooms with many threads there could be a significant number |
| 266 | +of receipts. This has a few downsides: |
| 267 | + |
| 268 | +* The size of the `/sync` response would increase without bound. |
| 269 | +* The effort to generate and process the receipts for each room would increase |
| 270 | + without bound. |
| 271 | + |
| 272 | +### Compatibility with unthreaded clients |
| 273 | + |
| 274 | +When a user has both a client which is "unthreaded" and "threaded" then there |
| 275 | +is a possibility for read receipts to be misrepresented when switching between |
| 276 | +clients. Using the example room DAG from the preamble of this MSC: |
| 277 | + |
| 278 | +* A user which has an unthreaded receipt on event `D` and a threaded receipt on |
| 279 | + event `E` would likely see event `E` as unread on an "unthreaded" client. |
| 280 | + |
| 281 | +The proposed solution may result in events being incorrectly marked as unread |
| 282 | +(when they have been read). The false positive for unread notifications is |
| 283 | +deliberate to avoid losing message / missing notifications. |
| 284 | + |
| 285 | +Solutions to this problem are deemed out of scope of this MSC. A solution that |
| 286 | +was briefly explored was [ranged read receipts](https://hackmd.io/Gxm8zuuSROeencoJ6gjgSg). |
| 287 | + |
| 288 | +### Federation compatibility |
| 289 | + |
| 290 | +A homeserver which does not understand threaded receipts will be unable to properly |
| 291 | +understand that multiple receipts exist in a room. They will generally be processed |
| 292 | +as unthreaded receipts with the latest receipt winning, regardless of thread. |
| 293 | + |
| 294 | +This could make read receipts of remote users jump between threads, but this should |
| 295 | +not be any worse than it is today. Additionally, since it only affects remote |
| 296 | +users, it will not impact notifications. |
| 297 | + |
| 298 | +## Alternatives |
| 299 | + |
| 300 | +### Thread ID location |
| 301 | + |
| 302 | +Instead of adding the thread ID in the body, it could be provided as part of the |
| 303 | +URL path or as a query parameter. Adding it to the URL (as part of the path or a |
| 304 | +query parameter) would make it difficult to differentiate the receipt's event ID |
| 305 | +field from the thread ID. |
| 306 | + |
| 307 | +Another idea was to encode information for all threads in the single receipt, e.g. |
| 308 | +by adding them to the body of the single read receipt. This could cause data |
| 309 | +integrity issues if multiple clients attempt to update the receipt without first |
| 310 | +reading it. |
| 311 | + |
| 312 | +### Receipt type |
| 313 | + |
| 314 | +To potentially improve compatibility it could make sense to use a separate receipt |
| 315 | +type (e.g. `m.read.thread`) as the read receipt for threads. Without some syncing |
| 316 | +mechanism between unthreaded and threaded receipts this seems likely to cause |
| 317 | +users to re-read the same notifications on threaded and unthreaded clients. |
| 318 | + |
| 319 | +While it is possible to map from an unthreaded read receipt to multiple threaded |
| 320 | +read receipts, the opposite is not possible (to the author's knowledge). In short, |
| 321 | +it seems the [compatibility issues discussed above](#compatibility-with-unthreaded-clients) |
| 322 | +would not be solved by adding more receipt types. |
| 323 | + |
| 324 | +This also gets more complicated with the addition of the `m.read.private` receipt -- |
| 325 | +would there additionally be an `m.read.private.thread`? How do you map between |
| 326 | +all of these? |
| 327 | + |
| 328 | +## Security considerations |
| 329 | + |
| 330 | +There is potential for abuse by allowing clients to specify a unique `threadId`. |
| 331 | +A mitigation could be to ensure that the receipt is related to an event of the |
| 332 | +thread, ensuring that each thread only has a single receipt. |
| 333 | + |
| 334 | +## Future extensions |
| 335 | + |
| 336 | +### Threaded fully read markers |
| 337 | + |
| 338 | +The `m.fully_read` marker is not supported in threads, a future MSC could expand |
| 339 | +support to this pseudo-receipt. |
| 340 | + |
| 341 | +### Setting threaded receipts using the `/read_markers` endpoint |
| 342 | + |
| 343 | +This MSC does not propose expanding the `/read_markers` endpoint to support threaded |
| 344 | +receipts. A future MSC might expand this to support an object per receipt with |
| 345 | +an event ID and thread ID or some other way of setting multiple receipts at once. |
| 346 | + |
| 347 | +## Unstable prefix |
| 348 | + |
| 349 | +To detect server support, clients can either rely on the spec version (when stable) |
| 350 | +or the presence of a `org.matrix.msc3771` flag in `unstable_features` on `/versions`. |
| 351 | + |
| 352 | +## Dependencies |
| 353 | + |
| 354 | +This MSC depends on the following MSCs, which have not yet been accepted into |
| 355 | +the spec: |
| 356 | + |
| 357 | +* [MSC3773](https://github.com/matrix-org/matrix-spec-proposals/pull/3773): Notifications for threads |
| 358 | + |
| 359 | +[^1]: Throughout this document "timeline" is used to mean what the user sees in |
| 360 | +the user interface of their Matrix client. |
| 361 | + |
| 362 | +[^2]: Generally it would be surprising if the same client sent both threaded and |
| 363 | +unthreaded receipts, but it is allowed. The only known use-case for this is that |
| 364 | +a threaded client can use this to clear *all* notifications in a room by sending |
| 365 | +an unthreaded read receipt on the latest event in the room (regardless of which |
| 366 | +thread it appears in). |
| 367 | + |
| 368 | +[^3]: Three relations is relatively arbitrary, but is meant to cover an edit or |
| 369 | +reaction to a thread (to an event with no relations, i.e. the root of a thread): |
| 370 | +`A<--[m.thread]--B<--[m.annotation]--C`. |
| 371 | +With an additional leftover for future improvements. This is considered reasonable |
| 372 | +since threads cannot fork, edits cannot modify relation information, and generally |
| 373 | +annotations to annotations are ignored by user interfaces. |
0 commit comments