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