ruma_events/rtc/
notification.rs

1//! Type for the MatrixRTC notification event ([MSC4075]).
2//!
3//! Stable: `m.rtc.notification`
4//! Unstable: `org.matrix.msc4075.rtc.notification`
5//!
6//! [MSC4075]: https://github.com/matrix-org/matrix-spec-proposals/pull/4075
7
8use std::time::Duration;
9
10use js_int::UInt;
11use ruma_common::MilliSecondsSinceUnixEpoch;
12use ruma_events::{Mentions, relation::Reference};
13use ruma_macros::{EventContent, StringEnum};
14use serde::{Deserialize, Serialize};
15
16use crate::PrivOwnedStr;
17
18/// The content of an `m.rtc.notification` event.
19#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
20#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
21#[ruma_event(
22    type = "m.rtc.notification",
23    kind = MessageLike
24)]
25pub struct RtcNotificationEventContent {
26    /// Local timestamp observed by the sender device.
27    ///
28    /// Used with `lifetime` to determine validity; receivers SHOULD compare with
29    /// `origin_server_ts` and prefer it if the difference is large.
30    pub sender_ts: MilliSecondsSinceUnixEpoch,
31
32    /// Relative time from `sender_ts` during which the notification is considered valid.
33    #[serde(with = "ruma_common::serde::duration::ms")]
34    pub lifetime: Duration,
35
36    /// Intentional mentions determining who should be notified.
37    #[serde(rename = "m.mentions", default, skip_serializing_if = "Option::is_none")]
38    pub mentions: Option<Mentions>,
39
40    /// Optional reference to the related `m.rtc.member` event.
41    #[serde(rename = "m.relates_to", skip_serializing_if = "Option::is_none")]
42    pub relates_to: Option<Reference>,
43
44    /// How this notification should notify the receiver.
45    pub notification_type: NotificationType,
46
47    /// Gives a soft indication of whether the call is a "audio" or "video" (+audio) call.
48    ///
49    /// This is just to indicate between trusted callers that they can start with audio or video
50    /// off, but the actual call semantics remain the same, and they may switch at will.
51    #[serde(rename = "m.call.intent", skip_serializing_if = "Option::is_none")]
52    pub call_intent: Option<CallIntent>,
53}
54
55impl RtcNotificationEventContent {
56    /// Creates a new `RtcNotificationEventContent` with the given configuration.
57    pub fn new(
58        sender_ts: MilliSecondsSinceUnixEpoch,
59        lifetime: Duration,
60        notification_type: NotificationType,
61    ) -> Self {
62        Self {
63            sender_ts,
64            lifetime,
65            mentions: None,
66            relates_to: None,
67            notification_type,
68            call_intent: None,
69        }
70    }
71
72    /// Calculates the timestamp at which this notification is considered invalid.
73    /// This calculation is based on MSC4075 and tries to use the `sender_ts` as the starting point
74    /// and the `lifetime` as the duration for which the notification is valid.
75    ///
76    /// The `sender_ts` cannot be trusted since it is a generated value by the sending client.
77    /// To mitigate issue because of misconfigured client clocks, the MSC requires
78    /// that the `origin_server_ts` is used as the starting point if the difference is large.
79    ///
80    /// # Arguments:
81    ///
82    /// - `max_sender_ts_offset` is the maximum allowed offset between the two timestamps. (default
83    ///   20s)
84    /// - `origin_server_ts` has to be set to the origin_server_ts from the event containing this
85    ///   event content.
86    ///
87    /// # Examples
88    /// To start a timer until this client should stop ringing for this notification:
89    /// `let duration_ring =
90    /// MilliSecondsSinceUnixEpoch::now().saturated_sub(content.expiration_ts(event.
91    /// origin_server_ts(), None));`
92    pub fn expiration_ts(
93        &self,
94        origin_server_ts: MilliSecondsSinceUnixEpoch,
95        max_sender_ts_offset: Option<u32>,
96    ) -> MilliSecondsSinceUnixEpoch {
97        let (larger, smaller) = if self.sender_ts.get() > origin_server_ts.get() {
98            (self.sender_ts.get(), origin_server_ts.get())
99        } else {
100            (origin_server_ts.get(), self.sender_ts.get())
101        };
102        let use_origin_server_ts =
103            larger.saturating_sub(smaller) > max_sender_ts_offset.unwrap_or(20_000).into();
104        let start_ts =
105            if use_origin_server_ts { origin_server_ts.get() } else { self.sender_ts.get() };
106        MilliSecondsSinceUnixEpoch(
107            start_ts.saturating_add(UInt::from(self.lifetime.as_millis() as u32)),
108        )
109    }
110}
111
112/// How this notification should notify the receiver.
113#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
114#[derive(Clone, StringEnum)]
115#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
116#[ruma_enum(rename_all = "snake_case")]
117pub enum NotificationType {
118    /// The receiving client should ring with an audible sound.
119    Ring,
120
121    /// The receiving client should display a visual notification.
122    Notification,
123
124    #[doc(hidden)]
125    _Custom(PrivOwnedStr),
126}
127
128/// Indication of whether the call is a "audio" or "video"(+audio) call.
129#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
130#[derive(Clone, StringEnum)]
131#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
132#[ruma_enum(rename_all = "snake_case")]
133pub enum CallIntent {
134    /// Soft indication from the sender that the call is intended for audio.
135    Audio,
136
137    /// Soft indication from the sender that the call is intended for video.
138    /// Hence that the receiver should start with camera enabled.
139    Video,
140
141    #[doc(hidden)]
142    _Custom(PrivOwnedStr),
143}
144
145#[cfg(test)]
146mod tests {
147    use std::time::Duration;
148
149    use assert_matches2::assert_matches;
150    use js_int::UInt;
151    use ruma_common::{
152        MilliSecondsSinceUnixEpoch, canonical_json::assert_to_canonical_json_eq, owned_event_id,
153    };
154    use serde_json::{from_value as from_json_value, json};
155
156    use super::{CallIntent, NotificationType, RtcNotificationEventContent};
157    use crate::{AnyMessageLikeEvent, Mentions, MessageLikeEvent};
158
159    #[test]
160    fn notification_event_serialization() {
161        let mut content = RtcNotificationEventContent::new(
162            MilliSecondsSinceUnixEpoch(UInt::new(1_752_583_130_365).unwrap()),
163            Duration::from_millis(30_000),
164            NotificationType::Ring,
165        );
166        content.mentions = Some(Mentions::with_room_mention());
167        content.relates_to = Some(ruma_events::relation::Reference::new(owned_event_id!("$m:ex")));
168
169        assert_to_canonical_json_eq!(
170            content,
171            json!({
172                "sender_ts": 1_752_583_130_365_u64,
173                "lifetime": 30_000_u32,
174                "m.mentions": {"room": true},
175                "m.relates_to": {"rel_type": "m.reference", "event_id": "$m:ex"},
176                "notification_type": "ring",
177            })
178        );
179    }
180
181    #[test]
182    fn notification_event_call_intent_serialization() {
183        let mut content = RtcNotificationEventContent::new(
184            MilliSecondsSinceUnixEpoch(UInt::new(0).unwrap()),
185            Duration::from_millis(30_000),
186            NotificationType::Notification,
187        );
188        content.call_intent = Some(CallIntent::Audio);
189
190        assert_to_canonical_json_eq!(
191            content,
192            json!({
193                "sender_ts": 0,
194                "lifetime": 30_000_u32,
195                "notification_type": "notification",
196                "m.call.intent": "audio",
197            })
198        );
199    }
200
201    #[test]
202    fn call_intent_deserialization_default() {
203        let raw_content = json!({
204            "m.mentions": {
205                "user_ids": [],
206                "room": true
207            },
208            "notification_type": "ring",
209            "m.relates_to": {
210                "event_id": "$IACrEkEKgDa-n4cMk-lEJ3vqLLUL9zX1nVyAnpmFaec",
211                "rel_type": "m.reference"
212            },
213            "sender_ts": 17_709_890_710_u64,
214            "lifetime": 30000,
215        });
216        let content: RtcNotificationEventContent = from_json_value(raw_content).unwrap();
217        assert_eq!(content.call_intent, None);
218    }
219
220    #[test]
221    fn test_call_intent_serialization() {
222        assert_eq!(serde_json::to_string(&CallIntent::Audio).unwrap(), r#""audio""#);
223        assert_eq!(serde_json::to_string(&CallIntent::Video).unwrap(), r#""video""#);
224    }
225
226    #[test]
227    fn notification_event_deserialization() {
228        let json_data = json!({
229            "content": {
230                "sender_ts": 1_752_583_130_365_u64,
231                "lifetime": 30_000_u32,
232                "m.mentions": {"room": true},
233                "m.relates_to": {"rel_type": "m.reference", "event_id": "$m:ex"},
234                "notification_type": "notification"
235            },
236            "event_id": "$event:notareal.hs",
237            "origin_server_ts": 134_829_848,
238            "room_id": "!roomid:notareal.hs",
239            "sender": "@user:notareal.hs",
240            "type": "m.rtc.notification"
241        });
242
243        let event = from_json_value::<AnyMessageLikeEvent>(json_data).unwrap();
244        assert_matches!(
245            event,
246            AnyMessageLikeEvent::RtcNotification(MessageLikeEvent::Original(ev))
247        );
248        assert_eq!(ev.content.lifetime, Duration::from_millis(30_000));
249    }
250
251    #[test]
252    fn expiration_ts_computation() {
253        let content = RtcNotificationEventContent::new(
254            MilliSecondsSinceUnixEpoch(UInt::new(100_365).unwrap()),
255            Duration::from_millis(30_000),
256            NotificationType::Ring,
257        );
258
259        // sender_ts is trustworthy
260        let origin_server_ts = MilliSecondsSinceUnixEpoch(UInt::new(120_000).unwrap());
261        assert_eq!(
262            content.expiration_ts(origin_server_ts, None),
263            MilliSecondsSinceUnixEpoch(UInt::new(130_365).unwrap())
264        );
265
266        // sender_ts is not trustworthy (sender_ts too small), origin_server_ts is used instead
267        let origin_server_ts = MilliSecondsSinceUnixEpoch(UInt::new(200_000).unwrap());
268        assert_eq!(
269            content.expiration_ts(origin_server_ts, None),
270            MilliSecondsSinceUnixEpoch(UInt::new(230_000).unwrap())
271        );
272
273        // sender_ts is not trustworthy (sender_ts too large), origin_server_ts is used instead
274        let origin_server_ts = MilliSecondsSinceUnixEpoch(UInt::new(50_000).unwrap());
275        assert_eq!(
276            content.expiration_ts(origin_server_ts, None),
277            MilliSecondsSinceUnixEpoch(UInt::new(80_000).unwrap())
278        );
279
280        // using a custom max offset (result in origin_server_ts)
281        let origin_server_ts = MilliSecondsSinceUnixEpoch(UInt::new(130_200).unwrap());
282        assert_eq!(
283            content.expiration_ts(origin_server_ts, Some(100)),
284            MilliSecondsSinceUnixEpoch(UInt::new(160_200).unwrap())
285        );
286
287        // using a custom max offset (result in sender_ts)
288        let origin_server_ts = MilliSecondsSinceUnixEpoch(UInt::new(100_300).unwrap());
289        assert_eq!(
290            content.expiration_ts(origin_server_ts, Some(100)),
291            MilliSecondsSinceUnixEpoch(UInt::new(130_365).unwrap())
292        );
293    }
294}