1use ruma::{
18 events::{
19 poll::unstable_start::{
20 ReplacementUnstablePollStartEventContent, UnstablePollStartContentBlock,
21 UnstablePollStartEventContent,
22 },
23 room::message::{
24 FormattedBody, MessageType, Relation, ReplacementMetadata, RoomMessageEventContent,
25 RoomMessageEventContentWithoutRelation,
26 },
27 AnyMessageLikeEvent, AnyMessageLikeEventContent, AnySyncMessageLikeEvent,
28 AnySyncTimelineEvent, AnyTimelineEvent, Mentions, MessageLikeEvent,
29 OriginalMessageLikeEvent, SyncMessageLikeEvent,
30 },
31 EventId, RoomId, UserId,
32};
33use thiserror::Error;
34use tracing::{instrument, warn};
35
36use super::EventSource;
37use crate::Room;
38
39pub enum EditedContent {
41 RoomMessage(RoomMessageEventContentWithoutRelation),
43
44 MediaCaption {
46 caption: Option<String>,
50
51 formatted_caption: Option<FormattedBody>,
55
56 mentions: Option<Mentions>,
59 },
60
61 PollStart {
63 fallback_text: String,
65 new_content: UnstablePollStartContentBlock,
67 },
68}
69
70#[cfg(not(tarpaulin_include))]
71impl std::fmt::Debug for EditedContent {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 Self::RoomMessage(_) => f.debug_tuple("RoomMessage").finish(),
75 Self::MediaCaption { .. } => f.debug_tuple("MediaCaption").finish(),
76 Self::PollStart { .. } => f.debug_tuple("PollStart").finish(),
77 }
78 }
79}
80
81#[derive(Debug, Error)]
83pub enum EditError {
84 #[error("State events can't be edited")]
86 StateEvent,
87
88 #[error("You're not the author of the event you'd like to edit.")]
91 NotAuthor,
92
93 #[error("Couldn't fetch the remote event: {0}")]
95 Fetch(Box<crate::Error>),
96
97 #[error(transparent)]
99 Deserialize(#[from] serde_json::Error),
100
101 #[error(
103 "The original event type ({target}) isn't the same as \
104 the parameter's new content type ({new_content})"
105 )]
106 IncompatibleEditType {
107 target: String,
109 new_content: &'static str,
111 },
112}
113
114impl Room {
115 #[instrument(skip(self, new_content), fields(room = %self.room_id()))]
120 pub async fn make_edit_event(
121 &self,
122 event_id: &EventId,
123 new_content: EditedContent,
124 ) -> Result<AnyMessageLikeEventContent, EditError> {
125 make_edit_event(self, self.room_id(), self.own_user_id(), event_id, new_content).await
126 }
127}
128
129async fn make_edit_event<S: EventSource>(
130 source: S,
131 room_id: &RoomId,
132 own_user_id: &UserId,
133 event_id: &EventId,
134 new_content: EditedContent,
135) -> Result<AnyMessageLikeEventContent, EditError> {
136 let target = source.get_event(event_id).await.map_err(|err| EditError::Fetch(Box::new(err)))?;
137
138 let event = target.raw().deserialize().map_err(EditError::Deserialize)?;
139
140 let AnySyncTimelineEvent::MessageLike(message_like_event) = event else {
142 return Err(EditError::StateEvent);
143 };
144
145 if message_like_event.sender() != own_user_id {
147 return Err(EditError::NotAuthor);
148 }
149
150 match new_content {
151 EditedContent::RoomMessage(new_content) => {
152 let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(original)) =
154 message_like_event
155 else {
156 return Err(EditError::IncompatibleEditType {
157 target: message_like_event.event_type().to_string(),
158 new_content: "room message",
159 });
160 };
161
162 let mentions = original.content.mentions.clone();
163 let replied_to_original_room_msg =
164 extract_replied_to(source, room_id, original.content.relates_to).await;
165
166 let replacement = new_content.make_replacement(
167 ReplacementMetadata::new(event_id.to_owned(), mentions),
168 replied_to_original_room_msg.as_ref(),
169 );
170
171 Ok(replacement.into())
172 }
173
174 EditedContent::MediaCaption { caption, formatted_caption, mentions } => {
175 let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(original)) =
177 message_like_event
178 else {
179 return Err(EditError::IncompatibleEditType {
180 target: message_like_event.event_type().to_string(),
181 new_content: "caption for a media room message",
182 });
183 };
184
185 let original_mentions = original.content.mentions.clone();
186 let replied_to_original_room_msg =
187 extract_replied_to(source, room_id, original.content.relates_to.clone()).await;
188
189 let mut prev_content = original.content;
190
191 if !update_media_caption(&mut prev_content, caption, formatted_caption, mentions) {
192 return Err(EditError::IncompatibleEditType {
193 target: prev_content.msgtype.msgtype().to_owned(),
194 new_content: "caption for a media room message",
195 });
196 }
197
198 let replacement = prev_content.make_replacement(
199 ReplacementMetadata::new(event_id.to_owned(), original_mentions),
200 replied_to_original_room_msg.as_ref(),
201 );
202
203 Ok(replacement.into())
204 }
205
206 EditedContent::PollStart { fallback_text, new_content } => {
207 if !matches!(
208 message_like_event,
209 AnySyncMessageLikeEvent::UnstablePollStart(SyncMessageLikeEvent::Original(_))
210 ) {
211 return Err(EditError::IncompatibleEditType {
212 target: message_like_event.event_type().to_string(),
213 new_content: "poll start",
214 });
215 }
216
217 let replacement = UnstablePollStartEventContent::Replacement(
218 ReplacementUnstablePollStartEventContent::plain_text(
219 fallback_text,
220 new_content,
221 event_id.to_owned(),
222 ),
223 );
224
225 Ok(replacement.into())
226 }
227 }
228}
229
230macro_rules! set_caption {
236 ($event:expr, $caption:expr) => {
237 let filename = $event.filename().to_owned();
238 if let Some(caption) = $caption {
243 $event.filename = Some(filename);
244 $event.body = caption;
245 } else {
246 $event.filename = None;
247 $event.body = filename;
248 }
249 };
250}
251
252pub(crate) fn update_media_caption(
257 content: &mut RoomMessageEventContent,
258 caption: Option<String>,
259 formatted_caption: Option<FormattedBody>,
260 mentions: Option<Mentions>,
261) -> bool {
262 content.mentions = mentions;
263
264 match &mut content.msgtype {
265 MessageType::Audio(event) => {
266 set_caption!(event, caption);
267 event.formatted = formatted_caption;
268 true
269 }
270 MessageType::File(event) => {
271 set_caption!(event, caption);
272 event.formatted = formatted_caption;
273 true
274 }
275 #[cfg(feature = "unstable-msc4274")]
276 MessageType::Gallery(event) => {
277 event.body = caption.unwrap_or_default();
278 event.formatted = formatted_caption;
279 true
280 }
281 MessageType::Image(event) => {
282 set_caption!(event, caption);
283 event.formatted = formatted_caption;
284 true
285 }
286 MessageType::Video(event) => {
287 set_caption!(event, caption);
288 event.formatted = formatted_caption;
289 true
290 }
291 _ => false,
292 }
293}
294
295async fn extract_replied_to<S: EventSource>(
297 source: S,
298 room_id: &RoomId,
299 relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
300) -> Option<OriginalMessageLikeEvent<RoomMessageEventContent>> {
301 let replied_to_sync_timeline_event = if let Some(Relation::Reply { in_reply_to }) = relates_to {
302 source
303 .get_event(&in_reply_to.event_id)
304 .await
305 .map_err(|err| {
306 warn!("couldn't fetch the replied-to event, when editing: {err}");
307 err
308 })
309 .ok()
310 } else {
311 None
312 };
313
314 replied_to_sync_timeline_event
315 .and_then(|sync_timeline_event| {
316 sync_timeline_event
317 .raw()
318 .deserialize()
319 .map_err(|err| warn!("unable to deserialize replied-to event: {err}"))
320 .ok()
321 })
322 .and_then(|event| {
323 if let AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(
324 MessageLikeEvent::Original(original),
325 )) = event.into_full_event(room_id.to_owned())
326 {
327 Some(original)
328 } else {
329 None
330 }
331 })
332}
333
334#[cfg(test)]
335mod tests {
336 use std::collections::BTreeMap;
337
338 use assert_matches2::{assert_let, assert_matches};
339 use matrix_sdk_base::deserialized_responses::TimelineEvent;
340 use matrix_sdk_test::{async_test, event_factory::EventFactory};
341 use ruma::{
342 event_id,
343 events::{
344 room::message::{MessageType, Relation, RoomMessageEventContentWithoutRelation},
345 AnyMessageLikeEventContent, AnySyncTimelineEvent, Mentions,
346 },
347 owned_mxc_uri, owned_user_id, room_id, user_id, EventId, OwnedEventId,
348 };
349
350 use super::{make_edit_event, EditError, EventSource};
351 use crate::{room::edit::EditedContent, Error};
352
353 #[derive(Default)]
354 struct TestEventCache {
355 events: BTreeMap<OwnedEventId, TimelineEvent>,
356 }
357
358 impl EventSource for TestEventCache {
359 async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, Error> {
360 Ok(self.events.get(event_id).unwrap().clone())
361 }
362 }
363
364 #[async_test]
365 async fn test_edit_state_event() {
366 let event_id = event_id!("$1");
367 let own_user_id = user_id!("@me:saucisse.bzh");
368
369 let mut cache = TestEventCache::default();
370 let f = EventFactory::new();
371 cache.events.insert(
372 event_id.to_owned(),
373 f.room_name("The room name").event_id(event_id).sender(own_user_id).into(),
374 );
375
376 let room_id = room_id!("!galette:saucisse.bzh");
377 let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
378
379 assert_matches!(
380 make_edit_event(
381 cache,
382 room_id,
383 own_user_id,
384 event_id,
385 EditedContent::RoomMessage(new_content),
386 )
387 .await,
388 Err(EditError::StateEvent)
389 );
390 }
391
392 #[async_test]
393 async fn test_edit_event_other_user() {
394 let event_id = event_id!("$1");
395 let f = EventFactory::new();
396
397 let mut cache = TestEventCache::default();
398
399 cache.events.insert(
400 event_id.to_owned(),
401 f.text_msg("hi").event_id(event_id).sender(user_id!("@other:saucisse.bzh")).into(),
402 );
403
404 let room_id = room_id!("!galette:saucisse.bzh");
405 let own_user_id = user_id!("@me:saucisse.bzh");
406 let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
407
408 assert_matches!(
409 make_edit_event(
410 cache,
411 room_id,
412 own_user_id,
413 event_id,
414 EditedContent::RoomMessage(new_content),
415 )
416 .await,
417 Err(EditError::NotAuthor)
418 );
419 }
420
421 #[async_test]
422 async fn test_make_edit_event_success() {
423 let event_id = event_id!("$1");
424 let own_user_id = user_id!("@me:saucisse.bzh");
425
426 let mut cache = TestEventCache::default();
427 let f = EventFactory::new();
428 cache.events.insert(
429 event_id.to_owned(),
430 f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
431 );
432
433 let room_id = room_id!("!galette:saucisse.bzh");
434 let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
435
436 let edit_event = make_edit_event(
437 cache,
438 room_id,
439 own_user_id,
440 event_id,
441 EditedContent::RoomMessage(new_content),
442 )
443 .await
444 .unwrap();
445
446 assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &edit_event);
447 assert_eq!(msg.body(), "* the edit");
449 assert_let!(Some(Relation::Replacement(repl)) = &msg.relates_to);
450
451 assert_eq!(repl.event_id, event_id);
452 assert_eq!(repl.new_content.msgtype.body(), "the edit");
453 }
454
455 #[async_test]
456 async fn test_make_edit_caption_for_non_media_room_message() {
457 let event_id = event_id!("$1");
458 let own_user_id = user_id!("@me:saucisse.bzh");
459
460 let mut cache = TestEventCache::default();
461 let f = EventFactory::new();
462 cache.events.insert(
463 event_id.to_owned(),
464 f.text_msg("hello world").event_id(event_id).sender(own_user_id).into(),
465 );
466
467 let room_id = room_id!("!galette:saucisse.bzh");
468
469 let err = make_edit_event(
470 cache,
471 room_id,
472 own_user_id,
473 event_id,
474 EditedContent::MediaCaption {
475 caption: Some("yo".to_owned()),
476 formatted_caption: None,
477 mentions: None,
478 },
479 )
480 .await
481 .unwrap_err();
482
483 assert_let!(EditError::IncompatibleEditType { target, new_content } = err);
484 assert_eq!(target, "m.text");
485 assert_eq!(new_content, "caption for a media room message");
486 }
487
488 #[async_test]
489 async fn test_add_caption_for_media() {
490 let event_id = event_id!("$1");
491 let own_user_id = user_id!("@me:saucisse.bzh");
492
493 let filename = "rickroll.gif";
494
495 let mut cache = TestEventCache::default();
496 let f = EventFactory::new();
497 cache.events.insert(
498 event_id.to_owned(),
499 f.image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
500 .event_id(event_id)
501 .sender(own_user_id)
502 .into(),
503 );
504
505 let room_id = room_id!("!galette:saucisse.bzh");
506
507 let edit_event = make_edit_event(
508 cache,
509 room_id,
510 own_user_id,
511 event_id,
512 EditedContent::MediaCaption {
513 caption: Some("Best joke ever".to_owned()),
514 formatted_caption: None,
515 mentions: None,
516 },
517 )
518 .await
519 .unwrap();
520
521 assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
522 assert_let!(MessageType::Image(image) = msg.msgtype);
523
524 assert_eq!(image.filename(), filename);
525 assert_eq!(image.caption(), Some("* Best joke ever")); assert!(image.formatted_caption().is_none());
527
528 assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
529 assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
530 assert_eq!(new_image.filename(), filename);
531 assert_eq!(new_image.caption(), Some("Best joke ever"));
532 assert!(new_image.formatted_caption().is_none());
533 }
534
535 #[async_test]
536 async fn test_remove_caption_for_media() {
537 let event_id = event_id!("$1");
538 let own_user_id = user_id!("@me:saucisse.bzh");
539
540 let filename = "rickroll.gif";
541
542 let mut cache = TestEventCache::default();
543 let f = EventFactory::new();
544
545 let event = f
546 .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
547 .caption(Some("caption".to_owned()), None)
548 .event_id(event_id)
549 .sender(own_user_id)
550 .into_event();
551
552 {
553 let event = event.raw().deserialize().unwrap();
555 assert_let!(AnySyncTimelineEvent::MessageLike(event) = event);
556 assert_let!(
557 AnyMessageLikeEventContent::RoomMessage(msg) = event.original_content().unwrap()
558 );
559 assert_let!(MessageType::Image(image) = msg.msgtype);
560 assert_eq!(image.filename(), filename);
561 assert_eq!(image.caption(), Some("caption"));
562 assert!(image.formatted_caption().is_none());
563 }
564
565 cache.events.insert(event_id.to_owned(), event);
566
567 let room_id = room_id!("!galette:saucisse.bzh");
568
569 let edit_event = make_edit_event(
570 cache,
571 room_id,
572 own_user_id,
573 event_id,
574 EditedContent::MediaCaption { caption: None, formatted_caption: None, mentions: None },
576 )
577 .await
578 .unwrap();
579
580 assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
581 assert_let!(MessageType::Image(image) = msg.msgtype);
582
583 assert_eq!(image.filename(), "* rickroll.gif"); assert!(image.caption().is_none());
585 assert!(image.formatted_caption().is_none());
586
587 assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
588 assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
589 assert_eq!(new_image.filename(), "rickroll.gif");
590 assert!(new_image.caption().is_none());
591 assert!(new_image.formatted_caption().is_none());
592 }
593
594 #[async_test]
595 async fn test_add_media_caption_mention() {
596 let event_id = event_id!("$1");
597 let own_user_id = user_id!("@me:saucisse.bzh");
598
599 let filename = "rickroll.gif";
600
601 let mut cache = TestEventCache::default();
602 let f = EventFactory::new();
603
604 let event = f
606 .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
607 .event_id(event_id)
608 .sender(own_user_id)
609 .into_event();
610
611 {
612 let event = event.raw().deserialize().unwrap();
614 assert_let!(AnySyncTimelineEvent::MessageLike(event) = event);
615 assert_let!(
616 AnyMessageLikeEventContent::RoomMessage(msg) = event.original_content().unwrap()
617 );
618 assert_matches!(msg.mentions, None);
619 }
620
621 cache.events.insert(event_id.to_owned(), event);
622
623 let room_id = room_id!("!galette:saucisse.bzh");
624
625 let mentioned_user_id = owned_user_id!("@crepe:saucisse.bzh");
627 let edit_event = {
628 let mentions = Mentions::with_user_ids([mentioned_user_id.clone()]);
629 make_edit_event(
630 cache,
631 room_id,
632 own_user_id,
633 event_id,
634 EditedContent::MediaCaption {
635 caption: None,
636 formatted_caption: None,
637 mentions: Some(mentions),
638 },
639 )
640 .await
641 .unwrap()
642 };
643
644 assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
645 assert_let!(MessageType::Image(image) = msg.msgtype);
646
647 assert!(image.caption().is_none());
648 assert!(image.formatted_caption().is_none());
649
650 assert_let!(Some(mentions) = msg.mentions);
652 assert!(!mentions.room);
653 assert_eq!(
654 mentions.user_ids.into_iter().collect::<Vec<_>>(),
655 vec![mentioned_user_id.clone()]
656 );
657
658 assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
659 assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
660 assert!(new_image.caption().is_none());
661 assert!(new_image.formatted_caption().is_none());
662
663 assert_let!(Some(mentions) = repl.new_content.mentions);
665 assert!(!mentions.room);
666 assert_eq!(mentions.user_ids.into_iter().collect::<Vec<_>>(), vec![mentioned_user_id]);
667 }
668
669 #[async_test]
670 async fn test_make_edit_event_success_with_response() {
671 let event_id = event_id!("$1");
672 let resp_event_id = event_id!("$resp");
673 let own_user_id = user_id!("@me:saucisse.bzh");
674
675 let mut cache = TestEventCache::default();
676 let f = EventFactory::new();
677
678 cache.events.insert(
679 event_id.to_owned(),
680 f.text_msg("hi").event_id(event_id).sender(user_id!("@steb:saucisse.bzh")).into(),
681 );
682
683 cache.events.insert(
684 resp_event_id.to_owned(),
685 f.text_msg("you're the hi")
686 .event_id(resp_event_id)
687 .sender(own_user_id)
688 .reply_to(event_id)
689 .into(),
690 );
691
692 let room_id = room_id!("!galette:saucisse.bzh");
693 let new_content = RoomMessageEventContentWithoutRelation::text_plain("uh i mean hi too");
694
695 let edit_event = make_edit_event(
696 cache,
697 room_id,
698 own_user_id,
699 resp_event_id,
700 EditedContent::RoomMessage(new_content),
701 )
702 .await
703 .unwrap();
704
705 assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &edit_event);
706 assert_eq!(
708 msg.body(),
709 r#"> <@steb:saucisse.bzh> hi
710
711* uh i mean hi too"#
712 );
713 assert_let!(Some(Relation::Replacement(repl)) = &msg.relates_to);
714
715 assert_eq!(repl.event_id, resp_event_id);
716 assert_eq!(repl.new_content.msgtype.body(), "uh i mean hi too");
717 }
718}