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 #[default]
27 Natural = 0,
28
29 Compact = 1,
36
37 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 #[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 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 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 pub(crate) fn visual_media_widget(&self) -> Option<MessageVisualMedia> {
98 let mut child = BinExt::child(self)?;
99
100 if let Some(reply) = child.downcast_ref::<MessageReply>() {
102 child = BinExt::child(reply.content())?;
103 }
104
105 if let Some(caption) = child.downcast_ref::<MessageCaption>() {
107 child = caption.child()?;
108 }
109
110 child.downcast::<MessageVisualMedia>().ok()
111 }
112
113 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 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 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 pub(crate) fn texture(&self) -> Option<gdk::Texture> {
210 self.visual_media_widget()?.texture()
211 }
212}
213
214trait MessageContentContainer: IsA<gtk::Widget> {
216 fn child(&self) -> Option<gtk::Widget>;
218
219 fn set_child(&self, child: Option<gtk::Widget>);
221
222 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 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 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 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 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#[derive(Debug, Clone, Default)]
464pub(crate) struct MessageCacheKey {
465 transaction_id: Option<OwnedTransactionId>,
470 event_id: Option<OwnedEventId>,
475 is_edited: bool,
479}
480
481impl MessageCacheKey {
482 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}