ruma-events 0.34.0

Serializable types for the events in the Matrix specification.
Documentation
//! Types for the [`m.receipt`] event.
//!
//! [`m.receipt`]: https://spec.matrix.org/v1.18/client-server-api/#mreceipt

mod receipt_thread_serde;

use std::{
    collections::{BTreeMap, btree_map},
    ops::{Deref, DerefMut},
};

use ruma_common::{
    EventId, IdParseError, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, UserId,
};
use ruma_macros::{EventContent, StringEnum};
use serde::{Deserialize, Serialize};

use crate::PrivOwnedStr;

/// The content of an `m.receipt` event.
///
/// A mapping of event ID to a collection of receipts for this event ID. The event ID is the ID of
/// the event being acknowledged and *not* an ID for the receipt itself.
#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
#[allow(clippy::exhaustive_structs)]
#[ruma_event(type = "m.receipt", kind = EphemeralRoom)]
pub struct ReceiptEventContent(pub BTreeMap<OwnedEventId, Receipts>);

impl ReceiptEventContent {
    /// Get the receipt for the given user ID with the given receipt type, if it exists.
    pub fn user_receipt(
        &self,
        user_id: &UserId,
        receipt_type: ReceiptType,
    ) -> Option<(&EventId, &Receipt)> {
        self.iter().find_map(|(event_id, receipts)| {
            let receipt = receipts.get(&receipt_type)?.get(user_id)?;
            Some((event_id.as_ref(), receipt))
        })
    }
}

impl Deref for ReceiptEventContent {
    type Target = BTreeMap<OwnedEventId, Receipts>;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for ReceiptEventContent {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl IntoIterator for ReceiptEventContent {
    type Item = (OwnedEventId, Receipts);
    type IntoIter = btree_map::IntoIter<OwnedEventId, Receipts>;

    fn into_iter(self) -> Self::IntoIter {
        self.0.into_iter()
    }
}

impl FromIterator<(OwnedEventId, Receipts)> for ReceiptEventContent {
    fn from_iter<T>(iter: T) -> Self
    where
        T: IntoIterator<Item = (OwnedEventId, Receipts)>,
    {
        Self(BTreeMap::from_iter(iter))
    }
}

/// A collection of receipts.
pub type Receipts = BTreeMap<ReceiptType, UserReceipts>;

/// The type of receipt.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, StringEnum, Hash)]
#[non_exhaustive]
pub enum ReceiptType {
    /// A [public read receipt].
    ///
    /// Indicates that the given event has been presented to the user. It is
    /// also the point from where the unread notifications count is computed.
    ///
    /// This receipt is federated to other users.
    ///
    /// If both `Read` and `ReadPrivate` are present, the one that references
    /// the most recent event is used to get the latest read receipt.
    ///
    /// [public read receipt]: https://spec.matrix.org/v1.18/client-server-api/#receipts
    #[ruma_enum(rename = "m.read")]
    Read,

    /// A [private read receipt].
    ///
    /// Indicates that the given event has been presented to the user. It is
    /// also the point from where the unread notifications count is computed.
    ///
    /// This read receipt is not federated so only the user and their homeserver
    /// are aware of it.
    ///
    /// If both `Read` and `ReadPrivate` are present, the one that references
    /// the most recent event is used to get the latest read receipt.
    ///
    /// [private read receipt]: https://spec.matrix.org/v1.18/client-server-api/#private-read-receipts
    #[ruma_enum(rename = "m.read.private")]
    ReadPrivate,

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

/// A mapping of user ID to receipt.
///
/// The user ID is the entity who sent this receipt.
pub type UserReceipts = BTreeMap<OwnedUserId, Receipt>;

/// An acknowledgement of an event.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
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 = "ruma_common::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), 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.18/client-server-api/#threaded-read-receipts
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
#[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_matches2::assert_matches;
    use ruma_common::{
        MilliSecondsSinceUnixEpoch, canonical_json::assert_to_canonical_json_eq, owned_event_id,
    };
    use serde_json::{from_value as from_json_value, json};

    use super::{Receipt, ReceiptThread};

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

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

        receipt.thread = ReceiptThread::Thread(owned_event_id!("$abcdef76543"));
        assert_to_canonical_json_eq!(receipt, json!({ "thread_id": "$abcdef76543" }));

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

        receipt.thread = ReceiptThread::try_from(Some("io.ruma.unknown")).unwrap();
        assert_to_canonical_json_eq!(
            receipt,
            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);
        assert_matches!(receipt.thread, ReceiptThread::Thread(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");
    }
}