Skip to main content

fractal/utils/matrix/
media_message.rs

1use gettextrs::gettext;
2use gtk::{gio, glib, prelude::*};
3use matrix_sdk::Client;
4use ruma::events::{
5    room::message::{
6        AudioMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent,
7        MessageType, VideoMessageEventContent,
8    },
9    sticker::StickerEventContent,
10};
11use tracing::{debug, error};
12
13use crate::{
14    components::ContentType,
15    gettext_f,
16    prelude::*,
17    toast,
18    utils::{
19        File,
20        media::{
21            FrameDimensions, MediaFileError,
22            audio::normalize_waveform,
23            image::{
24                Blurhash, Image, ImageError, ImageRequestPriority, ImageSource,
25                ThumbnailDownloader, ThumbnailSettings,
26            },
27        },
28        save_data_to_tmp_file,
29    },
30};
31
32/// Get the filename of a media message.
33macro_rules! filename {
34    ($message:ident, $mime_fallback:expr) => {{
35        let mut filename = $message.filename().to_owned();
36        filename.clean_string();
37
38        if filename.is_empty() {
39            let mimetype = $message
40                .info
41                .as_ref()
42                .and_then(|info| info.mimetype.as_deref());
43
44            $crate::utils::media::filename_for_mime(mimetype, $mime_fallback)
45        } else {
46            filename
47        }
48    }};
49}
50
51/// A media message.
52#[derive(Debug, Clone)]
53pub(crate) enum MediaMessage {
54    /// An audio.
55    Audio(AudioMessageEventContent),
56    /// A file.
57    File(FileMessageEventContent),
58    /// An image.
59    Image(ImageMessageEventContent),
60    /// A video.
61    Video(VideoMessageEventContent),
62    /// A sticker.
63    Sticker(Box<StickerEventContent>),
64}
65
66impl MediaMessage {
67    /// Construct a `MediaMessage` from the given message.
68    pub(crate) fn from_message(msgtype: &MessageType) -> Option<Self> {
69        match msgtype {
70            MessageType::Audio(c) => Some(Self::Audio(c.clone())),
71            MessageType::File(c) => Some(Self::File(c.clone())),
72            MessageType::Image(c) => Some(Self::Image(c.clone())),
73            MessageType::Video(c) => Some(Self::Video(c.clone())),
74            _ => None,
75        }
76    }
77
78    /// The name of the media, as displayed in the interface.
79    ///
80    /// This is usually the filename in the message, except:
81    ///
82    /// - For a voice message, it's a placeholder string because file names are
83    ///   usually generated randomly.
84    /// - For a sticker, this returns the description of the sticker, because
85    ///   they do not have a filename.
86    pub(crate) fn display_name(&self) -> String {
87        match self {
88            Self::Audio(c) => {
89                if c.voice.is_some() {
90                    gettext("Voice Message")
91                } else {
92                    filename!(c, Some(mime::AUDIO))
93                }
94            }
95            Self::File(c) => filename!(c, None),
96            Self::Image(c) => filename!(c, Some(mime::IMAGE)),
97            Self::Video(c) => filename!(c, Some(mime::VIDEO)),
98            Self::Sticker(c) => c.body.clone(),
99        }
100    }
101
102    /// The filename of the media, used when saving the file.
103    ///
104    /// This is usually the filename in the message, except:
105    ///
106    /// - For a voice message, it's a generated name that uses the timestamp of
107    ///   the message.
108    /// - For a sticker, this returns the description of the sticker, because
109    ///   they do not have a filename.
110    pub(crate) fn filename(&self, timestamp: &glib::DateTime) -> String {
111        match self {
112            Self::Audio(c) => {
113                let mut filename = filename!(c, Some(mime::AUDIO));
114
115                if c.voice.is_some() {
116                    let datetime = timestamp
117                        .to_local()
118                        .and_then(|local_timestamp| local_timestamp.format("%Y-%m-%d %H-%M-%S"))
119                        // Fallback to the timestamp in seconds.
120                        .map_or_else(|_| timestamp.second().to_string(), String::from);
121                    // Translators: this is the name of the file that the voice message is saved as.
122                    // Do NOT translate the content between '{' and '}', this is a variable name
123                    // corresponding to a date and time, e.g. "2017-05-21 12-24-03".
124                    let name =
125                        gettext_f("Voice Message From {datetime}", &[("datetime", &datetime)]);
126
127                    filename = filename
128                        .rsplit_once('.')
129                        .map(|(_, extension)| format!("{name}.{extension}"))
130                        .unwrap_or(name);
131                }
132
133                filename
134            }
135            Self::File(c) => filename!(c, None),
136            Self::Image(c) => filename!(c, Some(mime::IMAGE)),
137            Self::Video(c) => filename!(c, Some(mime::VIDEO)),
138            Self::Sticker(c) => c.body.clone(),
139        }
140    }
141
142    /// The caption of the media, if any.
143    ///
144    /// Returns `Some((body, formatted_body))` if the media includes a caption.
145    pub(crate) fn caption(&self) -> Option<(String, Option<FormattedBody>)> {
146        let mut caption = match self {
147            Self::Audio(c) => c
148                .caption()
149                .map(|caption| (caption.to_owned(), c.formatted.clone())),
150            Self::File(c) => c
151                .caption()
152                .map(|caption| (caption.to_owned(), c.formatted.clone())),
153            Self::Image(c) => c
154                .caption()
155                .map(|caption| (caption.to_owned(), c.formatted.clone())),
156            Self::Video(c) => c
157                .caption()
158                .map(|caption| (caption.to_owned(), c.formatted.clone())),
159            Self::Sticker(_) => None,
160        };
161
162        caption.take_if(|(caption, formatted)| {
163            caption.clean_string();
164            formatted.clean_string();
165
166            caption.is_empty()
167        });
168
169        caption
170    }
171
172    /// Fetch the content of the media with the given client.
173    ///
174    /// Returns an error if something occurred while fetching the content.
175    pub(crate) async fn into_content(self, client: &Client) -> Result<Vec<u8>, matrix_sdk::Error> {
176        let media = client.media();
177
178        macro_rules! content {
179            ($event_content:expr) => {{
180                Ok(
181                    $crate::spawn_tokio!(
182                        async move { media.get_file(&$event_content, true).await }
183                    )
184                    .await
185                    .unwrap()?
186                    .expect("All media message types have a file"),
187                )
188            }};
189        }
190
191        match self {
192            Self::Audio(c) => content!(c),
193            Self::File(c) => content!(c),
194            Self::Image(c) => content!(c),
195            Self::Video(c) => content!(c),
196            Self::Sticker(c) => content!(*c),
197        }
198    }
199
200    /// Fetch the content of the media with the given client and write it to a
201    /// temporary file.
202    ///
203    /// Returns an error if something occurred while fetching the content.
204    pub(crate) async fn into_tmp_file(self, client: &Client) -> Result<File, MediaFileError> {
205        let data = self.into_content(client).await?;
206        Ok(save_data_to_tmp_file(data).await?)
207    }
208
209    /// Save the content of the media to a file selected by the user.
210    ///
211    /// Shows a dialog to the user to select a file on the system.
212    pub(crate) async fn save_to_file(
213        self,
214        timestamp: &glib::DateTime,
215        client: &Client,
216        parent: &impl IsA<gtk::Widget>,
217    ) {
218        let filename = self.filename(timestamp);
219
220        let data = match self.into_content(client).await {
221            Ok(data) => data,
222            Err(error) => {
223                error!("Could not retrieve media file: {error}");
224                toast!(parent, error.to_user_facing());
225
226                return;
227            }
228        };
229
230        let dialog = gtk::FileDialog::builder()
231            .title(gettext("Save File"))
232            .modal(true)
233            .accept_label(gettext("Save"))
234            .initial_name(filename)
235            .build();
236
237        match dialog
238            .save_future(parent.root().and_downcast_ref::<gtk::Window>())
239            .await
240        {
241            Ok(file) => {
242                if let Err(error) = file.replace_contents(
243                    &data,
244                    None,
245                    false,
246                    gio::FileCreateFlags::REPLACE_DESTINATION,
247                    gio::Cancellable::NONE,
248                ) {
249                    error!("Could not save file: {error}");
250                    toast!(parent, gettext("Could not save file"));
251                }
252            }
253            Err(error) => {
254                if error.matches(gtk::DialogError::Dismissed) {
255                    debug!("File dialog dismissed by user");
256                } else {
257                    error!("Could not access file: {error}");
258                    toast!(parent, gettext("Could not access file"));
259                }
260            }
261        }
262    }
263}
264
265impl From<AudioMessageEventContent> for MediaMessage {
266    fn from(value: AudioMessageEventContent) -> Self {
267        Self::Audio(value)
268    }
269}
270
271impl From<FileMessageEventContent> for MediaMessage {
272    fn from(value: FileMessageEventContent) -> Self {
273        Self::File(value)
274    }
275}
276
277impl From<StickerEventContent> for MediaMessage {
278    fn from(value: StickerEventContent) -> Self {
279        Self::Sticker(value.into())
280    }
281}
282
283/// A visual media message.
284#[derive(Debug, Clone)]
285pub(crate) enum VisualMediaMessage {
286    /// An image.
287    Image(ImageMessageEventContent),
288    /// A video.
289    Video(VideoMessageEventContent),
290    /// A sticker.
291    Sticker(Box<StickerEventContent>),
292}
293
294impl VisualMediaMessage {
295    /// Construct a `VisualMediaMessage` from the given message.
296    pub(crate) fn from_message(msgtype: &MessageType) -> Option<Self> {
297        match msgtype {
298            MessageType::Image(c) => Some(Self::Image(c.clone())),
299            MessageType::Video(c) => Some(Self::Video(c.clone())),
300            _ => None,
301        }
302    }
303
304    /// The filename of the media.
305    ///
306    /// For a sticker, this returns the description of the sticker.
307    pub(crate) fn filename(&self) -> String {
308        match self {
309            Self::Image(c) => filename!(c, Some(mime::IMAGE)),
310            Self::Video(c) => filename!(c, Some(mime::VIDEO)),
311            Self::Sticker(c) => c.body.clone(),
312        }
313    }
314
315    /// The dimensions of the media, if any.
316    pub(crate) fn dimensions(&self) -> Option<FrameDimensions> {
317        let (width, height) = match self {
318            Self::Image(c) => c.info.as_ref().map(|i| (i.width, i.height))?,
319            Self::Video(c) => c.info.as_ref().map(|i| (i.width, i.height))?,
320            Self::Sticker(c) => (c.info.width, c.info.height),
321        };
322        FrameDimensions::from_options(width, height)
323    }
324
325    /// The type of the media.
326    pub(crate) fn visual_media_type(&self) -> VisualMediaType {
327        match self {
328            Self::Image(_) => VisualMediaType::Image,
329            Self::Sticker(_) => VisualMediaType::Sticker,
330            Self::Video(_) => VisualMediaType::Video,
331        }
332    }
333
334    /// The content type of the media.
335    pub(crate) fn content_type(&self) -> ContentType {
336        match self {
337            Self::Image(_) | Self::Sticker(_) => ContentType::Image,
338            Self::Video(_) => ContentType::Video,
339        }
340    }
341
342    /// Get the Blurhash of the media, if any.
343    pub(crate) fn blurhash(&self) -> Option<Blurhash> {
344        match self {
345            Self::Image(image_content) => image_content.info.as_deref()?.blurhash.clone(),
346            Self::Sticker(sticker_content) => sticker_content.info.blurhash.clone(),
347            Self::Video(video_content) => video_content.info.as_deref()?.blurhash.clone(),
348        }
349        .map(Blurhash)
350    }
351
352    /// Fetch a thumbnail of the media with the given client and thumbnail
353    /// settings.
354    ///
355    /// This might not return a thumbnail at the requested size, depending on
356    /// the message and the homeserver.
357    ///
358    /// Returns `Ok(None)` if no thumbnail could be retrieved and no fallback
359    /// could be downloaded. This only applies to video messages.
360    ///
361    /// Returns an error if something occurred while fetching the content or
362    /// loading it.
363    pub(crate) async fn thumbnail(
364        &self,
365        client: Client,
366        settings: ThumbnailSettings,
367        priority: ImageRequestPriority,
368    ) -> Result<Option<Image>, ImageError> {
369        let downloader = match &self {
370            Self::Image(c) => {
371                let image_info = c.info.as_deref();
372                ThumbnailDownloader {
373                    main: ImageSource {
374                        source: (&c.source).into(),
375                        info: image_info.map(Into::into),
376                    },
377                    alt: image_info.and_then(|i| {
378                        i.thumbnail_source.as_ref().map(|s| ImageSource {
379                            source: s.into(),
380                            info: i.thumbnail_info.as_deref().map(Into::into),
381                        })
382                    }),
383                }
384            }
385            Self::Video(c) => {
386                let Some(video_info) = c.info.as_deref() else {
387                    return Ok(None);
388                };
389                let Some(thumbnail_source) = video_info.thumbnail_source.as_ref() else {
390                    return Ok(None);
391                };
392
393                ThumbnailDownloader {
394                    main: ImageSource {
395                        source: thumbnail_source.into(),
396                        info: video_info.thumbnail_info.as_deref().map(Into::into),
397                    },
398                    alt: None,
399                }
400            }
401            Self::Sticker(c) => {
402                let image_info = &c.info;
403                ThumbnailDownloader {
404                    main: ImageSource {
405                        source: (&c.source).into(),
406                        info: Some(image_info.into()),
407                    },
408                    alt: image_info.thumbnail_source.as_ref().map(|s| ImageSource {
409                        source: s.into(),
410                        info: image_info.thumbnail_info.as_deref().map(Into::into),
411                    }),
412                }
413            }
414        };
415
416        downloader
417            .download(client, settings, priority)
418            .await
419            .map(Some)
420    }
421
422    /// Fetch the content of the media with the given client and write it to a
423    /// temporary file.
424    ///
425    /// Returns an error if something occurred while fetching the content or
426    /// saving the content to a file.
427    pub(crate) async fn into_tmp_file(self, client: &Client) -> Result<File, MediaFileError> {
428        MediaMessage::from(self).into_tmp_file(client).await
429    }
430
431    /// Save the content of the media to a file selected by the user.
432    ///
433    /// Shows a dialog to the user to select a file on the system.
434    pub(crate) async fn save_to_file(
435        self,
436        timestamp: &glib::DateTime,
437        client: &Client,
438        parent: &impl IsA<gtk::Widget>,
439    ) {
440        MediaMessage::from(self)
441            .save_to_file(timestamp, client, parent)
442            .await;
443    }
444}
445
446impl From<ImageMessageEventContent> for VisualMediaMessage {
447    fn from(value: ImageMessageEventContent) -> Self {
448        Self::Image(value)
449    }
450}
451
452impl From<VideoMessageEventContent> for VisualMediaMessage {
453    fn from(value: VideoMessageEventContent) -> Self {
454        Self::Video(value)
455    }
456}
457
458impl From<StickerEventContent> for VisualMediaMessage {
459    fn from(value: StickerEventContent) -> Self {
460        Self::Sticker(value.into())
461    }
462}
463
464impl From<Box<StickerEventContent>> for VisualMediaMessage {
465    fn from(value: Box<StickerEventContent>) -> Self {
466        Self::Sticker(value)
467    }
468}
469
470impl From<VisualMediaMessage> for MediaMessage {
471    fn from(value: VisualMediaMessage) -> Self {
472        match value {
473            VisualMediaMessage::Image(c) => Self::Image(c),
474            VisualMediaMessage::Video(c) => Self::Video(c),
475            VisualMediaMessage::Sticker(c) => Self::Sticker(c),
476        }
477    }
478}
479
480/// The type of a visual media message.
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub(crate) enum VisualMediaType {
483    /// An image.
484    Image,
485    /// A video.
486    Video,
487    /// A sticker.
488    Sticker,
489}
490
491/// Extension trait for audio messages.
492pub(crate) trait AudioMessageExt {
493    /// Get the normalized waveform in this audio message, if any.
494    ///
495    /// A normalized waveform is a waveform containing only values between 0 and
496    /// 1.
497    fn normalized_waveform(&self) -> Option<Vec<f32>>;
498}
499
500impl AudioMessageExt for AudioMessageEventContent {
501    fn normalized_waveform(&self) -> Option<Vec<f32>> {
502        let waveform = &self.audio.as_ref()?.waveform;
503
504        if waveform.is_empty() {
505            return None;
506        }
507
508        Some(normalize_waveform(
509            waveform
510                .iter()
511                .map(|amplitude| u64::from(amplitude.get()) as f64)
512                .collect(),
513        ))
514    }
515}