matrix_sdk/room/
edit.rs

1// Copyright 2024 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Facilities to edit existing events.
16
17use 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
39/// The new content that will replace the previous event's content.
40pub enum EditedContent {
41    /// The content is a `m.room.message`.
42    RoomMessage(RoomMessageEventContentWithoutRelation),
43
44    /// Tweak a caption for a `m.room.message` that's a media.
45    MediaCaption {
46        /// New caption for the media.
47        ///
48        /// Set to `None` to remove an existing caption.
49        caption: Option<String>,
50
51        /// New formatted caption for the media.
52        ///
53        /// Set to `None` to remove an existing formatted caption.
54        formatted_caption: Option<FormattedBody>,
55
56        /// New set of intentional mentions to be included in the edited
57        /// caption.
58        mentions: Option<Mentions>,
59    },
60
61    /// The content is a new poll start.
62    PollStart {
63        /// New fallback text for the poll.
64        fallback_text: String,
65        /// New start block for the poll.
66        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/// An error occurring while editing an event.
82#[derive(Debug, Error)]
83pub enum EditError {
84    /// We tried to edit a state event, which is not allowed, per spec.
85    #[error("State events can't be edited")]
86    StateEvent,
87
88    /// We tried to edit an event which sender isn't the current user, which is
89    /// forbidden, per spec.
90    #[error("You're not the author of the event you'd like to edit.")]
91    NotAuthor,
92
93    /// We couldn't fetch the remote event with /room/event.
94    #[error("Couldn't fetch the remote event: {0}")]
95    Fetch(Box<crate::Error>),
96
97    /// We couldn't properly deserialize the target event.
98    #[error(transparent)]
99    Deserialize(#[from] serde_json::Error),
100
101    /// We tried to edit an event of type A with content of type B.
102    #[error("The original event type ({target}) isn't the same as the parameter's new content type ({new_content})")]
103    IncompatibleEditType {
104        /// The type of the target event.
105        target: String,
106        /// The type of the new content.
107        new_content: &'static str,
108    },
109}
110
111impl Room {
112    /// Create a new edit event for the target event id with the new content.
113    ///
114    /// The event can then be sent with [`Room::send`] or a
115    /// [`crate::send_queue::RoomSendQueue`].
116    #[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    // The event must be message-like.
138    let AnySyncTimelineEvent::MessageLike(message_like_event) = event else {
139        return Err(EditError::StateEvent);
140    };
141
142    // The event must have been sent by the current user.
143    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            // Handle edits of m.room.message.
150            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            // Handle edits of m.room.message.
173            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
227/// Sets the caption of a media event content.
228///
229/// Why a macro over a plain function: the event content types all differ from
230/// each other, and it would require adding a trait and implementing it for all
231/// event types instead of having this simple macro.
232macro_rules! set_caption {
233    ($event:expr, $caption:expr) => {
234        let filename = $event.filename().to_owned();
235        // As a reminder:
236        // - body and no filename set means the body is the filename
237        // - body and filename set means the body is the caption, and filename is the
238        //   filename.
239        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
249/// Sets the caption of a [`RoomMessageEventContent`].
250///
251/// Returns true if the event represented a media event (and thus the captions
252/// could be updated), false otherwise.
253pub(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        MessageType::Image(event) => {
273            set_caption!(event, caption);
274            event.formatted = formatted_caption;
275            true
276        }
277        MessageType::Video(event) => {
278            set_caption!(event, caption);
279            event.formatted = formatted_caption;
280            true
281        }
282        _ => false,
283    }
284}
285
286/// Try to find the original replied-to event content, in a best-effort manner.
287async fn extract_replied_to<S: EventSource>(
288    source: S,
289    room_id: &RoomId,
290    relates_to: Option<Relation<RoomMessageEventContentWithoutRelation>>,
291) -> Option<OriginalMessageLikeEvent<RoomMessageEventContent>> {
292    let replied_to_sync_timeline_event = if let Some(Relation::Reply { in_reply_to }) = relates_to {
293        source
294            .get_event(&in_reply_to.event_id)
295            .await
296            .map_err(|err| {
297                warn!("couldn't fetch the replied-to event, when editing: {err}");
298                err
299            })
300            .ok()
301    } else {
302        None
303    };
304
305    replied_to_sync_timeline_event
306        .and_then(|sync_timeline_event| {
307            sync_timeline_event
308                .raw()
309                .deserialize()
310                .map_err(|err| warn!("unable to deserialize replied-to event: {err}"))
311                .ok()
312        })
313        .and_then(|event| {
314            if let AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(
315                MessageLikeEvent::Original(original),
316            )) = event.into_full_event(room_id.to_owned())
317            {
318                Some(original)
319            } else {
320                None
321            }
322        })
323}
324
325#[cfg(test)]
326mod tests {
327    use std::collections::BTreeMap;
328
329    use assert_matches2::{assert_let, assert_matches};
330    use matrix_sdk_base::deserialized_responses::TimelineEvent;
331    use matrix_sdk_test::{async_test, event_factory::EventFactory};
332    use ruma::{
333        event_id,
334        events::{
335            room::message::{MessageType, Relation, RoomMessageEventContentWithoutRelation},
336            AnyMessageLikeEventContent, AnySyncTimelineEvent, Mentions,
337        },
338        owned_mxc_uri, owned_user_id, room_id, user_id, EventId, OwnedEventId,
339    };
340
341    use super::{make_edit_event, EditError, EventSource};
342    use crate::{room::edit::EditedContent, Error};
343
344    #[derive(Default)]
345    struct TestEventCache {
346        events: BTreeMap<OwnedEventId, TimelineEvent>,
347    }
348
349    impl EventSource for TestEventCache {
350        async fn get_event(&self, event_id: &EventId) -> Result<TimelineEvent, Error> {
351            Ok(self.events.get(event_id).unwrap().clone())
352        }
353    }
354
355    #[async_test]
356    async fn test_edit_state_event() {
357        let event_id = event_id!("$1");
358        let own_user_id = user_id!("@me:saucisse.bzh");
359
360        let mut cache = TestEventCache::default();
361        let f = EventFactory::new();
362        cache.events.insert(
363            event_id.to_owned(),
364            f.room_name("The room name").event_id(event_id).sender(own_user_id).into(),
365        );
366
367        let room_id = room_id!("!galette:saucisse.bzh");
368        let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
369
370        assert_matches!(
371            make_edit_event(
372                cache,
373                room_id,
374                own_user_id,
375                event_id,
376                EditedContent::RoomMessage(new_content),
377            )
378            .await,
379            Err(EditError::StateEvent)
380        );
381    }
382
383    #[async_test]
384    async fn test_edit_event_other_user() {
385        let event_id = event_id!("$1");
386        let f = EventFactory::new();
387
388        let mut cache = TestEventCache::default();
389
390        cache.events.insert(
391            event_id.to_owned(),
392            f.text_msg("hi").event_id(event_id).sender(user_id!("@other:saucisse.bzh")).into(),
393        );
394
395        let room_id = room_id!("!galette:saucisse.bzh");
396        let own_user_id = user_id!("@me:saucisse.bzh");
397        let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
398
399        assert_matches!(
400            make_edit_event(
401                cache,
402                room_id,
403                own_user_id,
404                event_id,
405                EditedContent::RoomMessage(new_content),
406            )
407            .await,
408            Err(EditError::NotAuthor)
409        );
410    }
411
412    #[async_test]
413    async fn test_make_edit_event_success() {
414        let event_id = event_id!("$1");
415        let own_user_id = user_id!("@me:saucisse.bzh");
416
417        let mut cache = TestEventCache::default();
418        let f = EventFactory::new();
419        cache.events.insert(
420            event_id.to_owned(),
421            f.text_msg("hi").event_id(event_id).sender(own_user_id).into(),
422        );
423
424        let room_id = room_id!("!galette:saucisse.bzh");
425        let new_content = RoomMessageEventContentWithoutRelation::text_plain("the edit");
426
427        let edit_event = make_edit_event(
428            cache,
429            room_id,
430            own_user_id,
431            event_id,
432            EditedContent::RoomMessage(new_content),
433        )
434        .await
435        .unwrap();
436
437        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &edit_event);
438        // This is the fallback text, for clients not supporting edits.
439        assert_eq!(msg.body(), "* the edit");
440        assert_let!(Some(Relation::Replacement(repl)) = &msg.relates_to);
441
442        assert_eq!(repl.event_id, event_id);
443        assert_eq!(repl.new_content.msgtype.body(), "the edit");
444    }
445
446    #[async_test]
447    async fn test_make_edit_caption_for_non_media_room_message() {
448        let event_id = event_id!("$1");
449        let own_user_id = user_id!("@me:saucisse.bzh");
450
451        let mut cache = TestEventCache::default();
452        let f = EventFactory::new();
453        cache.events.insert(
454            event_id.to_owned(),
455            f.text_msg("hello world").event_id(event_id).sender(own_user_id).into(),
456        );
457
458        let room_id = room_id!("!galette:saucisse.bzh");
459
460        let err = make_edit_event(
461            cache,
462            room_id,
463            own_user_id,
464            event_id,
465            EditedContent::MediaCaption {
466                caption: Some("yo".to_owned()),
467                formatted_caption: None,
468                mentions: None,
469            },
470        )
471        .await
472        .unwrap_err();
473
474        assert_let!(EditError::IncompatibleEditType { target, new_content } = err);
475        assert_eq!(target, "m.text");
476        assert_eq!(new_content, "caption for a media room message");
477    }
478
479    #[async_test]
480    async fn test_add_caption_for_media() {
481        let event_id = event_id!("$1");
482        let own_user_id = user_id!("@me:saucisse.bzh");
483
484        let filename = "rickroll.gif";
485
486        let mut cache = TestEventCache::default();
487        let f = EventFactory::new();
488        cache.events.insert(
489            event_id.to_owned(),
490            f.image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
491                .event_id(event_id)
492                .sender(own_user_id)
493                .into(),
494        );
495
496        let room_id = room_id!("!galette:saucisse.bzh");
497
498        let edit_event = make_edit_event(
499            cache,
500            room_id,
501            own_user_id,
502            event_id,
503            EditedContent::MediaCaption {
504                caption: Some("Best joke ever".to_owned()),
505                formatted_caption: None,
506                mentions: None,
507            },
508        )
509        .await
510        .unwrap();
511
512        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
513        assert_let!(MessageType::Image(image) = msg.msgtype);
514
515        assert_eq!(image.filename(), filename);
516        assert_eq!(image.caption(), Some("* Best joke ever")); // Fallback for a replacement 🤷
517        assert!(image.formatted_caption().is_none());
518
519        assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
520        assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
521        assert_eq!(new_image.filename(), filename);
522        assert_eq!(new_image.caption(), Some("Best joke ever"));
523        assert!(new_image.formatted_caption().is_none());
524    }
525
526    #[async_test]
527    async fn test_remove_caption_for_media() {
528        let event_id = event_id!("$1");
529        let own_user_id = user_id!("@me:saucisse.bzh");
530
531        let filename = "rickroll.gif";
532
533        let mut cache = TestEventCache::default();
534        let f = EventFactory::new();
535
536        let event = f
537            .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
538            .caption(Some("caption".to_owned()), None)
539            .event_id(event_id)
540            .sender(own_user_id)
541            .into_event();
542
543        {
544            // Sanity checks.
545            let event = event.raw().deserialize().unwrap();
546            assert_let!(AnySyncTimelineEvent::MessageLike(event) = event);
547            assert_let!(
548                AnyMessageLikeEventContent::RoomMessage(msg) = event.original_content().unwrap()
549            );
550            assert_let!(MessageType::Image(image) = msg.msgtype);
551            assert_eq!(image.filename(), filename);
552            assert_eq!(image.caption(), Some("caption"));
553            assert!(image.formatted_caption().is_none());
554        }
555
556        cache.events.insert(event_id.to_owned(), event);
557
558        let room_id = room_id!("!galette:saucisse.bzh");
559
560        let edit_event = make_edit_event(
561            cache,
562            room_id,
563            own_user_id,
564            event_id,
565            // Remove the caption by setting it to None.
566            EditedContent::MediaCaption { caption: None, formatted_caption: None, mentions: None },
567        )
568        .await
569        .unwrap();
570
571        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
572        assert_let!(MessageType::Image(image) = msg.msgtype);
573
574        assert_eq!(image.filename(), "* rickroll.gif"); // Fallback for a replacement 🤷
575        assert!(image.caption().is_none());
576        assert!(image.formatted_caption().is_none());
577
578        assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
579        assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
580        assert_eq!(new_image.filename(), "rickroll.gif");
581        assert!(new_image.caption().is_none());
582        assert!(new_image.formatted_caption().is_none());
583    }
584
585    #[async_test]
586    async fn test_add_media_caption_mention() {
587        let event_id = event_id!("$1");
588        let own_user_id = user_id!("@me:saucisse.bzh");
589
590        let filename = "rickroll.gif";
591
592        let mut cache = TestEventCache::default();
593        let f = EventFactory::new();
594
595        // Start with a media event that has no mentions.
596        let event = f
597            .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll"))
598            .event_id(event_id)
599            .sender(own_user_id)
600            .into_event();
601
602        {
603            // Sanity checks.
604            let event = event.raw().deserialize().unwrap();
605            assert_let!(AnySyncTimelineEvent::MessageLike(event) = event);
606            assert_let!(
607                AnyMessageLikeEventContent::RoomMessage(msg) = event.original_content().unwrap()
608            );
609            assert_matches!(msg.mentions, None);
610        }
611
612        cache.events.insert(event_id.to_owned(), event);
613
614        let room_id = room_id!("!galette:saucisse.bzh");
615
616        // Add an intentional mention in the caption.
617        let mentioned_user_id = owned_user_id!("@crepe:saucisse.bzh");
618        let edit_event = {
619            let mentions = Mentions::with_user_ids([mentioned_user_id.clone()]);
620            make_edit_event(
621                cache,
622                room_id,
623                own_user_id,
624                event_id,
625                EditedContent::MediaCaption {
626                    caption: None,
627                    formatted_caption: None,
628                    mentions: Some(mentions),
629                },
630            )
631            .await
632            .unwrap()
633        };
634
635        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event);
636        assert_let!(MessageType::Image(image) = msg.msgtype);
637
638        assert!(image.caption().is_none());
639        assert!(image.formatted_caption().is_none());
640
641        // The raw event contains the mention.
642        assert_let!(Some(mentions) = msg.mentions);
643        assert!(!mentions.room);
644        assert_eq!(
645            mentions.user_ids.into_iter().collect::<Vec<_>>(),
646            vec![mentioned_user_id.clone()]
647        );
648
649        assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to);
650        assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype);
651        assert!(new_image.caption().is_none());
652        assert!(new_image.formatted_caption().is_none());
653
654        // The replacement contains the mention.
655        assert_let!(Some(mentions) = repl.new_content.mentions);
656        assert!(!mentions.room);
657        assert_eq!(mentions.user_ids.into_iter().collect::<Vec<_>>(), vec![mentioned_user_id]);
658    }
659
660    #[async_test]
661    async fn test_make_edit_event_success_with_response() {
662        let event_id = event_id!("$1");
663        let resp_event_id = event_id!("$resp");
664        let own_user_id = user_id!("@me:saucisse.bzh");
665
666        let mut cache = TestEventCache::default();
667        let f = EventFactory::new();
668
669        cache.events.insert(
670            event_id.to_owned(),
671            f.text_msg("hi").event_id(event_id).sender(user_id!("@steb:saucisse.bzh")).into(),
672        );
673
674        cache.events.insert(
675            resp_event_id.to_owned(),
676            f.text_msg("you're the hi")
677                .event_id(resp_event_id)
678                .sender(own_user_id)
679                .reply_to(event_id)
680                .into(),
681        );
682
683        let room_id = room_id!("!galette:saucisse.bzh");
684        let new_content = RoomMessageEventContentWithoutRelation::text_plain("uh i mean hi too");
685
686        let edit_event = make_edit_event(
687            cache,
688            room_id,
689            own_user_id,
690            resp_event_id,
691            EditedContent::RoomMessage(new_content),
692        )
693        .await
694        .unwrap();
695
696        assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = &edit_event);
697        // This is the fallback text, for clients not supporting edits.
698        assert_eq!(
699            msg.body(),
700            r#"> <@steb:saucisse.bzh> hi
701
702* uh i mean hi too"#
703        );
704        assert_let!(Some(Relation::Replacement(repl)) = &msg.relates_to);
705
706        assert_eq!(repl.event_id, resp_event_id);
707        assert_eq!(repl.new_content.msgtype.body(), "uh i mean hi too");
708    }
709}