1use serde::{Deserialize, Serialize};
2
3use crate::room::{EncryptedFile, OwnedMxcUri, UInt};
4
5#[derive(Clone, Debug, Deserialize, Serialize)]
7#[allow(clippy::exhaustive_enums)]
8pub enum PreviewImageSource {
9 #[serde(rename = "beeper:image:encryption", alias = "matrix:image:encryption")]
11 EncryptedImage(EncryptedFile),
12
13 #[serde(rename = "og:image", alias = "og:image:url")]
15 Url(OwnedMxcUri),
16}
17
18#[derive(Clone, Debug, Deserialize, Serialize)]
22#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
23pub struct PreviewImage {
24 #[serde(flatten)]
26 pub source: PreviewImageSource,
27
28 #[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 #[serde(rename = "og:image:width", skip_serializing_if = "Option::is_none")]
38 pub width: Option<UInt>,
39
40 #[serde(rename = "og:image:height", skip_serializing_if = "Option::is_none")]
42 pub height: Option<UInt>,
43
44 #[serde(rename = "og:image:type", skip_serializing_if = "Option::is_none")]
46 pub mimetype: Option<String>,
47}
48
49impl PreviewImage {
50 pub fn plain(url: OwnedMxcUri) -> Self {
52 Self::with_image(PreviewImageSource::Url(url))
53 }
54
55 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#[derive(Clone, Debug, Deserialize, Serialize)]
68#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
69pub struct UrlPreview {
70 #[serde(alias = "matrix:matched_url")]
72 pub matched_url: Option<String>,
73
74 #[serde(rename = "og:url", skip_serializing_if = "Option::is_none")]
76 pub url: Option<String>,
77
78 #[serde(rename = "og:title", skip_serializing_if = "Option::is_none")]
80 pub title: Option<String>,
81
82 #[serde(rename = "og:description", skip_serializing_if = "Option::is_none")]
84 pub description: Option<String>,
85
86 #[serde(flatten, skip_serializing_if = "Option::is_none")]
88 pub image: Option<PreviewImage>,
89}
90
91impl UrlPreview {
92 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 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 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 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 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 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 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}