Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for read receipts for threads #1323

Merged
merged 2 commits into from
Oct 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/ruma-client-api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Improvements:
* Add support for the pagination direction parameter to `/relations` (MSC3715 / Matrix 1.4)
* Add support for notifications for threads (MSC3773 / Matrix 1.4)
* Send CORP headers by default for media responses (MSC3828 / Matrix 1.4)
* Add support for read receipts for threads (MSC3771 / Matrix 1.4)

# 0.15.1

Expand Down
12 changes: 11 additions & 1 deletion crates/ruma-client-api/src/receipt/create_receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod v3 {

use ruma_common::{
api::ruma_api,
events::receipt::ReceiptThread,
serde::{OrdAsRefStr, PartialEqAsRefStr, PartialOrdAsRefStr, StringEnum},
EventId, RoomId,
};
Expand Down Expand Up @@ -37,6 +38,15 @@ pub mod v3 {
/// The event ID to acknowledge up to.
#[ruma_api(path)]
pub event_id: &'a EventId,

/// The thread this receipt applies to.
///
/// *Note* that this must be the default value if used with
/// [`ReceiptType::FullyRead`].
///
/// Defaults to [`ReceiptThread::Unthreaded`].
#[serde(rename = "thread_id", skip_serializing_if = "ruma_common::serde::is_default")]
pub thread: ReceiptThread,
}

#[derive(Default)]
Expand All @@ -48,7 +58,7 @@ pub mod v3 {
impl<'a> Request<'a> {
/// Creates a new `Request` with the given room ID, receipt type and event ID.
pub fn new(room_id: &'a RoomId, receipt_type: ReceiptType, event_id: &'a EventId) -> Self {
Self { room_id, receipt_type, event_id }
Self { room_id, receipt_type, event_id, thread: ReceiptThread::default() }
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/ruma-common/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Improvements:
* Add parameter to `RoomMessageEventContent::make_reply_to` to be thread-aware
* Add `RoomMessageEventContent::make_for_reply`
* Stabilize support for event replacements (edits)
* Add support for read receipts for threads (MSC3771 / Matrix 1.4)

# 0.10.3

Expand Down
147 changes: 145 additions & 2 deletions crates/ruma-common/src/events/receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
//!
//! [`m.receipt`]: https://spec.matrix.org/v1.2/client-server-api/#mreceipt

mod receipt_thread_serde;

use std::{
collections::BTreeMap,
ops::{Deref, DerefMut},
Expand All @@ -10,7 +12,10 @@ use std::{
use ruma_macros::{EventContent, OrdAsRefStr, PartialEqAsRefStr, PartialOrdAsRefStr, StringEnum};
use serde::{Deserialize, Serialize};

use crate::{EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, PrivOwnedStr, UserId};
use crate::{
EventId, IdParseError, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, PrivOwnedStr,
UserId,
};

/// The content of an `m.receipt` event.
///
Expand Down Expand Up @@ -102,13 +107,151 @@ pub struct Receipt {
/// The time when the receipt was sent.
#[serde(skip_serializing_if = "Option::is_none")]
pub ts: Option<MilliSecondsSinceUnixEpoch>,

/// The thread this receipt applies to.
#[serde(rename = "thread_id", default, skip_serializing_if = "crate::serde::is_default")]
pub thread: ReceiptThread,
}

impl Receipt {
/// Creates a new `Receipt` with the given timestamp.
///
/// To create an empty receipt instead, use [`Receipt::default`].
pub fn new(ts: MilliSecondsSinceUnixEpoch) -> Self {
Self { ts: Some(ts) }
Self { ts: Some(ts), thread: ReceiptThread::Unthreaded }
}
}

/// The [thread a receipt applies to].
///
/// This type can hold an arbitrary string. To build this with a custom value, convert it from an
/// `Option<String>` with `::from()` / `.into()`. [`ReceiptThread::Unthreaded`] can be constructed
/// from `None`.
///
/// To check for values that are not available as a documented variant here, use its string
/// representation, obtained through [`.as_str()`](Self::as_str()).
///
/// [thread a receipt applies to]: https://spec.matrix.org/v1.4/client-server-api/#threaded-read-receipts
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReceiptThread {
/// The receipt applies to the timeline, regardless of threads.
///
/// Used by clients that are not aware of threads.
///
/// This is the default.
#[default]
Unthreaded,

/// The receipt applies to the main timeline.
///
/// Used for events that don't belong to a thread.
Main,

/// The receipt applies to a thread.
///
/// Used for events that belong to a thread with the given thread root.
Thread(OwnedEventId),

#[doc(hidden)]
_Custom(PrivOwnedStr),
}

impl ReceiptThread {
/// Get the string representation of this `ReceiptThread`.
///
/// [`ReceiptThread::Unthreaded`] returns `None`.
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Unthreaded => None,
Self::Main => Some("main"),
Self::Thread(event_id) => Some(event_id.as_str()),
Self::_Custom(s) => Some(&s.0),
}
}
}

impl<T> TryFrom<Option<T>> for ReceiptThread
where
T: AsRef<str> + Into<Box<str>>,
{
type Error = IdParseError;

fn try_from(s: Option<T>) -> Result<Self, Self::Error> {
let res = match s {
None => Self::Unthreaded,
Some(s) => match s.as_ref() {
"main" => Self::Main,
s_ref if s_ref.starts_with('$') => Self::Thread(EventId::parse(s_ref)?),
_ => Self::_Custom(PrivOwnedStr(s.into())),
},
};

Ok(res)
}
}

#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use serde_json::{from_value as from_json_value, json, to_value as to_json_value};

use super::{Receipt, ReceiptThread};
use crate::{event_id, MilliSecondsSinceUnixEpoch};

#[test]
fn serialize_receipt() {
let mut receipt = Receipt::default();
assert_eq!(to_json_value(receipt.clone()).unwrap(), json!({}));

receipt.thread = ReceiptThread::Main;
assert_eq!(to_json_value(receipt.clone()).unwrap(), json!({ "thread_id": "main" }));

receipt.thread = ReceiptThread::Thread(event_id!("$abcdef76543").to_owned());
assert_eq!(to_json_value(receipt).unwrap(), json!({ "thread_id": "$abcdef76543" }));

let mut receipt =
Receipt::new(MilliSecondsSinceUnixEpoch(1_664_702_144_365_u64.try_into().unwrap()));
assert_eq!(to_json_value(receipt.clone()).unwrap(), json!({ "ts": 1_664_702_144_365_u64 }));

receipt.thread = ReceiptThread::try_from(Some("io.ruma.unknown")).unwrap();
assert_eq!(
to_json_value(receipt).unwrap(),
json!({ "ts": 1_664_702_144_365_u64, "thread_id": "io.ruma.unknown" })
);
}

#[test]
fn deserialize_receipt() {
let receipt = from_json_value::<Receipt>(json!({})).unwrap();
assert_eq!(receipt.ts, None);
assert_eq!(receipt.thread, ReceiptThread::Unthreaded);

let receipt = from_json_value::<Receipt>(json!({ "thread_id": "main" })).unwrap();
assert_eq!(receipt.ts, None);
assert_eq!(receipt.thread, ReceiptThread::Main);

let receipt = from_json_value::<Receipt>(json!({ "thread_id": "$abcdef76543" })).unwrap();
assert_eq!(receipt.ts, None);
let event_id = assert_matches!(receipt.thread, ReceiptThread::Thread(event_id) => event_id);
assert_eq!(event_id, "$abcdef76543");

let receipt = from_json_value::<Receipt>(json!({ "ts": 1_664_702_144_365_u64 })).unwrap();
assert_eq!(
receipt.ts.unwrap(),
MilliSecondsSinceUnixEpoch(1_664_702_144_365_u64.try_into().unwrap())
);
assert_eq!(receipt.thread, ReceiptThread::Unthreaded);

let receipt = from_json_value::<Receipt>(
json!({ "ts": 1_664_702_144_365_u64, "thread_id": "io.ruma.unknown" }),
)
.unwrap();
assert_eq!(
receipt.ts.unwrap(),
MilliSecondsSinceUnixEpoch(1_664_702_144_365_u64.try_into().unwrap())
);
assert_matches!(receipt.thread, ReceiptThread::_Custom(_));
assert_eq!(receipt.thread.as_str().unwrap(), "io.ruma.unknown");
}
}
22 changes: 22 additions & 0 deletions crates/ruma-common/src/events/receipt/receipt_thread_serde.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer};

use super::ReceiptThread;

impl Serialize for ReceiptThread {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.as_str().serialize(serializer)
}
}

impl<'de> Deserialize<'de> for ReceiptThread {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = crate::serde::deserialize_cow_str(deserializer)?;
Self::try_from(Some(s)).map_err(serde::de::Error::custom)
}
}