Threads: Read receipts & notifications (#1255)

* Spec MSC3771: Threaded read receipts

Note: this builds on a (as of writing) non-existent "threading" section, which is part of a different commit.

* Spec MSC3773: Threaded notifications

* changelog

* Various clarifications per review
release/v1.4
Travis Ralston 2 years ago committed by GitHub
parent 25dda1eadb
commit 227757d499
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1 @@
Add per-thread notifications and read receipts, as per [MSC3771](https://github.com/matrix-org/matrix-spec-proposals/pull/3771) and [MSC3773](https://github.com/matrix-org/matrix-spec-proposals/pull/3773).

@ -0,0 +1 @@
Add per-thread notifications and read receipts, as per [MSC3771](https://github.com/matrix-org/matrix-spec-proposals/pull/3771) and [MSC3773](https://github.com/matrix-org/matrix-spec-proposals/pull/3773).

@ -107,8 +107,10 @@ determined by the push rules which apply to an event.
When the user updates their read receipt (either by using the API or by
sending an event), notifications prior to and including that event MUST
be marked as read. Note that users can send both an `m.read` and
`m.read.private` receipt, both of which are capable of clearing notifications.
be marked as read. Which specific events are affected can vary depending
on whether a [threaded read receipt](#threaded-read-receipts) was used.
Note that users can send both an `m.read` and `m.read.private` receipt,
both of which are capable of clearing notifications.
If the user has both `m.read` and `m.read.private` set in the room then
the receipt which is more recent/ahead must be used to determine where
@ -121,6 +123,17 @@ ahead), however if the `m.read.private` receipt were to be updated to
event D then the user has read up to D (the `m.read` receipt is now
behind the `m.read.private` receipt).
{{< added-in v="1.4" >}} When handling threaded read receipts, the server
is to partition the notification count to each thread (with the main timeline
being its own thread). To determine if an event is part of a thread the
server follows the [event relationship](#forming-relationships-between-events)
until it finds a thread root (as specified by the [threading module](#threading)),
however it is not recommended that the server traverse infinitely. Instead,
implementations are encouraged to do a maximum of 3 hops to find a thread
before deciding that the event does not belong to a thread. This is primarily
to ensure that future events, like `m.reaction`, are correctly considered
"part of" a given thread.
##### Push Rules
A push rule is a single rule that states under what *conditions* an

@ -22,33 +22,68 @@ that the user had read all events *up to* the referenced event. See the
[Receiving notifications](#receiving-notifications) section for more
information on how read receipts affect notification counts.
{{< added-in v="1.4" >}} Read receipts exist in three major forms:
* Unthreaded: Denotes a read-up-to receipt regardless of threads. This is how
pre-threading read receipts worked.
* Threaded, main timeline: Denotes a read-up-to receipt for events not in a
particular thread. Identified by the thread ID `main`.
* Threaded, in a thread: Denotes a read-up-to receipt within a particular
thread. Identified by the event ID of the thread root.
Threaded read receipts are discussed in further detail [below](#threaded-read-receipts).
#### Events
Each `user_id`, `receipt_type` pair must be associated with only a
single `event_id`.
{{< changed-in v="1.4" >}} Each `user_id`, `receipt_type`, and categorisation
(unthreaded, or `thread_id`) tuple must be associated with only a single
`event_id`.
{{% event event="m.receipt" %}}
#### Client behaviour
{{< changed-in v="1.4" >}} Altered to support threaded read receipts.
In `/sync`, receipts are listed under the `ephemeral` array of events
for a given room. New receipts that come down the event streams are
deltas which update existing mappings. Clients should replace older
receipt acknowledgements based on `user_id` and `receipt_type` pairs.
receipt acknowledgements based on `user_id`, `receipt_type`, and the
`thread_id` (if present).
For example:
Client receives m.receipt:
user = @alice:example.com
receipt_type = m.read
event_id = $aaa:example.com
thread_id = undefined
Client receives another m.receipt:
user = @alice:example.com
receipt_type = m.read
event_id = $bbb:example.com
thread_id = main
The client does not replace any acknowledgements, yet.
Client receives yet another m.receipt:
user = @alice:example.com
receipt_type = m.read
event_id = $ccc:example.com
thread_id = undefined
The client replaces the older acknowledgement for $aaa:example.com
with this new one for $ccc:example.com, but does not replace the
acknowledgement for $bbb:example.com because it belongs to a thread.
The client should replace the older acknowledgement for $aaa:example.com with
this one for $bbb:example.com
Client receives yet another m.receipt:
user = @alice:example.com
receipt_type = m.read
event_id = $ddd:example.com
thread_id = main
Now the client replaces the older $bbb:example.com acknowledgement with
this new $ddd:example.com acknowledgement. The client does NOT replace the
older acknowledgement for $ccc:example.com as it is unthreaded.
Clients should send read receipts when there is some certainty that the
event in question has been **displayed** to the user. Simply receiving
@ -58,6 +93,12 @@ room that the event was sent to or dismissing a notification in order
for the event to count as "read". Clients SHOULD NOT send read receipts
for events sent by their own user.
Similar to the rules for sending receipts, threaded receipts should appear
in the context of the thread. If a thread is rendered behind a disclosure,
the client hasn't yet shown the event (or any applicable read receipts)
to the user. Once they expand the thread though, a threaded read receipt
would be sent and per-thread receipts from other users shown.
A client can update the markers for its user by interacting with the
following HTTP APIs.
@ -87,6 +128,89 @@ not have their notification counts rewound to that point in time. While
uncommon, it is considered valid to have an `m.read` (public) receipt lag
several messages behind the `m.read.private` receipt, for example.
##### Threaded read receipts
{{% added-in v="1.4" %}}
If a client does not use [threading](#threading), then they will simply only
send "unthreaded" read receipts which affect the whole room regardless of threads.
A threaded read receipt is simply one which has a `thread_id` on it, targeting
either a thread root's event ID or `main` for the main timeline.
Threading introduces a concept of multiple conversations being held in the same
room and thus deserve their own read receipts and notification counts. An event is
considered to be "in a thread" if it meets any of the following criteria:
* It has a `rel_type` of `m.thread`.
* It has child events with a `rel_type` of `m.thread` (in which case it'd be the
thread root).
* Following the event relationships, it has a parent event which qualifies for
one of the above. Implementations should not recurse infinitely, though: a
maximum of 3 hops is recommended to cover indirect relationships.
Events not in a thread but still in the room are considered to be part of the
"main timeline", or a special thread with an ID of `main`.
The following is an example DAG for a room, with dotted lines showing event
relationships and solid lines showing topological ordering.
![threaded-dag](/diagrams/threaded-dag.png)
{{% boxes/note %}}
`m.reaction` relationships are not currently specified, but are shown here for
their conceptual place in a threaded DAG. They are currently proposed as
[MSC2677](https://github.com/matrix-org/matrix-spec-proposals/pull/2677).
{{% /boxes/note %}}
This DAG can be represented as 3 threaded timelines, with `A` and `B` being thread
roots:
![threaded-dag-threads](/diagrams/threaded-dag-threads.png)
With this, we can demonstrate that:
* A threaded read receipt on `I` would mark `A`, `B`, and `I` as read.
* A threaded read receipt on `E` would mark `C` and `E` as read.
* An unthreaded read receipt on `D` would mark `A`, `B`, `C`, and `D` as read.
Note that marking `A` as read with a threaded read receipt would not mean
that `C`, `E`, `G`, or `H` get marked as read: Thread A's timeline would need
its own threaded read receipt at `H` to accomplish that.
The read receipts for the above 3 examples would be:
```json
{
"$I": {
"m.read": {
"@user:example.org": {
"ts": 1661384801651,
"thread_id": "main" // because `I` is not in a thread, but is a threaded receipt
}
}
},
"$E": {
"m.read": {
"@user:example.org": {
"ts": 1661384801651,
"thread_id": "$A" // because `E` is in Thread `A`
}
}
},
"$D": {
"m.read": {
"@user:example.org": {
"ts": 1661384801651
// no `thread_id` because the receipt is *unthreaded*
}
}
}
}
```
Conditions on sending read receipts apply similarly to threaded and unthreaded read
receipts. For example, a client might send a private read receipt for a threaded
event when the user expands that thread.
#### Server behaviour
For efficiency, receipts SHOULD be batched into one event per room
@ -99,7 +223,7 @@ format of the EDUs are:
{
<room_id>: {
<receipt_type>: {
<user_id>: { <content> }
<user_id>: { <content (ts & thread_id, currently)> }
},
...
},

@ -1,4 +1,5 @@
# Copyright 2016 OpenMarket Ltd
# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -16,6 +17,12 @@ allOf:
- type: object
title: RoomEventFilter
properties:
unread_thread_notifications:
type: boolean
description: |-
If `true`, enables per-[thread](/client-server-api/#threading) notification
counts. Only applies to the `/sync` endpoint. Defaults to `false`.
x-addedInMatrixVersion: "1.4"
lazy_load_members:
type: boolean
description: |-

@ -239,17 +239,50 @@ paths:
Counts of unread notifications for this room. See the
[Receiving notifications](/client-server-api/#receiving-notifications) section
for more information on how these are calculated.
If `unread_thread_notifications` was specified as `true` on the `RoomEventFilter`,
these counts will only be for the main timeline rather than all events in the room.
See the [threading module](#threading) for more information.
x-changedInMatrixVersion:
1.4: |
Updated to reflect behaviour of having `unread_thread_notifications` as `true` in
the `RoomEventFilter` for `/sync`.
properties:
highlight_count:
title: Highlighted notification count
type: integer
description: The number of unread notifications
for this room with the highlight flag set
for this room with the highlight flag set.
notification_count:
title: Total notification count
type: integer
description: The total number of unread notifications
for this room
for this room.
unread_thread_notifications:
title: Unread Thread Notification Counts
type: object
description: |-
If `unread_thread_notifications` was specified as `true` on the `RoomEventFilter`,
the notification counts for each [thread](#threading) in this room. The object is
keyed by thread root ID, with values matching `unread_notifications`.
If a thread does not have any notifications it can be omitted from this object. If
no threads have notification counts, this whole object can be omitted.
x-addedInMatrixVersion: "1.4"
additionalProperties:
title: ThreadNotificationCounts
type: object
properties:
highlight_count:
title: ThreadedHighlightNotificationCount
type: integer
description: |-
The number of unread notifications for this *thread* with the highlight flag set.
notification_count:
title: ThreadedTotalNotificationCount
type: integer
description: |-
The total number of unread notifications for this *thread*.
invite:
title: Invited Rooms
type: object
@ -424,6 +457,16 @@ paths:
}
}
]
},
"unread_notifications": {
"highlight_count": 1,
"notification_count": 5
},
"unread_thread_notifications": {
"$threadroot": {
"highlight_count": 3,
"notification_count": 6
}
}
}
},

@ -65,6 +65,15 @@ allOf:
A POSIX timestamp in milliseconds for when the user read
the event specified in the read receipt.
example: 1533358089009
thread_id:
type: string
x-addedInMatrixVersion: "1.4"
description: |-
The root thread event's ID (or `main`) for which
thread this receipt is intended to be under. If
not specified, the read receipt is *unthreaded*
(default).
example: "$threadroot"
required: ['ts']
required: ['event_ids', 'data']
required: ['m.read']

@ -38,6 +38,14 @@ properties:
type: integer
format: int64
description: The timestamp the receipt was sent at.
thread_id:
type: string
x-addedInMatrixVersion: "1.4"
description: |-
The root thread event's ID (or `main`) for which
thread this receipt is intended to be under. If
not specified, the read receipt is *unthreaded*
(default).
"m.read.private":
type: object
title: Own User

@ -0,0 +1 @@
<mxfile host="app.diagrams.net" modified="2022-09-27T03:26:23.216Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" etag="YZcXq9Sm_7Lqw5o2RvSU" version="14.6.7" type="device"><diagram id="_rQ0dgHO1UnHExDn0l7E" name="Page-1">7ZpdU+IwFIZ/DZc6bdNWuJQCujPquOvOrl452TbQaNowIXztr9+EJv0goLCirYwyo81JGpL3PQ8nU2mBIFlcMDiOr2mESMuxokUL9FqO41i+I/7IyDKL2JbvZZERw5GKFYE7/BfpgSo6xRGaVAZySgnH42owpGmKQl6JQcbovDpsSEn1XcdwhIzAXQiJGf2NIx5n0bZzVsQvER7F+p1tv5P1JFAPVjuZxDCi81II9FsgYJTy7CpZBIhI9bQu2X2DLb35whhK+S43/PIf6YMfPSfXNzfx1WP3ez/2ToDaxwySqdrxz5ghGInYudQaJ4jgFKkt8KXWhdFpGiE5td0C3XmMObobw1D2zkUqiFjME6K6hzTlA5hgIrPgEpEZ4jiEsgMTElBC2WpS0O/Jl4jPEJMjyDnBo1T0cTpW09ypJajty4FosVURO9dZZCiiCeJsKYbo9NTWqOT0dHteOO2c+acqY+Oyz201FKr8GuWzFxaIC+XCPo742x3pHrsjAFQdcS3TEdu3Nvhhv5sfruHHNcTpsTuRq7zUHJhO5G59jBO2YcS5If0rYsPJOCsSQ7yQBq2LHAR9byDW2D2Egp2qgsDeoOAGAcG76dc25EKRKHaqSRmP6YimkPSLaLeay8WYKyrTbhV8QpwvVeWGU06rkqMF5vfi2lLXD/JafKRmrd6i1NVb6kYq9nuvJ5CN0l2yWdy2aun7Kiz9FHhOxHZv0Fz8/kETmObGyn2/bKuQiU5ZiF7QUx1tOGQjxF/LWzNNGCKQ41l1HQc33TGg6TYZGsdvGDQueDM0T9NkrMenNEU1cFRC56GM1RaOCPyDSBeGz6PVTkpeDwL5eqlsHRAw8BkAAwZgwZsBOwRIbn5u1afbjlczSs4XSnWh5O6IklMnSuZJu9cElMBaTXLdumuSewwg7Xm2awhI3o4ggTpB8gyQ+o0AyW5eTbINYb5Q+iCU/B1RcutEyXwaN2gCSu76g7K6axLoGEIlpwzBkGOaHgNje577IjiJ8+XLxi3kHLF0FXEstzkYnr2toqmEPLFObfFTSUpbfbjvTKqa/ZZisYliCB0OJ2Jp6ymaL+L/s9b878dFE+g2C6Xv1lwoQXsD3yjC/BjY3rN+fh62Ozuy7dVZYs3KcdkECF3QvNOqZQhz/LQ1BCX9ILrZj1D0KkswfWsETO6HPdcXzeJLFdlBofhuCuj/Aw==</diagram></mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@ -0,0 +1 @@
<mxfile host="app.diagrams.net" modified="2022-09-27T03:11:43.523Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" etag="L_ujIRop4Jndk67DcTE9" version="14.6.7" type="device"><diagram id="_rQ0dgHO1UnHExDn0l7E" name="Page-1">7VpbU+IwFP41PMqkTS/wKDfdGXXcdWdXn5wsDTTaNkwIAvvrN6UJbYlCF1gbXcYZTU5P0ub0u6S1DdiNFxcMTcJrGuCoYYNg0YC9hm3bwLPFnzSyzCIW8NwsMmYkkLE8cEd+Y5UoozMS4GkpkVMacTIpB4c0SfCQl2KIMTovp41oVD7rBI2xFrgbokiP/iQBD7Noy/bz+CUm41Cd2fLa2ZEYqWS5kmmIAjovhGC/AbuMUp614kUXR2n1VF2ycYM3jq4vjOGEVxnww3ukD17wHF/f3IRXj52v/dA9k7O8oGgmF3wur5YvVQnmIeH4boKGaX8u7nMDdkIeR6JniSaaTrLCj8gCi3N15JSYcbx481qtdQUEdjCNMWdLkaIGQFk0BRtH9uf5PVApYaH8KobkXR+vZ84LIxqyNn9Tp5ZWFhwIoMguZTykY5qgqJ9HO4zOkiAtyapOec4VpRMZfMKcLyXq0YzTcmnxgvB70Qay/ZC2m67s9RaFQ72l6iRivfdqgrRTGJV282Grnho3ogkfoJhEaeA7iQXpbHCD5+L3NxqjZH1j03Vvv62iTHTGhnhLPaUscMTGmO/Cpw4ThiPEyUv5Oo5+022NHB0TyGED08jhn8hxTHLAiuSwayWHr7EjbvKQYRQcjIanWTxR+QlN8IEAadpuASNWNYSApu8WQWLtgEiApuF6AWnnFnGOWbKK2MAR0Qj9wlEHDZ/Hq+V2aUTZqkBw0E1/NoF2iaMXzMkQ1QOvWrUXaujqGqG9vmna2z5p7zHJ4VQkB6xVe52Po71F6QXVALIpvWAHQj6O9FZFV63OroOrZ4L0Qsc06XVP0ntMcrgVyeHUKr3ux5He/ba99ifd9laF13YVOgNNB/pSIitDbjXdOWNoWUiYUJLwaeFst2kgP5WjZpSCp577B2/kW7C1LV80sivYGK0uh45GU1GYTYKsS7A/Z3TK9E0wFMeCTdcwS3FOlnJMznsVOe/Wain6e0ZjLWWv3bz9SXfzVdF16G5+L/dwwYa27XAPG7S25Zfd41jO4GnIHxjhDG3THjXgyReOyVy/InO9Wn1BZ0fcFK4w5IQmRjvDf/+KvSq+Dn2LuJ8ztMrOoH04sOkMbbgt/984g/7fpQsTnME17v2/fZASgJMzlOvZrshcv1ZnAK84Aw4If7ddwrvq/puKXcEQpkIeSDL+vloMyAOi54LXbbCA8wp2UgP2an1abWvQuzRBmD3XNGG2tLKchPkAcqgPQneyo10nOyxdmb+YQA///T6oFN38o9ZsO5h/Gwz7fwA=</diagram></mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Loading…
Cancel
Save