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