matrix-doc/proposals/3771-read-receipts-for-thre...

13 KiB
Raw Permalink Blame History

MSC3771: Read receipts for threads

Background

Currently, each room can only have a single receipt of each type per user. The read receipt (m.read or m.read.private) is used to sync the read status of a room across clients, to share with other users which events have been read, and is used by the homeserver to calculate the number of unread messages.

Now that MSC3440 has merged to add support for threads, there are two ways to display messages:

  • Unthreaded: The traditional way of displaying messages before threads existed. All messages are just shown in the order theyre provided by the server as a single timeline1.
  • Threaded: Taking into account the m.thread and other relations to separate a room DAG into multiple sub-timelines:
    • One timeline for each root message (I.e. the target of a thread relation)
    • One for messages which are not part of a thread: the main timeline.

For an example room DAG (solid lines are show topological ordering, dotted lines show event relations):

flowchart RL
    I-->H
    H-->G
    G-->F
    F-->E
    E-->D
    D-->C
    C-->B
    B-->A
    C-.->|m.thread|A
    D-.->|m.thread|B
    E-.->|m.thread|A
    F-.->|m.thread|B
    G-.->|m.reaction|C
    H-.->|m.edit|E

This can be separated into three threaded timelines:

flowchart RL
    subgraph "Main" timeline
    B-->A
    I-->B
    end
    subgraph Thread A timeline
    C-->A
    E-->C
    G-.->|m.reaction|C
    H-.->|m.edit|E
    end
    subgraph Thread B timeline
    D-->B
    F-->D
    end

Due to this separation of messages into separate timelines a single read receipt per room causes missed (or flaky) notification counts and does not give an accurate representation of what messages have been read by people.

Note that it is expected that some clients will continue to show only an unthreaded view of the room, either until they are able to support a threaded view or because they do not wish to incorporate threads.

Proposal

This MSC proposes allowing a receipt per thread, as well as an unthreaded receipt. Thus, receipts are split into two categories, which this document calls "unthreaded" and "threaded". Threaded receipts are identified by the root message of the thread; additionally there is a special pseudo-thread for the main timeline. This allows marking the main timeline (a pseudo-thread) as read, without marking any actual threads (split off from the main timeline) as read.

The most significant difference between threaded and unthreaded receipts is how they clear notifications:

  • Unthreaded receipts clear notifications just as they do today (i.e. "notifications prior to and including that event MUST be marked as read").
  • Threaded receipts clear notifications in a similar way, but taking into account the thread the receipt is part of (i.e. "notifications generated from events with a thread relation matching the receipts thread ID prior to and including that event which are MUST be marked as read")

Using the above diagrams with threaded read receipts on E and I; and an unthreaded read receipt on D would give:

flowchart RL
    subgraph "Main" timeline
    B-->A
    I-->B
    end
    subgraph Thread A timeline
    C-->A
    E-->C
    G-.->|m.reaction|C
    H-.->|m.edit|E
    end
    subgraph Thread B timeline
    D-->B
    F-->D
    end

    classDef unthreaded fill:yellow,stroke:#333,stroke-width:2px
    classDef threaded fill:crimson,stroke:#333,stroke-width:2px
    classDef both fill:orange,stroke:#333,stroke-width:2px

    %% An unthreaded read receipt on D marks A, B, C, D as read.
    class A,B,C both;
    class D unthreaded;
    %% Threaded read receipts on E and I mark C, E and A, B, I as
    %% read, respectively.
    class E,I threaded;

As denoted by the colors:

  • The unthreaded read receipt on D would mark A, B, C, and D as read.
  • The threaded read receipt on E would mark C and E as read.
  • The threaded read receipt on I would mark A, B, and I as read.

Threaded receipts

This MSC proposes allowing the same receipt type to exist multiple times in a room per user:

  • Once for the unthreaded timeline.
  • Once for the main timeline in the room.
  • Once per threaded timeline.

No other changes to receipts are proposed, i.e. this still does not allow a caller to move their receipts backwards in a room. The relationship between m.read and m.read.private is not changed.

The request body to the /receipt endpoint gains the following optional fields:

  • thread_id (string): The thread that the receipt belongs to (i.e. the event_id contained within the m.relates_to of the event represented by eventId).

    A special value of "main" corresponds to the receipt being for the main timeline (i.e. events which are not part of a thread).

    If this field is not provided then the receipt applies to the unthreaded version of the room.2

The following conditions are errors and should be rejected with a 400 error with errcode of M_INVALID_PARAM:

  • A non-string thread_id (or empty) thread_id field.

  • Providing the thread_id properties for a receipt of type m.fully_read.

  • If the given event_id is not related to the thread_id. There may be multiple relations between events ((e.g. a m.annotation to m.thread), homeservers should apply a reasonable maximum number of relations to traverse when attempting to identify if an event is part of a thread.

    It is recommended that at least 3 relations are traversed when attempting to find a thread, implementations should be careful to not infinitely recurse.3

Given a threaded message:

{
  "event_id": "$thread_reply",
  "room_id": "!room:example.org",
  "content": {
    "m.relates_to": {
      "rel_type": "m.thread",
      "event_id": "$thread_root"
    }
  }
}

A client could mark this as read by sending a request:

POST /_matrix/client/r0/rooms/!room:example.org/receipt/m.read/$thread_reply

{
  "thread_id": "$thread_root"
}

And to send a receipt on the main timeline (e.g. on the root event):

POST /_matrix/client/r0/rooms/!room:example.org/receipt/m.read/$thread_root

{
  "thread_id": "main"
}

As it is today, not providing the thread_id field sends an unthreaded receipt:

POST /_matrix/client/r0/rooms/!room:example.org/receipt/m.read/$thread_reply

{}

Receiving threaded receipts via /sync.

The client would receive this as part of /sync response similar to other receipts:

{
  "content": {
    "$thread_reply": {
      "m.read": {
        "@rikj:jki.re": {
          "ts": 1436451550453,
          "thread_id": "$thread_root" // or "main" or absent
        }
      }
    }
  },
  "room_id": "!jEsUZKDJdhlrceRyVU:example.org",
  "type": "m.receipt"
}

If there is no thread_id field then the receipt applies to the unthreaded timeline. Clients may interpret this as applying only to the main timeline or as applying across the main timeline and all threaded timelines.

Sending threaded receipts over federation

Homeservers should include a thread_id field for threaded receipts in the Receipt Metadata when sending the m.receipt EDU over federation. Unthreaded receipts lack this field, as they do today.

Notifications

MSC3773 discusses how notifications for threads are created and returned to the client, but does not provide a way to clear threaded notifications.

A threaded read receipt (i.e. a m.read or m.read.private receipt with a thread_id property) should clear notifications for the matching thread following the current rules, but only clear notifications with a matching thread_id (as discussed in MSC3773). See the examples of the read receipts on E and I above.

An unthreaded read receipt (i.e. a m.read or m.read.private receipt without a thread_id) should apply the current rules and disregard thread information when clearing notifications. To re-iterate, this means it would clear any earlier notifications across all threads. This is illustrated by the read receipt on event D above.

Potential issues

Long-lived rooms

For long-lived rooms or rooms with many threads there could be a significant number of receipts. This has a few downsides:

  • The size of the /sync response would increase without bound.
  • The effort to generate and process the receipts for each room would increase without bound.

Compatibility with unthreaded clients

When a user has both a client which is "unthreaded" and "threaded" then there is a possibility for read receipts to be misrepresented when switching between clients. Using the example room DAG from the preamble of this MSC:

  • A user which has an unthreaded receipt on event D and a threaded receipt on event E would likely see event E as unread on an "unthreaded" client.

The proposed solution may result in events being incorrectly marked as unread (when they have been read). The false positive for unread notifications is deliberate to avoid losing message / missing notifications.

Solutions to this problem are deemed out of scope of this MSC. A solution that was briefly explored was ranged read receipts.

Federation compatibility

A homeserver which does not understand threaded receipts will be unable to properly understand that multiple receipts exist in a room. They will generally be processed as unthreaded receipts with the latest receipt winning, regardless of thread.

This could make read receipts of remote users jump between threads, but this should not be any worse than it is today. Additionally, since it only affects remote users, it will not impact notifications.

Alternatives

Thread ID location

Instead of adding the thread ID in the body, it could be provided as part of the URL path or as a query parameter. Adding it to the URL (as part of the path or a query parameter) would make it difficult to differentiate the receipt's event ID field from the thread ID.

Another idea was to encode information for all threads in the single receipt, e.g. by adding them to the body of the single read receipt. This could cause data integrity issues if multiple clients attempt to update the receipt without first reading it.

Receipt type

To potentially improve compatibility it could make sense to use a separate receipt type (e.g. m.read.thread) as the read receipt for threads. Without some syncing mechanism between unthreaded and threaded receipts this seems likely to cause users to re-read the same notifications on threaded and unthreaded clients.

While it is possible to map from an unthreaded read receipt to multiple threaded read receipts, the opposite is not possible (to the author's knowledge). In short, it seems the compatibility issues discussed above would not be solved by adding more receipt types.

This also gets more complicated with the addition of the m.read.private receipt -- would there additionally be an m.read.private.thread? How do you map between all of these?

Security considerations

There is potential for abuse by allowing clients to specify a unique threadId. A mitigation could be to ensure that the receipt is related to an event of the thread, ensuring that each thread only has a single receipt.

Future extensions

Threaded fully read markers

The m.fully_read marker is not supported in threads, a future MSC could expand support to this pseudo-receipt.

Setting threaded receipts using the /read_markers endpoint

This MSC does not propose expanding the /read_markers endpoint to support threaded receipts. A future MSC might expand this to support an object per receipt with an event ID and thread ID or some other way of setting multiple receipts at once.

Unstable prefix

To detect server support, clients can either rely on the spec version (when stable) or the presence of a org.matrix.msc3771 flag in unstable_features on /versions.

Dependencies

This MSC depends on the following MSCs, which have not yet been accepted into the spec:

  • MSC3773: Notifications for threads

  1. Throughout this document "timeline" is used to mean what the user sees in the user interface of their Matrix client. ↩︎

  2. Generally it would be surprising if the same client sent both threaded and unthreaded receipts, but it is allowed. The only known use-case for this is that a threaded client can use this to clear all notifications in a room by sending an unthreaded read receipt on the latest event in the room (regardless of which thread it appears in). ↩︎

  3. Three relations is relatively arbitrary, but is meant to cover an edit or reaction to a thread (to an event with no relations, i.e. the root of a thread): A<--[m.thread]--B<--[m.annotation]--C. With an additional leftover for future improvements. This is considered reasonable since threads cannot fork, edits cannot modify relation information, and generally annotations to annotations are ignored by user interfaces. ↩︎