diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index 7d8c83f6..51d03046 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -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 diff --git a/crates/ruma-common/src/events/receipt.rs b/crates/ruma-common/src/events/receipt.rs index c350adfc..106016d2 100644 --- a/crates/ruma-common/src/events/receipt.rs +++ b/crates/ruma-common/src/events/receipt.rs @@ -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}, @@ -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. /// @@ -102,6 +107,10 @@ pub struct Receipt { /// The time when the receipt was sent. #[serde(skip_serializing_if = "Option::is_none")] pub ts: Option, + + /// The thread this receipt applies to. + #[serde(rename = "thread_id", default, skip_serializing_if = "crate::serde::is_default")] + pub thread: ReceiptThread, } impl Receipt { @@ -109,6 +118,140 @@ impl Receipt { /// /// 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` 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 TryFrom> for ReceiptThread +where + T: AsRef + Into>, +{ + type Error = IdParseError; + + fn try_from(s: Option) -> Result { + 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::(json!({})).unwrap(); + assert_eq!(receipt.ts, None); + assert_eq!(receipt.thread, ReceiptThread::Unthreaded); + + let receipt = from_json_value::(json!({ "thread_id": "main" })).unwrap(); + assert_eq!(receipt.ts, None); + assert_eq!(receipt.thread, ReceiptThread::Main); + + let receipt = from_json_value::(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::(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::( + 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"); } } diff --git a/crates/ruma-common/src/events/receipt/receipt_thread_serde.rs b/crates/ruma-common/src/events/receipt/receipt_thread_serde.rs new file mode 100644 index 00000000..cc5f93dc --- /dev/null +++ b/crates/ruma-common/src/events/receipt/receipt_thread_serde.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::ReceiptThread; + +impl Serialize for ReceiptThread { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.as_str().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ReceiptThread { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = crate::serde::deserialize_cow_str(deserializer)?; + Self::try_from(Some(s)).map_err(serde::de::Error::custom) + } +}