fractal/session/view/content/room_history/message_row/
content.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{gdk, glib, glib::clone};
4use matrix_sdk_ui::timeline::{
5    Message, RepliedToInfo, ReplyContent, TimelineDetails, TimelineItemContent,
6};
7use ruma::{events::room::message::MessageType, OwnedEventId, OwnedTransactionId};
8use tracing::{error, warn};
9
10use super::{
11    audio::MessageAudio, caption::MessageCaption, file::MessageFile, location::MessageLocation,
12    reply::MessageReply, text::MessageText, visual_media::MessageVisualMedia,
13};
14use crate::{
15    prelude::*,
16    session::model::{Event, Member, Room, Session},
17    spawn,
18    utils::matrix::MediaMessage,
19};
20
21#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
22#[repr(i32)]
23#[enum_type(name = "ContentFormat")]
24pub enum ContentFormat {
25    /// The content should appear at its natural size.
26    #[default]
27    Natural = 0,
28
29    /// The content should appear in a smaller format without interactions, if
30    /// possible.
31    ///
32    /// This has no effect on text replies.
33    ///
34    /// The related events of replies are not displayed.
35    Compact = 1,
36
37    /// Like `Compact`, but the content should be ellipsized if possible to show
38    /// only a single line.
39    Ellipsized = 2,
40}
41
42mod imp {
43    use std::cell::Cell;
44
45    use super::*;
46
47    #[derive(Debug, Default, glib::Properties)]
48    #[properties(wrapper_type = super::MessageContent)]
49    pub struct MessageContent {
50        /// The displayed format of the message.
51        #[property(get, set = Self::set_format, explicit_notify, builder(ContentFormat::default()))]
52        format: Cell<ContentFormat>,
53    }
54
55    #[glib::object_subclass]
56    impl ObjectSubclass for MessageContent {
57        const NAME: &'static str = "ContentMessageContent";
58        type Type = super::MessageContent;
59        type ParentType = adw::Bin;
60    }
61
62    #[glib::derived_properties]
63    impl ObjectImpl for MessageContent {}
64
65    impl WidgetImpl for MessageContent {}
66    impl BinImpl for MessageContent {}
67
68    impl MessageContent {
69        /// Set the displayed format of the message.
70        fn set_format(&self, format: ContentFormat) {
71            if self.format.get() == format {
72                return;
73            }
74
75            self.format.set(format);
76            self.obj().notify_format();
77        }
78    }
79}
80
81glib::wrapper! {
82    /// The content of a message in the timeline.
83    pub struct MessageContent(ObjectSubclass<imp::MessageContent>)
84        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
85}
86
87impl MessageContent {
88    pub fn new() -> Self {
89        glib::Object::new()
90    }
91
92    /// Access the widget with the visual media content of the event, if any.
93    ///
94    /// This allows to access the descendant content while discarding the
95    /// content of a related message, like a replied-to event, or the caption of
96    /// the event.
97    pub(crate) fn visual_media_widget(&self) -> Option<MessageVisualMedia> {
98        let mut child = BinExt::child(self)?;
99
100        // If it is a reply, the media is in the main content.
101        if let Some(reply) = child.downcast_ref::<MessageReply>() {
102            child = BinExt::child(reply.content())?;
103        }
104
105        // If it is a caption, the media is the child of the caption.
106        if let Some(caption) = child.downcast_ref::<MessageCaption>() {
107            child = caption.child()?;
108        }
109
110        child.downcast::<MessageVisualMedia>().ok()
111    }
112
113    /// Update this widget to present the given `Event`.
114    pub(crate) fn update_for_event(&self, event: &Event) {
115        let detect_at_room = event.can_contain_at_room() && event.sender().can_notify_room();
116
117        let format = self.format();
118        if format == ContentFormat::Natural {
119            if let Some(related_content) = event.reply_to_event_content() {
120                match related_content {
121                    TimelineDetails::Unavailable => {
122                        spawn!(
123                            glib::Priority::HIGH,
124                            clone!(
125                                #[weak]
126                                event,
127                                async move {
128                                    if let Err(error) = event.fetch_missing_details().await {
129                                        error!("Could not fetch event details: {error}");
130                                    }
131                                }
132                            )
133                        );
134                    }
135                    TimelineDetails::Error(error) => {
136                        error!(
137                            "Could not fetch replied to event '{}': {error}",
138                            event.reply_to_id().unwrap()
139                        );
140                    }
141                    TimelineDetails::Ready(replied_to_event) => {
142                        // We should have a strong reference to the list in the RoomHistory so we
143                        // can use `get_or_create_members()`.
144                        let replied_to_sender = event
145                            .room()
146                            .get_or_create_members()
147                            .get_or_create(replied_to_event.sender().to_owned());
148                        let replied_to_content = replied_to_event.content();
149                        let replied_to_detect_at_room = replied_to_content.can_contain_at_room()
150                            && replied_to_sender.can_notify_room();
151
152                        let reply = MessageReply::new();
153                        reply.set_show_related_content_header(replied_to_content.can_show_header());
154                        reply.set_related_content_sender(replied_to_sender.upcast_ref());
155                        reply.related_content().build_content(
156                            replied_to_content.clone(),
157                            ContentFormat::Compact,
158                            &replied_to_sender,
159                            replied_to_detect_at_room,
160                            None,
161                            event.reply_to_id(),
162                        );
163                        reply.content().build_content(
164                            event.content(),
165                            ContentFormat::Natural,
166                            &event.sender(),
167                            detect_at_room,
168                            event.transaction_id(),
169                            event.event_id(),
170                        );
171                        BinExt::set_child(self, Some(&reply));
172
173                        return;
174                    }
175                    TimelineDetails::Pending => {}
176                }
177            }
178        }
179
180        self.build_content(
181            event.content(),
182            format,
183            &event.sender(),
184            detect_at_room,
185            event.transaction_id(),
186            event.event_id(),
187        );
188    }
189
190    /// Update this widget to present the given related event.
191    pub(crate) fn update_for_related_event(&self, info: &RepliedToInfo, sender: &Member) {
192        let ReplyContent::Message(message) = info.content() else {
193            return;
194        };
195
196        let detect_at_room = message.can_contain_at_room() && sender.can_notify_room();
197
198        self.build_message_content(
199            message,
200            self.format(),
201            sender,
202            detect_at_room,
203            None,
204            Some(info.event_id().to_owned()),
205        );
206    }
207
208    /// Get the texture displayed by this widget, if any.
209    pub(crate) fn texture(&self) -> Option<gdk::Texture> {
210        self.visual_media_widget()?.texture()
211    }
212}
213
214/// Helper trait for types used to build a message's content.
215trait MessageContentContainer: IsA<gtk::Widget> {
216    /// Get the child of this widget
217    fn child(&self) -> Option<gtk::Widget>;
218
219    /// Set the child of this widget
220    fn set_child(&self, child: Option<gtk::Widget>);
221
222    /// Reuse the child of this widget if it is of the correct type `W`, or
223    /// replace it with a new `W` constructed with its `Default` implementation.
224    ///
225    /// Returns the reused or new widget.
226    fn reuse_child_or_default<W: IsA<gtk::Widget> + Clone + Default>(&self) -> W {
227        if let Some(child) = self.child().and_downcast::<W>() {
228            child
229        } else {
230            let child = W::default();
231            self.set_child(Some(child.clone().upcast()));
232            child
233        }
234    }
235
236    /// Build the content widget of `event` as a child of this widget.
237    fn build_content(
238        &self,
239        content: TimelineItemContent,
240        format: ContentFormat,
241        sender: &Member,
242        detect_at_room: bool,
243        transaction_id: Option<OwnedTransactionId>,
244        event_id: Option<OwnedEventId>,
245    ) {
246        let room = sender.room();
247
248        match content {
249            TimelineItemContent::Message(message) => {
250                self.build_message_content(
251                    &message,
252                    format,
253                    sender,
254                    detect_at_room,
255                    transaction_id,
256                    event_id,
257                );
258            }
259            TimelineItemContent::Sticker(sticker) => {
260                self.build_media_message_content(
261                    sticker.content().clone().into(),
262                    format,
263                    &room,
264                    detect_at_room,
265                    MessageCacheKey {
266                        transaction_id,
267                        event_id,
268                        is_edited: false,
269                    },
270                );
271            }
272            TimelineItemContent::UnableToDecrypt(_) => {
273                let child = self.reuse_child_or_default::<MessageText>();
274                child.with_plain_text(gettext("Could not decrypt this message, decryption will be retried once the keys are available."), format);
275            }
276            TimelineItemContent::RedactedMessage => {
277                let child = self.reuse_child_or_default::<MessageText>();
278                child.with_plain_text(gettext("This message was removed."), format);
279            }
280            content => {
281                warn!("Unsupported event content: {content:?}");
282                let child = self.reuse_child_or_default::<MessageText>();
283                child.with_plain_text(gettext("Unsupported event"), format);
284            }
285        }
286    }
287
288    /// Build the content widget of the given message as a child of this widget.
289    fn build_message_content(
290        &self,
291        message: &Message,
292        format: ContentFormat,
293        sender: &Member,
294        detect_at_room: bool,
295        transaction_id: Option<OwnedTransactionId>,
296        event_id: Option<OwnedEventId>,
297    ) {
298        let room = sender.room();
299
300        if let Some(media_message) = MediaMessage::from_message(message.msgtype()) {
301            self.build_media_message_content(
302                media_message,
303                format,
304                &room,
305                detect_at_room,
306                MessageCacheKey {
307                    transaction_id,
308                    event_id,
309                    is_edited: message.is_edited(),
310                },
311            );
312            return;
313        }
314
315        match message.msgtype() {
316            MessageType::Emote(message) => {
317                let child = self.reuse_child_or_default::<MessageText>();
318                child.with_emote(
319                    message.formatted.clone(),
320                    message.body.clone(),
321                    sender,
322                    &room,
323                    format,
324                    detect_at_room,
325                );
326            }
327            MessageType::Location(message) => {
328                let child = self.reuse_child_or_default::<MessageLocation>();
329                child.set_geo_uri(&message.geo_uri, format);
330            }
331            MessageType::Notice(message) => {
332                let child = self.reuse_child_or_default::<MessageText>();
333                child.with_markup(
334                    message.formatted.clone(),
335                    message.body.clone(),
336                    &room,
337                    format,
338                    detect_at_room,
339                );
340            }
341            MessageType::ServerNotice(message) => {
342                let child = self.reuse_child_or_default::<MessageText>();
343                child.with_plain_text(message.body.clone(), format);
344            }
345            MessageType::Text(message) => {
346                let child = self.reuse_child_or_default::<MessageText>();
347                child.with_markup(
348                    message.formatted.clone(),
349                    message.body.clone(),
350                    &room,
351                    format,
352                    detect_at_room,
353                );
354            }
355            msgtype => {
356                warn!("Event not supported: {msgtype:?}");
357                let child = self.reuse_child_or_default::<MessageText>();
358                child.with_plain_text(gettext("Unsupported event"), format);
359            }
360        }
361    }
362
363    /// Build the content widget of the given media message as a child of this
364    /// widget.
365    fn build_media_message_content(
366        &self,
367        media_message: MediaMessage,
368        format: ContentFormat,
369        room: &Room,
370        detect_at_room: bool,
371        cache_key: MessageCacheKey,
372    ) {
373        let Some(session) = room.session() else {
374            return;
375        };
376
377        if let Some((caption, formatted_caption)) = media_message.caption() {
378            let caption_widget = self.reuse_child_or_default::<MessageCaption>();
379
380            caption_widget.set_caption(
381                caption.to_owned(),
382                formatted_caption.cloned(),
383                room,
384                format,
385                detect_at_room,
386            );
387
388            caption_widget.build_media_content(media_message, format, &session, cache_key);
389        } else {
390            self.build_media_content(media_message, format, &session, cache_key);
391        }
392    }
393
394    /// Build the content widget of the given media content as the child of this
395    /// widget.
396    ///
397    /// If the child of the parent is already of the proper type, it is reused.
398    fn build_media_content(
399        &self,
400        media_message: MediaMessage,
401        format: ContentFormat,
402        session: &Session,
403        cache_key: MessageCacheKey,
404    ) {
405        match media_message {
406            MediaMessage::Audio(audio) => {
407                let widget = self.reuse_child_or_default::<MessageAudio>();
408                widget.audio(audio.into(), session, format, cache_key);
409            }
410            MediaMessage::File(file) => {
411                let widget = self.reuse_child_or_default::<MessageFile>();
412
413                let media_message = MediaMessage::from(file);
414                widget.set_filename(Some(media_message.filename()));
415                widget.set_format(format);
416            }
417            MediaMessage::Image(image) => {
418                let widget = self.reuse_child_or_default::<MessageVisualMedia>();
419                widget.set_media_message(image.into(), session, format, cache_key);
420            }
421            MediaMessage::Video(video) => {
422                let widget = self.reuse_child_or_default::<MessageVisualMedia>();
423                widget.set_media_message(video.into(), session, format, cache_key);
424            }
425            MediaMessage::Sticker(sticker) => {
426                let widget = self.reuse_child_or_default::<MessageVisualMedia>();
427                widget.set_media_message(sticker.into(), session, format, cache_key);
428            }
429        }
430    }
431}
432
433impl<W> MessageContentContainer for W
434where
435    W: IsA<adw::Bin> + IsA<gtk::Widget>,
436{
437    fn child(&self) -> Option<gtk::Widget> {
438        BinExt::child(self)
439    }
440
441    fn set_child(&self, child: Option<gtk::Widget>) {
442        BinExt::set_child(self, child.as_ref());
443    }
444}
445
446impl MessageContentContainer for MessageCaption {
447    fn child(&self) -> Option<gtk::Widget> {
448        self.child()
449    }
450
451    fn set_child(&self, child: Option<gtk::Widget>) {
452        self.set_child(child);
453    }
454}
455
456/// The data used as a cache key for messages.
457///
458/// This is used when there is no reliable way to detect if the content of a
459/// message changed. For example, the URI of a media file might change between a
460/// local echo and a remote echo, but we do not need to reload the media in this
461/// case, and we have no other way to know that both URIs point to the same
462/// file.
463#[derive(Debug, Clone, Default)]
464pub(crate) struct MessageCacheKey {
465    /// The transaction ID of the event.
466    ///
467    /// Local echo should keep its transaction ID after the message is sent, so
468    /// we do not need to reload the message if it did not change.
469    transaction_id: Option<OwnedTransactionId>,
470    /// The global ID of the event.
471    ///
472    /// Local echo that was sent and remote echo should have the same event ID,
473    /// so we do not need to reload the message if it did not change.
474    event_id: Option<OwnedEventId>,
475    /// Whether the message is edited.
476    ///
477    /// The message must be reloaded when it was edited.
478    is_edited: bool,
479}
480
481impl MessageCacheKey {
482    /// Whether the given new `MessageCacheKey` should trigger a reload of the
483    /// mmessage compared to this one.
484    pub(super) fn should_reload(&self, new: &MessageCacheKey) -> bool {
485        if new.is_edited {
486            return true;
487        }
488
489        let transaction_id_invalidated = self.transaction_id.is_none()
490            || new.transaction_id.is_none()
491            || self.transaction_id != new.transaction_id;
492        let event_id_invalidated =
493            self.event_id.is_none() || new.event_id.is_none() || self.event_id != new.event_id;
494
495        transaction_id_invalidated && event_id_invalidated
496    }
497}