Skip to main content

ruma_events/room/message/
url_preview.rs

1use serde::{Deserialize, Serialize};
2
3use crate::room::{EncryptedFile, OwnedMxcUri, UInt};
4
5/// The Source of the PreviewImage.
6#[derive(Clone, Debug, Deserialize, Serialize)]
7#[allow(clippy::exhaustive_enums)]
8pub enum PreviewImageSource {
9    /// Source of the PreviewImage as encrypted file data
10    #[serde(rename = "beeper:image:encryption", alias = "matrix:image:encryption")]
11    EncryptedImage(EncryptedFile),
12
13    /// Source of the PreviewImage as a simple MxcUri
14    #[serde(rename = "og:image", alias = "og:image:url")]
15    Url(OwnedMxcUri),
16}
17
18/// Metadata and [`PreviewImageSource`] of an [`UrlPreview`] image.
19///
20/// Modelled after [OpenGraph Image Properties](https://ogp.me/#structured).
21#[derive(Clone, Debug, Deserialize, Serialize)]
22#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
23pub struct PreviewImage {
24    /// Source information for the image.
25    #[serde(flatten)]
26    pub source: PreviewImageSource,
27
28    /// The size of the image in bytes.
29    #[serde(
30        rename = "matrix:image:size",
31        alias = "og:image:size",
32        skip_serializing_if = "Option::is_none"
33    )]
34    pub size: Option<UInt>,
35
36    /// The width of the image in pixels.
37    #[serde(rename = "og:image:width", skip_serializing_if = "Option::is_none")]
38    pub width: Option<UInt>,
39
40    /// The height of the image in pixels.
41    #[serde(rename = "og:image:height", skip_serializing_if = "Option::is_none")]
42    pub height: Option<UInt>,
43
44    /// The mime_type of the image.
45    #[serde(rename = "og:image:type", skip_serializing_if = "Option::is_none")]
46    pub mimetype: Option<String>,
47}
48
49impl PreviewImage {
50    /// Construct a PreviewImage with the given [`OwnedMxcUri`] as the source.
51    pub fn plain(url: OwnedMxcUri) -> Self {
52        Self::with_image(PreviewImageSource::Url(url))
53    }
54
55    /// Construct the PreviewImage for the given [`EncryptedFile`] as the source.
56    pub fn encrypted(file: EncryptedFile) -> Self {
57        Self::with_image(PreviewImageSource::EncryptedImage(file))
58    }
59
60    fn with_image(source: PreviewImageSource) -> Self {
61        PreviewImage { source, size: None, width: None, height: None, mimetype: None }
62    }
63}
64
65/// Preview Information for a URL matched in the message's text, according to
66/// [MSC 4095](https://github.com/matrix-org/matrix-spec-proposals/pull/4095).
67#[derive(Clone, Debug, Deserialize, Serialize)]
68#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
69pub struct UrlPreview {
70    /// The url this was matching on.
71    #[serde(alias = "matrix:matched_url")]
72    pub matched_url: Option<String>,
73
74    /// Canonical URL according to open graph data.
75    #[serde(rename = "og:url", skip_serializing_if = "Option::is_none")]
76    pub url: Option<String>,
77
78    /// Title to use for the preview.
79    #[serde(rename = "og:title", skip_serializing_if = "Option::is_none")]
80    pub title: Option<String>,
81
82    /// Description to use for the preview.
83    #[serde(rename = "og:description", skip_serializing_if = "Option::is_none")]
84    pub description: Option<String>,
85
86    /// Metadata of a preview image if given.
87    #[serde(flatten, skip_serializing_if = "Option::is_none")]
88    pub image: Option<PreviewImage>,
89}
90
91impl UrlPreview {
92    /// Construct an preview for a matched_url.
93    pub fn matched_url(matched_url: String) -> Self {
94        UrlPreview {
95            matched_url: Some(matched_url),
96            url: None,
97            image: None,
98            description: None,
99            title: None,
100        }
101    }
102
103    /// Construct an preview for a canonical url.
104    pub fn canonical_url(url: String) -> Self {
105        UrlPreview {
106            matched_url: None,
107            url: Some(url),
108            image: None,
109            description: None,
110            title: None,
111        }
112    }
113
114    /// Whether this preview contains an actual preview or the users homeserver
115    /// should be asked for preview data instead.
116    pub fn contains_preview(&self) -> bool {
117        self.url.is_some()
118            || self.title.is_some()
119            || self.description.is_some()
120            || self.image.is_some()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use assert_matches2::assert_matches;
127    use assign::assign;
128    use js_int::uint;
129    use ruma_common::{canonical_json::assert_to_canonical_json_eq, owned_mxc_uri};
130    use ruma_events::room::message::{MessageType, RoomMessageEventContent};
131    use serde_json::{from_value as from_json_value, json};
132
133    use super::{super::text::TextMessageEventContent, *};
134    use crate::room::{EncryptedFile, EncryptedFileHashes, V2EncryptedFileInfo};
135
136    fn encrypted_file() -> EncryptedFile {
137        EncryptedFile::new(
138            owned_mxc_uri!("mxc://localhost/encryptedfile"),
139            V2EncryptedFileInfo::encode([0; 32], [1; 16]).into(),
140            EncryptedFileHashes::with_sha256([2; 32]),
141        )
142    }
143
144    #[test]
145    fn serialize_preview_image() {
146        let preview =
147            PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO"));
148
149        assert_to_canonical_json_eq!(
150            preview,
151            json!({
152                "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
153            }),
154        );
155
156        let preview = PreviewImage::encrypted(encrypted_file());
157
158        assert_to_canonical_json_eq!(
159            preview,
160            json!({
161                "beeper:image:encryption": {
162                    "hashes" : {
163                        "sha256": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI",
164                    },
165                    "iv": "AQEBAQEBAQEBAQEBAQEBAQ",
166                    "key": {
167                        "alg": "A256CTR",
168                        "ext": true,
169                        "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
170                        "key_ops": ["decrypt", "encrypt"],
171                        "kty": "oct",
172                    },
173                    "v": "v2",
174                    "url": "mxc://localhost/encryptedfile",
175                },
176            }),
177        );
178    }
179
180    #[test]
181    fn serialize_room_message_with_url_preview() {
182        let preview_img =
183            PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO"));
184        let full_preview = assign!(UrlPreview::matched_url("https://matrix.org/".to_owned()), {image: Some(preview_img)});
185        let msg = MessageType::Text(assign!(TextMessageEventContent::plain("Test message"),  {
186            url_previews: Some(vec![full_preview])
187        }));
188
189        assert_to_canonical_json_eq!(
190            RoomMessageEventContent::new(msg),
191            json!({
192                "msgtype": "m.text",
193                "body": "Test message",
194                "com.beeper.linkpreviews": [
195                    {
196                        "matched_url": "https://matrix.org/",
197                        "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
198                    }
199                ],
200            }),
201        );
202    }
203
204    #[test]
205    fn serialize_room_message_with_url_preview_with_encrypted_image() {
206        let preview_img = PreviewImage::encrypted(encrypted_file());
207        let full_preview = assign!(UrlPreview::matched_url("https://matrix.org/".to_owned()), {
208            image: Some(preview_img),
209        });
210
211        let msg = MessageType::Text(assign!(TextMessageEventContent::plain("Test message"),  {
212            url_previews: Some(vec![full_preview])
213        }));
214
215        assert_to_canonical_json_eq!(
216            RoomMessageEventContent::new(msg),
217            json!({
218                "msgtype": "m.text",
219                "body": "Test message",
220                "com.beeper.linkpreviews": [
221                    {
222                        "matched_url": "https://matrix.org/",
223                        "beeper:image:encryption": {
224                            "hashes" : {
225                                "sha256": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI",
226                            },
227                            "iv": "AQEBAQEBAQEBAQEBAQEBAQ",
228                            "key": {
229                                "alg": "A256CTR",
230                                "ext": true,
231                                "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
232                                "key_ops": ["decrypt", "encrypt"],
233                                "kty": "oct",
234                            },
235                            "v": "v2",
236                            "url": "mxc://localhost/encryptedfile",
237                        },
238                    }
239                ],
240            }),
241        );
242    }
243
244    #[cfg(feature = "unstable-msc1767")]
245    #[test]
246    fn serialize_extensible_room_message_with_preview() {
247        use crate::message::MessageEventContent;
248
249        let preview_img = assign!(PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO")), {
250                height: Some(uint!(400)),
251                width: Some(uint!(800)),
252                mimetype: Some("image/jpeg".to_owned()),
253                size: Some(uint!(16588))
254        });
255        let full_preview = assign!(UrlPreview::matched_url("matrix.org/support".to_owned()), {
256                   image: Some(preview_img),
257                   url: Some("https://matrix.org/support/".to_owned()),
258                   title: Some("Support Matrix".to_owned()),
259                   description: Some("Matrix, the open protocol for secure decentralised communications".to_owned()),
260        });
261        let msg = assign!(MessageEventContent::plain("matrix.org/support"),  {
262            url_previews: Some(vec![full_preview])
263        });
264        assert_to_canonical_json_eq!(
265            msg,
266            json!({
267                "org.matrix.msc1767.text": [
268                    {"body": "matrix.org/support"}
269                ],
270                "com.beeper.linkpreviews": [
271                    {
272                        "matched_url": "matrix.org/support",
273                        "matrix:image:size": 16588,
274                        "og:description": "Matrix, the open protocol for secure decentralised communications",
275                        "og:image":"mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
276                        "og:image:height": 400,
277                        "og:image:type": "image/jpeg",
278                        "og:image:width": 800,
279                        "og:title": "Support Matrix",
280                        "og:url": "https://matrix.org/support/",
281                    }
282                ],
283            }),
284        );
285    }
286
287    #[test]
288    fn deserialize_regular_example() {
289        let normal_preview = json!({
290            "msgtype": "m.text",
291            "body": "https://matrix.org",
292            "m.url_previews": [
293                {
294                    "matrix:matched_url": "https://matrix.org",
295                    "matrix:image:size": 16588,
296                    "og:description": "Matrix, the open protocol for secure decentralised communications",
297                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
298                    "og:image:height": 400,
299                    "og:image:type": "image/jpeg",
300                    "og:image:width": 800,
301                    "og:title": "Matrix.org",
302                    "og:url": "https://matrix.org/"
303                }
304            ],
305            "m.mentions": {}
306        });
307
308        let message_with_preview: TextMessageEventContent =
309            from_json_value(normal_preview).unwrap();
310        let TextMessageEventContent { url_previews, .. } = message_with_preview;
311        let previews = url_previews.expect("No url previews found");
312        assert_eq!(previews.len(), 1);
313        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
314        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
315        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
316        assert_eq!(
317            description.as_ref().unwrap(),
318            "Matrix, the open protocol for secure decentralised communications"
319        );
320        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
321
322        // Check the preview image parsed:
323        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
324        assert_eq!(size.unwrap(), uint!(16588));
325        assert_matches!(source, PreviewImageSource::Url(url));
326        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
327        assert_eq!(height.unwrap(), uint!(400));
328        assert_eq!(width.unwrap(), uint!(800));
329        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
330    }
331
332    #[test]
333    fn deserialize_under_dev_prefix() {
334        let normal_preview = json!({
335            "msgtype": "m.text",
336            "body": "https://matrix.org",
337            "com.beeper.linkpreviews": [
338                {
339                    "matched_url": "https://matrix.org",
340                    "matrix:image:size": 16588,
341                    "og:description": "Matrix, the open protocol for secure decentralised communications",
342                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
343                    "og:image:height": 400,
344                    "og:image:type": "image/jpeg",
345                    "og:image:width": 800,
346                    "og:title": "Matrix.org",
347                    "og:url": "https://matrix.org/"
348                }
349            ],
350            "m.mentions": {}
351        });
352
353        let message_with_preview: TextMessageEventContent =
354            from_json_value(normal_preview).unwrap();
355        let TextMessageEventContent { url_previews, .. } = message_with_preview;
356        let previews = url_previews.expect("No url previews found");
357        assert_eq!(previews.len(), 1);
358        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
359        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
360        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
361        assert_eq!(
362            description.as_ref().unwrap(),
363            "Matrix, the open protocol for secure decentralised communications"
364        );
365        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
366
367        // Check the preview image parsed:
368        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
369        assert_eq!(size.unwrap(), uint!(16588));
370        assert_matches!(source, PreviewImageSource::Url(url));
371        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
372        assert_eq!(height.unwrap(), uint!(400));
373        assert_eq!(width.unwrap(), uint!(800));
374        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
375    }
376
377    #[test]
378    fn deserialize_example_no_previews() {
379        let normal_preview = json!({
380            "msgtype": "m.text",
381            "body": "https://matrix.org",
382            "m.url_previews": [],
383            "m.mentions": {}
384        });
385        let message_with_preview: TextMessageEventContent =
386            from_json_value(normal_preview).unwrap();
387        let TextMessageEventContent { url_previews, .. } = message_with_preview;
388        assert!(url_previews.clone().unwrap().is_empty(), "Unexpectedly found url previews");
389    }
390
391    #[test]
392    fn deserialize_example_empty_previews() {
393        let normal_preview = json!({
394            "msgtype": "m.text",
395            "body": "https://matrix.org",
396            "m.url_previews": [
397                { "matrix:matched_url": "https://matrix.org" }
398            ],
399            "m.mentions": {}
400        });
401
402        let message_with_preview: TextMessageEventContent =
403            from_json_value(normal_preview).unwrap();
404        let TextMessageEventContent { url_previews, .. } = message_with_preview;
405        let previews = url_previews.expect("No url previews found");
406        assert_eq!(previews.len(), 1);
407        let preview = previews.first().unwrap();
408        assert_eq!(preview.matched_url.as_ref().unwrap(), "https://matrix.org");
409        assert!(!preview.contains_preview());
410    }
411
412    #[test]
413    fn deserialize_encrypted_image_dev_example() {
414        let normal_preview = json!({
415            "msgtype": "m.text",
416            "body": "https://matrix.org",
417            "com.beeper.linkpreviews": [
418                {
419                    "matched_url": "https://matrix.org",
420                    "og:title": "Matrix.org",
421                    "og:url": "https://matrix.org/",
422                    "og:description": "Matrix, the open protocol for secure decentralised communications",
423                    "matrix:image:size": 16588,
424                    "og:image:height": 400,
425                    "og:image:type": "image/jpeg",
426                    "og:image:width": 800,
427                    "beeper:image:encryption": {
428                        "key": {
429                            "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
430                            "alg": "A256CTR",
431                            "ext": true,
432                            "kty": "oct",
433                            "key_ops": [
434                                "encrypt",
435                                "decrypt"
436                            ]
437                        },
438                        "iv": "AQEBAQEBAQEBAQEBAQEBAQ",
439                        "hashes": {
440                            "sha256": "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI",
441                        },
442                        "v": "v2",
443                        "url": "mxc://beeper.com/53207ac52ce3e2c722bb638987064bfdc0cc257b"
444                    }
445                }
446            ],
447            "m.mentions": {}
448        });
449
450        let message_with_preview: TextMessageEventContent =
451            from_json_value(normal_preview).unwrap();
452        let TextMessageEventContent { url_previews, .. } = message_with_preview;
453        let previews = url_previews.expect("No url previews found");
454        assert_eq!(previews.len(), 1);
455        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
456        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
457        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
458        assert_eq!(
459            description.as_ref().unwrap(),
460            "Matrix, the open protocol for secure decentralised communications"
461        );
462        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
463
464        // Check the preview image parsed:
465        let PreviewImage { size, height, width, mimetype, source } = image.as_ref().unwrap();
466
467        assert_eq!(size.unwrap(), uint!(16588));
468
469        assert_matches!(source, PreviewImageSource::EncryptedImage(encrypted_image));
470        assert_eq!(
471            encrypted_image.url.as_str(),
472            "mxc://beeper.com/53207ac52ce3e2c722bb638987064bfdc0cc257b"
473        );
474        assert_eq!(height.unwrap(), uint!(400));
475        assert_eq!(width.unwrap(), uint!(800));
476        assert_eq!(mimetype.as_ref().unwrap().as_str(), "image/jpeg");
477    }
478
479    #[test]
480    #[cfg(feature = "unstable-msc1767")]
481    fn deserialize_extensible_example() {
482        use crate::message::MessageEventContent;
483        let normal_preview = json!({
484            "m.text": [
485                {"body": "matrix.org/support"}
486            ],
487            "m.url_previews": [
488                {
489                    "matrix:matched_url": "matrix.org/support",
490                    "matrix:image:size": 16588,
491                    "og:description": "Matrix, the open protocol for secure decentralised communications",
492                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
493                    "og:image:height": 400,
494                    "og:image:type": "image/jpeg",
495                    "og:image:width": 800,
496                    "og:title": "Support Matrix",
497                    "og:url": "https://matrix.org/support/"
498                }
499            ],
500            "m.mentions": {}
501        });
502
503        let message_with_preview: MessageEventContent = from_json_value(normal_preview).unwrap();
504        let MessageEventContent { url_previews, .. } = message_with_preview;
505        let previews = url_previews.expect("No url previews found");
506        assert_eq!(previews.len(), 1);
507        let preview = previews.first().unwrap();
508        assert!(preview.contains_preview());
509        let UrlPreview { image, matched_url, title, url, description } = preview;
510        assert_eq!(matched_url.as_ref().unwrap(), "matrix.org/support");
511        assert_eq!(title.as_ref().unwrap(), "Support Matrix");
512        assert_eq!(
513            description.as_ref().unwrap(),
514            "Matrix, the open protocol for secure decentralised communications"
515        );
516        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/support/");
517
518        // Check the preview image parsed:
519        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
520        assert_eq!(size.unwrap(), uint!(16588));
521        assert_matches!(source, PreviewImageSource::Url(url));
522        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
523        assert_eq!(height.unwrap(), uint!(400));
524        assert_eq!(width.unwrap(), uint!(800));
525        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
526    }
527}